Enable CMS-managed site images

This commit is contained in:
Kevin
2026-09-04 01:48:38 -04:00
parent 357a0f0dc1
commit d76de0ccb4
26 changed files with 229 additions and 44 deletions
+2 -2
View File
@@ -37,8 +37,8 @@ Each selected site builds to `dist/<site-key>/`. The Worker configurations use
only static assets and a custom 404 page. They contain no custom domains,
routes, account IDs, or secrets.
`npm run verify:cms` checks the fixed Gitea branch/backend, all four Decap
collection contracts, the text-first raster-media boundary, the pinned admin
`npm run verify:cms` checks the fixed Gitea branch/backend, the four Markdown
content contracts, the fixed page-image record, the bounded raster-media boundary, the pinned admin
shell, and the stable secret-free pull-request status context. The live steps
remain unapplied; see the [Gitea pilot settings](docs/decap-cms-gitea-setup.md)
for the exact review checklist.
+21 -10
View File
@@ -64,12 +64,16 @@ the pilot:
value rather than a floating release range.
- The backend is fixed to `SloanOps/3031023-xyz` and `cms-pilot` and contains
only the public client ID for the non-confidential Gitea PKCE application.
- All four Markdown collections and every persisted frontmatter field are
modeled with the current Astro/Zod bounds. Markdown controls are text-first;
image embedding and general file widgets are absent.
- The team image field accepts an existing approved raster path or a bounded
upload. URL insertion and SVG paths are rejected. Builds additionally verify
the extension, raster signature, flat upload path, and 500 KiB limit.
- All four Markdown content collections and every persisted frontmatter field
are modeled with the current Astro/Zod bounds. Markdown controls remain
text-first; embedded images and general file widgets are absent.
- A fixed image-only Markdown record controls the primary home, About,
Services, and Contact page imagery without exposing either `site.json`.
- Team and page-image fields accept optional bounded uploads. Editors can add,
replace, or remove an image reference while retaining accessible image
descriptions and a layout fallback. URL insertion and SVG paths are
rejected. Builds additionally verify the extension, raster signature, flat
upload path, and 500 KiB limit.
- `.gitea/workflows/cms-content-validation.yml` runs on every pull request into
`main`, uses no secrets, and reports
`CMS content validation / cms-content-validation (pull_request)` after
@@ -126,9 +130,10 @@ Allow editing and creation only within these collections:
| --- | --- | --- |
| Primary site FAQs | `sites/www/content/faq/**` | Create, edit, draft, reorder, delete after confirmation |
| Primary services | `sites/www/content/services/**` | Create, edit, draft, reorder, feature |
| Primary team | `sites/www/content/team/**` | Create, edit, draft, reorder, select an approved image |
| Primary team | `sites/www/content/team/**` | Create, edit, draft, reorder, add, replace, or remove a portrait |
| Primary page images | `sites/www/content/page-images/primary.md` | Add, replace, or remove home, About, Services, and Contact images and edit their descriptions |
| Journal posts | `sites/blog/content/posts/**` | Create, edit, draft, date, tag, update |
| Shared pilot media | `public/images/uploads/**` | Upload bounded raster images after text editing is proven |
| Shared pilot media | `public/images/uploads/**` | Select or upload bounded raster images |
Do not expose `sites.json`, either `site.json`, schemas, workflows, Wrangler
configuration, package files, source components, scripts, contact routing, or
@@ -139,6 +144,12 @@ not accept executables, archives, HTML, scripts, or SVG uploads. Asset rights,
alt text, crop, dimensions, and optimization remain operator review items even
when the CMS accepts an upload.
The media gallery lists only `public/images/uploads`, so the pilot seeds that
directory with the images referenced by CMS-controlled fields. Removing an
image from content clears the field and uses the page's text-only or initials
fallback; it does not delete a potentially shared asset. Delete a gallery asset
only after confirming that no content entry references it.
## Repository protection model
### `main`
@@ -256,8 +267,8 @@ not record OAuth tokens or Cloudflare credentials.
| DC-06 | Create, edit, and delete a disposable journal post | Filename, dates, tags, body, draft state, and cleanup remain schema-valid. |
| DC-07 | Edit a primary FAQ | Only the primary preview rebuilds and displays the change. |
| DC-08 | Edit service ordering and featured state | YAML types remain correct and the primary verifier passes. |
| DC-09 | Edit a team entry without changing its image | Existing fields and image path are preserved exactly. |
| DC-10 | Upload a bounded raster image | File lands only in the approved media path and renders on the relevant preview. |
| DC-09 | Add, replace, and remove a team portrait | The selected upload renders in the team card; removal uses the initials fallback. |
| DC-10 | Upload a bounded raster image and select it in the fixed page-image record | File lands only in the approved media path and renders on the relevant primary preview page. |
| DC-11 | Attempt an oversized upload and a disallowed file type | The CMS size limit rejects the oversized file before upload; Gitea's unprotected-extension patterns reject the disallowed path. |
| DC-12 | Attempt to modify `.gitea/workflows/**`, source, package, script, schema, Wrangler, and site-manifest paths with the CMS account | Gitea rejects every push. |
| DC-13 | Attempt to create a differently named branch and change a protected file | The fallback rule rejects the push; branch naming cannot bypass protection. |
+89 -1
View File
@@ -141,9 +141,10 @@ collections:
- label: Approved raster image
name: image
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/(?:uploads/)?[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an existing approved image or a JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config:
max_file_size: 512000
@@ -166,6 +167,93 @@ collections:
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
editor_components: []
- name: primary_page_images
label: Primary site — Page images
description: Optional images and required accessible descriptions for the primary site's main pages.
files:
- label: Home, About, Services, and Contact
name: primary
file: sites/www/content/page-images/primary.md
fields:
- label: Home hero image
name: homeHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Home hero image description
name: homeHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: Home story image
name: homeStoryImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Home story image description
name: homeStoryImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: About hero image
name: aboutHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: About hero image description
name: aboutHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: About story image
name: aboutStoryImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: About story image description
name: aboutStoryImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: Services hero image
name: servicesHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Services hero image description
name: servicesHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: Contact hero image
name: contactHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Contact hero image description
name: contactHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- name: journal_posts
label: Journal — Posts
label_singular: Journal post
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+5
View File
@@ -40,6 +40,11 @@ if (key === 'www') {
for (const title of ['Dog walking', 'Grooming', 'Overnight care']) if (!services.includes(title)) throw new Error(`Service Markdown entry is missing: ${title}.`);
const about = await readFile(resolve(root, 'about/index.html'), 'utf8');
for (const name of ['Sarah Mitchell', 'Marcus Rivera', 'Emily Chen', 'David Okonkwo']) if (!about.includes(name)) throw new Error(`Team Markdown entry is missing: ${name}.`);
for (const image of ['/images/uploads/hero-dogs.jpg', '/images/uploads/happy-tails-team.jpg']) if (!home.includes(image)) throw new Error(`CMS-controlled homepage image is missing: ${image}.`);
for (const image of ['/images/uploads/sarah-mitchell.jpg', '/images/uploads/marcus-rivera.jpg', '/images/uploads/emily-chen.jpg', '/images/uploads/david-okonkwo.jpg']) if (!about.includes(image)) throw new Error(`CMS-controlled team image is missing: ${image}.`);
const contact = await readFile(resolve(root, 'contact/index.html'), 'utf8');
if (!contact.includes('/images/uploads/dog-walking.jpg')) throw new Error('CMS-controlled contact image is missing.');
if (!services.includes('/images/uploads/dog-grooming.jpg')) throw new Error('CMS-controlled services image is missing.');
}
if (key === 'blog') {
const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist', 'weekend-care-prep'];
+50 -17
View File
@@ -44,6 +44,19 @@ const markdownField = {
buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'],
editor_components: [],
};
const cmsImagePattern = [
'^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:jpe?g|png|webp|avif)$',
'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.',
];
const optionalImageField = {
widget: 'image',
required: false,
choose_url: false,
allow_multiple: false,
pattern: cmsImagePattern,
media_library: { config: { max_file_size: 512000, multiple: false } },
};
const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] };
const expectedCollections = {
primary_faq: {
folder: 'sites/www/content/faq',
@@ -75,16 +88,7 @@ const expectedCollections = {
fields: {
name: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
role: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
image: {
widget: 'image',
choose_url: false,
allow_multiple: false,
pattern: [
'^/images/(?:uploads/)?[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:jpe?g|png|webp|avif)$',
'Choose an existing approved image or a JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.',
],
media_library: { config: { max_file_size: 512000, multiple: false } },
},
image: optionalImageField,
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
draft: { widget: 'boolean', default: false },
body: markdownField,
@@ -106,8 +110,8 @@ const expectedCollections = {
};
assert(Array.isArray(config.collections), 'collections must be an array.');
assert(same(config.collections.map(({ name }) => name).sort(), Object.keys(expectedCollections).sort()), 'Decap must expose exactly the four approved collections.');
for (const collection of config.collections) {
assert(same(config.collections.map(({ name }) => name).sort(), [...Object.keys(expectedCollections), 'primary_page_images'].sort()), 'Decap must expose the four content collections and one fixed page-image collection.');
for (const collection of config.collections.filter(({ name }) => expectedCollections[name])) {
const expected = expectedCollections[collection.name];
expectSubset(collection, {
folder: expected.folder,
@@ -123,14 +127,39 @@ for (const collection of config.collections) {
for (const [fieldName, fieldContract] of Object.entries(expected.fields)) expectSubset(fields[fieldName], fieldContract, `${collection.name}.${fieldName}`);
}
const pageImageCollection = config.collections.find(({ name }) => name === 'primary_page_images');
assert(pageImageCollection.folder === undefined && pageImageCollection.create === undefined && pageImageCollection.delete === undefined, 'Page images must remain a fixed file collection.');
assert(Array.isArray(pageImageCollection.files) && pageImageCollection.files.length === 1, 'Page images must expose exactly one fixed record.');
const pageImageFile = pageImageCollection.files[0];
expectSubset(pageImageFile, { name: 'primary', file: 'sites/www/content/page-images/primary.md' }, 'primary_page_images.primary');
const expectedPageImageFields = {
homeHeroImage: optionalImageField,
homeHeroImageAlt: imageAltField,
homeStoryImage: optionalImageField,
homeStoryImageAlt: imageAltField,
aboutHeroImage: optionalImageField,
aboutHeroImageAlt: imageAltField,
aboutStoryImage: optionalImageField,
aboutStoryImageAlt: imageAltField,
servicesHeroImage: optionalImageField,
servicesHeroImageAlt: imageAltField,
contactHeroImage: optionalImageField,
contactHeroImageAlt: imageAltField,
};
const pageImageFields = Object.fromEntries(pageImageFile.fields.map((field) => [field.name, field]));
assert(same(Object.keys(pageImageFields).sort(), Object.keys(expectedPageImageFields).sort()), 'Page images must expose only the six approved image and description pairs.');
for (const [fieldName, fieldContract] of Object.entries(expectedPageImageFields)) expectSubset(pageImageFields[fieldName], fieldContract, `primary_page_images.${fieldName}`);
function visitField(field) {
assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`);
if (field.fields) field.fields.forEach(visitField);
if (field.field) visitField(field.field);
}
config.collections.flatMap(({ fields }) => fields).forEach(visitField);
const imageFields = config.collections.flatMap(({ fields }) => fields).filter(({ widget }) => widget === 'image');
assert(imageFields.length === 1 && imageFields[0].name === 'image', 'Only the bounded team raster-image field may expose media selection.');
const allFields = config.collections.flatMap(({ fields, files }) => fields ?? files.flatMap((file) => file.fields));
allFields.forEach(visitField);
const imageFields = allFields.filter(({ widget }) => widget === 'image');
assert(imageFields.length === 7, 'Only the team image and six fixed page-image fields may expose media selection.');
for (const field of imageFields) expectSubset(field, optionalImageField, `image field ${field.name}`);
const schema = await readFile('src/content.config.ts', 'utf8');
for (const fragment of [
@@ -145,7 +174,10 @@ for (const fragment of [
'items: z.array(z.string().min(3).max(80)).min(1).max(8)',
'name: z.string().min(3).max(80)',
'role: z.string().min(3).max(80)',
"image: z.string().startsWith('/images/')",
'const optionalCmsImage = z.preprocess(',
'image: optionalCmsImage',
'homeHeroImage: optionalCmsImage',
'contactHeroImageAlt: z.string().min(8).max(180)',
'order: z.number().int().min(0).max(99).default(0)',
'draft: z.boolean().default(false)',
'featured: z.boolean().default(false)',
@@ -165,6 +197,7 @@ async function verifyContentPaths(directory) {
}
}
for (const directory of Object.values(expectedCollections).map(({ folder }) => folder)) await verifyContentPaths(directory);
await verifyContentPaths('sites/www/content/page-images');
const admin = await readFile('public/admin/index.html', 'utf8');
assert(admin.includes('content="noindex,nofollow,noarchive"'), 'The admin shell must always carry no-index metadata.');
@@ -187,4 +220,4 @@ for (const command of ['npm run verify:cms', 'SITE_KEY=www npm run build:site',
assert(workflowText.includes(command), `Validation workflow is missing: ${command}`);
}
console.log('Verified Decap backend, four content contracts, text-first media boundary, admin pin, and stable PR status context.');
console.log('Verified Decap backend, four content contracts, fixed page images, bounded media, admin pin, and stable PR status context.');
+14
View File
@@ -0,0 +1,14 @@
---
homeHeroImage: /images/uploads/hero-dogs.jpg
homeHeroImageAlt: Two happy dogs playing outdoors
homeStoryImage: /images/uploads/happy-tails-team.jpg
homeStoryImageAlt: Happy Tails team with dogs in the park
aboutHeroImage: /images/uploads/happy-tails-team.jpg
aboutHeroImageAlt: Dogs and the Happy Tails team enjoying the park
aboutStoryImage: /images/uploads/happy-tails-team.jpg
aboutStoryImageAlt: Happy Tails team with dogs in the park
servicesHeroImage: /images/uploads/dog-grooming.jpg
servicesHeroImageAlt: A dog receiving gentle grooming care
contactHeroImage: /images/uploads/dog-walking.jpg
contactHeroImageAlt: A happy dog out for a walk
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: David Okonkwo
role: Overnight Care
image: /images/david-okonkwo.jpg
image: /images/uploads/david-okonkwo.jpg
order: 40
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Emily Chen
role: Pack Walker
image: /images/emily-chen.jpg
image: /images/uploads/emily-chen.jpg
order: 30
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Marcus Rivera
role: Lead Trainer
image: /images/marcus-rivera.jpg
image: /images/uploads/marcus-rivera.jpg
order: 20
---
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: Sarah Mitchell
role: Founder & Head Groomer
image: /images/sarah-mitchell.jpg
image: /images/uploads/sarah-mitchell.jpg
order: 10
---
+3 -2
View File
@@ -1,9 +1,10 @@
---
import { site } from '../lib/site-data.js';
const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props;
---
<section class="hero">
<div class="shell hero-grid">
<div class:list={['shell hero-grid', { 'hero-grid--text-only': !image }]}>
<div class="hero-copy"><p class="eyebrow">{site.hero.eyebrow}</p><h1>{site.hero.headline}</h1><p class="lede">{site.hero.copy}</p><a class="button" href={site.hero.ctaHref}>{site.hero.cta} <span aria-hidden="true">→</span></a></div>
<div class="hero-image"><img src={site.hero.image} alt={site.hero.imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div>
{image && <div class="hero-image"><img src={image} alt={imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div>}
</div>
</section>
+1 -1
View File
@@ -2,7 +2,7 @@
const { eyebrow, title, lede, image, imageAlt } = Astro.props;
---
<section class="page-intro">
<div class="shell page-intro-grid">
<div class:list={['shell page-intro-grid', { 'page-intro-grid--text-only': !image }]}>
<div><p class="eyebrow">{eyebrow}</p><h1>{title}</h1><p class="lede">{lede}</p></div>
{image && <img src={image} alt={imageAlt} loading="eager" />}
</div>
+25 -2
View File
@@ -1,6 +1,11 @@
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const optionalCmsImage = z.preprocess(
(value) => value === '' || value === null ? undefined : value,
z.string().regex(/^\/images\/uploads\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$/).optional(),
);
const blog = defineCollection({
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
schema: z.object({
@@ -40,10 +45,28 @@ const team = defineCollection({
schema: z.object({
name: z.string().min(3).max(80),
role: z.string().min(3).max(80),
image: z.string().startsWith('/images/'),
image: optionalCmsImage,
order: z.number().int().min(0).max(99).default(0),
draft: z.boolean().default(false),
}),
});
export const collections = { blog, faq, services, team };
const pageImages = defineCollection({
loader: glob({ base: './sites/www/content/page-images', pattern: '**/*.md' }),
schema: z.object({
homeHeroImage: optionalCmsImage,
homeHeroImageAlt: z.string().min(8).max(180),
homeStoryImage: optionalCmsImage,
homeStoryImageAlt: z.string().min(8).max(180),
aboutHeroImage: optionalCmsImage,
aboutHeroImageAlt: z.string().min(8).max(180),
aboutStoryImage: optionalCmsImage,
aboutStoryImageAlt: z.string().min(8).max(180),
servicesHeroImage: optionalCmsImage,
servicesHeroImageAlt: z.string().min(8).max(180),
contactHeroImage: optionalCmsImage,
contactHeroImageAlt: z.string().min(8).max(180),
}),
});
export const collections = { blog, faq, services, team, pageImages };
+2 -1
View File
@@ -6,5 +6,6 @@ import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
const team = (await getCollection('team', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
const teamCards = await Promise.all(team.map(async (member) => ({ member, Content: (await render(member)).Content })));
const pageImages = (await getCollection('pageImages'))[0]?.data;
---
<BaseLayout title={`About | ${site.name}`}><PageIntro eyebrow="Our pack" title="We know that great care starts with knowing your dog." lede="The Happy Tails team brings patience, playfulness, and real experience to every visit." image="/images/happy-tails-team.jpg" imageAlt="Dogs and the Happy Tails team enjoying the park" /><section class="section"><div class="shell story-grid"><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /></div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{teamCards.map(({ member, Content }) => <article class="team-card"><div class="team-avatar" aria-hidden="true">{member.data.name.split(' ').map((part) => part[0]).join('')}</div><div><p class="eyebrow">{member.data.role}</p><h3>{member.data.name}</h3><Content /></div></article>)}</div></div></section></BaseLayout>
<BaseLayout title={`About | ${site.name}`}><PageIntro eyebrow="Our pack" title="We know that great care starts with knowing your dog." lede="The Happy Tails team brings patience, playfulness, and real experience to every visit." image={pageImages?.aboutHeroImage} imageAlt={pageImages?.aboutHeroImageAlt} /><section class="section"><div class:list={['shell story-grid', { 'story-grid--text-only': !pageImages?.aboutStoryImage }]}><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div>{pageImages?.aboutStoryImage && <img src={pageImages.aboutStoryImage} alt={pageImages.aboutStoryImageAlt} loading="lazy" />}</div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{teamCards.map(({ member, Content }) => <article class="team-card">{member.data.image ? <img class="team-photo" src={member.data.image} alt={`${member.data.name}, ${member.data.role}`} loading="lazy" /> : <div class="team-avatar" aria-hidden="true">{member.data.name.split(' ').map((part) => part[0]).join('')}</div>}<div><p class="eyebrow">{member.data.role}</p><h3>{member.data.name}</h3><Content /></div></article>)}</div></div></section></BaseLayout>
+3 -1
View File
@@ -2,7 +2,9 @@
import BaseLayout from '../layouts/BaseLayout.astro';
import PageIntro from '../components/PageIntro.astro';
import LocationMap from '../components/LocationMap.astro';
import { getCollection } from 'astro:content';
import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
const pageImages = (await getCollection('pageImages'))[0]?.data;
---
<BaseLayout title={`Contact | ${site.name}`}><PageIntro eyebrow="Get in touch" title="Let's make your dog's day." lede="Reach out to ask a question or start planning the right care routine." image="/images/dog-walking.jpg" imageAlt="A happy dog out for a walk" /><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section>{site.contact.showMap && <section class="section surface"><div class="shell"><LocationMap address={site.contact.address} label={site.name} /></div></section>}</BaseLayout>
<BaseLayout title={`Contact | ${site.name}`}><PageIntro eyebrow="Get in touch" title="Let's make your dog's day." lede="Reach out to ask a question or start planning the right care routine." image={pageImages?.contactHeroImage} imageAlt={pageImages?.contactHeroImageAlt} /><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section>{site.contact.showMap && <section class="section surface"><div class="shell"><LocationMap address={site.contact.address} label={site.name} /></div></section>}</BaseLayout>
+5 -2
View File
@@ -6,12 +6,15 @@ import Hero from '../components/Hero.astro';
import { isBlog, site } from '../lib/site-data.js';
const posts = isBlog ? (await getCollection('blog', ({ data }) => !data.draft)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) : [];
const faqs = !isBlog ? (await getCollection('faq', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order) : [];
const pageImages = !isBlog ? (await getCollection('pageImages'))[0]?.data : undefined;
const heroImage = isBlog ? undefined : (pageImages?.homeHeroImage ?? null);
const heroImageAlt = isBlog ? undefined : pageImages?.homeHeroImageAlt;
---
<BaseLayout>
<Hero />
<Hero image={heroImage} imageAlt={heroImageAlt} />
{isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{posts.map((post) => <article class="post-card"><p class="tag">{post.data.tags.join(' · ')}</p><h3><a href={`/posts/${post.id}/`}>{post.data.title}</a></h3><p>{post.data.description}</p><footer>{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}</footer></article>)}</div><p class="phase-note">This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.</p></div></section> : <>
<section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section>
<section class="section surface"><div class="shell story-grid"><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /><div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section>
<section class="section surface"><div class:list={['shell story-grid', { 'story-grid--text-only': !pageImages?.homeStoryImage }]}>{pageImages?.homeStoryImage && <img src={pageImages.homeStoryImage} alt={pageImages.homeStoryImageAlt} loading="lazy" />}<div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section>
<section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq entries={faqs} />
</>}
</BaseLayout>
+2 -1
View File
@@ -7,5 +7,6 @@ import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/');
const services = (await getCollection('services', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
const serviceCards = await Promise.all(services.map(async (service) => ({ service, Content: (await render(service)).Content })));
const pageImages = (await getCollection('pageImages'))[0]?.data;
---
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image="/images/dog-grooming.jpg" imageAlt="A dog receiving gentle grooming care" /><section class="section"><div class="shell service-grid">{serviceCards.map(({ service, Content }) => <article class:list={['service-card', { featured: service.data.featured }]}><p class="eyebrow">{service.data.price}</p><h2>{service.data.title}</h2><Content /><ul>{service.data.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.data.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout>
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image={pageImages?.servicesHeroImage} imageAlt={pageImages?.servicesHeroImageAlt} /><section class="section"><div class="shell service-grid">{serviceCards.map(({ service, Content }) => <article class:list={['service-card', { featured: service.data.featured }]}><p class="eyebrow">{service.data.price}</p><h2>{service.data.title}</h2><Content /><ul>{service.data.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.data.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout>
+3
View File
@@ -20,6 +20,7 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.menu-button { display: none; font: inherit; font-weight: 600; color: var(--ink); background: transparent; border: 0; }
.hero { padding: clamp(4rem, 9vw, 8rem) 0; overflow: hidden; background: linear-gradient(110deg, var(--surface) 0%, var(--surface) 68%, #fff 68%); }
.hero-grid,.story-grid,.contact-grid { display: grid; grid-template-columns: 1.02fr .98fr; align-items: center; gap: clamp(3rem, 7vw, 8rem); }
.hero-grid--text-only,.story-grid--text-only { grid-template-columns: minmax(0, 720px); }
.hero-copy { padding: 1rem 0; }
.eyebrow,.tag { color: color-mix(in srgb, var(--accent), var(--ink) 25%); text-transform: uppercase; letter-spacing: .13em; font-size: .73rem; font-weight: 700; margin-bottom: 1rem; }
.lede { color: #52616b; font-size: clamp(1.1rem, 2vw, 1.32rem); max-width: 48ch; }
@@ -45,7 +46,9 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.review footer { font-weight: 700; }.review footer span { display: block; margin-top: .15rem; color: #75828c; font-size: .82rem; font-weight: 400; }
.review-disclosure,.phase-note { margin: 1.8rem auto 0; color: #66747d; font-size: .86rem; max-width: 75ch; }
.page-intro { padding: clamp(3rem, 5vw, 4.75rem) 0; background: var(--surface); overflow: hidden; }.page-intro-grid { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; gap: clamp(2rem, 6vw, 6rem); }.page-intro h1 { max-width: 15ch; font-size: clamp(2.75rem, 5vw, 5rem); }.page-intro .lede { max-width: 55ch; }.page-intro img { width: 100%; aspect-ratio: 1.25 / 1; object-fit: cover; border-radius: 1rem; box-shadow: 1rem 1rem 0 color-mix(in srgb, var(--accent), transparent 80%); }
.page-intro-grid--text-only { grid-template-columns: minmax(0, 820px); }
.team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }.team-card { display: grid; grid-template-columns: 120px 1fr; overflow: hidden; background: #fff; border: 1px solid #e8ecea; border-radius: .85rem; transition: transform .25s ease, box-shadow .25s ease; }.team-card:hover { transform: translateY(-.35rem); box-shadow: 0 1rem 2rem #102a4312; }.team-avatar { display: grid; place-items: center; margin: 1.2rem 0 1.2rem 1.2rem; border-radius: .6rem; color: var(--ink); background: linear-gradient(145deg, var(--surface), color-mix(in srgb, var(--accent), #fff 75%)); font-family: 'DM Serif Display', Georgia, serif; font-size: 2rem; letter-spacing: -.08em; }.team-card > div:last-child { padding: 1.5rem; }
.team-photo { width: 120px; height: 100%; min-height: 180px; object-fit: cover; }
.service-card { padding: 2rem; border: 1px solid #dce4e1; border-radius: .85rem; background: #fff; transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease; }.service-card:hover { transform: translateY(-.35rem); border-color: color-mix(in srgb, var(--accent), #fff 65%); box-shadow: 0 1rem 2rem #102a4312; }.service-card.featured { border: 2px solid var(--accent); transform: translateY(-.5rem); }.service-card.featured:hover { transform: translateY(-.7rem); }.service-card h2 { font-size: 2.2rem; }.service-card ul { padding: 0; list-style: none; }.service-card li { padding: .4rem 0; border-top: 1px solid #e8ecea; }.service-card li::before { content: '✓'; color: var(--accent); font-weight: 700; margin-right: .55rem; }
.centered { text-align: center; }.centered .lede { margin-inline: auto; }
.contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; }