diff --git a/README.md b/README.md index be02665..850383b 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Each selected site builds to `dist//`. 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. diff --git a/docs/decap-cms-pilot.md b/docs/decap-cms-pilot.md index 8d42ba9..85b140d 100644 --- a/docs/decap-cms-pilot.md +++ b/docs/decap-cms-pilot.md @@ -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. | diff --git a/public/admin/config.yml b/public/admin/config.yml index 427db1a..2c17c6f 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -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 diff --git a/public/images/uploads/david-okonkwo.jpg b/public/images/uploads/david-okonkwo.jpg new file mode 100644 index 0000000..e297f88 Binary files /dev/null and b/public/images/uploads/david-okonkwo.jpg differ diff --git a/public/images/uploads/dog-grooming.jpg b/public/images/uploads/dog-grooming.jpg new file mode 100644 index 0000000..6563795 Binary files /dev/null and b/public/images/uploads/dog-grooming.jpg differ diff --git a/public/images/uploads/dog-walking.jpg b/public/images/uploads/dog-walking.jpg new file mode 100644 index 0000000..2758e8e Binary files /dev/null and b/public/images/uploads/dog-walking.jpg differ diff --git a/public/images/uploads/emily-chen.jpg b/public/images/uploads/emily-chen.jpg new file mode 100644 index 0000000..9f68d8b Binary files /dev/null and b/public/images/uploads/emily-chen.jpg differ diff --git a/public/images/uploads/happy-tails-team.jpg b/public/images/uploads/happy-tails-team.jpg new file mode 100644 index 0000000..ed27456 Binary files /dev/null and b/public/images/uploads/happy-tails-team.jpg differ diff --git a/public/images/uploads/hero-dogs.jpg b/public/images/uploads/hero-dogs.jpg new file mode 100644 index 0000000..45ee1ab Binary files /dev/null and b/public/images/uploads/hero-dogs.jpg differ diff --git a/public/images/uploads/marcus-rivera.jpg b/public/images/uploads/marcus-rivera.jpg new file mode 100644 index 0000000..51a0d05 Binary files /dev/null and b/public/images/uploads/marcus-rivera.jpg differ diff --git a/public/images/uploads/sarah-mitchell.jpg b/public/images/uploads/sarah-mitchell.jpg new file mode 100644 index 0000000..ed22d81 Binary files /dev/null and b/public/images/uploads/sarah-mitchell.jpg differ diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index aa03904..9cef6ae 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -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']; diff --git a/scripts/verify-decap.mjs b/scripts/verify-decap.mjs index 2db55c6..7471685 100644 --- a/scripts/verify-decap.mjs +++ b/scripts/verify-decap.mjs @@ -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.'); diff --git a/sites/www/content/page-images/primary.md b/sites/www/content/page-images/primary.md new file mode 100644 index 0000000..82c7b8a --- /dev/null +++ b/sites/www/content/page-images/primary.md @@ -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 +--- diff --git a/sites/www/content/team/david-okonkwo.md b/sites/www/content/team/david-okonkwo.md index b79bb0f..80d2888 100644 --- a/sites/www/content/team/david-okonkwo.md +++ b/sites/www/content/team/david-okonkwo.md @@ -1,7 +1,7 @@ --- name: David Okonkwo role: Overnight Care -image: /images/david-okonkwo.jpg +image: /images/uploads/david-okonkwo.jpg order: 40 --- diff --git a/sites/www/content/team/emily-chen.md b/sites/www/content/team/emily-chen.md index 4aa9b76..956570a 100644 --- a/sites/www/content/team/emily-chen.md +++ b/sites/www/content/team/emily-chen.md @@ -1,7 +1,7 @@ --- name: Emily Chen role: Pack Walker -image: /images/emily-chen.jpg +image: /images/uploads/emily-chen.jpg order: 30 --- diff --git a/sites/www/content/team/marcus-rivera.md b/sites/www/content/team/marcus-rivera.md index 94ad4b6..f649864 100644 --- a/sites/www/content/team/marcus-rivera.md +++ b/sites/www/content/team/marcus-rivera.md @@ -1,7 +1,7 @@ --- name: Marcus Rivera role: Lead Trainer -image: /images/marcus-rivera.jpg +image: /images/uploads/marcus-rivera.jpg order: 20 --- diff --git a/sites/www/content/team/sarah-mitchell.md b/sites/www/content/team/sarah-mitchell.md index e418090..2642c65 100644 --- a/sites/www/content/team/sarah-mitchell.md +++ b/sites/www/content/team/sarah-mitchell.md @@ -1,7 +1,7 @@ --- name: Sarah Mitchell role: Founder & Head Groomer -image: /images/sarah-mitchell.jpg +image: /images/uploads/sarah-mitchell.jpg order: 10 --- diff --git a/src/components/Hero.astro b/src/components/Hero.astro index 1b23b63..12f1ae8 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -1,9 +1,10 @@ --- import { site } from '../lib/site-data.js'; +const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props; ---
-
+

{site.hero.eyebrow}

{site.hero.headline}

{site.hero.copy}

{site.hero.cta}
-
{site.hero.imageAlt}
Kind care. Every day.
+ {image &&
{imageAlt}
Kind care. Every day.
}
diff --git a/src/components/PageIntro.astro b/src/components/PageIntro.astro index 050f2d1..93bc328 100644 --- a/src/components/PageIntro.astro +++ b/src/components/PageIntro.astro @@ -2,7 +2,7 @@ const { eyebrow, title, lede, image, imageAlt } = Astro.props; ---
-
+

{eyebrow}

{title}

{lede}

{image && {imageAlt}}
diff --git a/src/content.config.ts b/src/content.config.ts index aae28d5..c11e262 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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 }; diff --git a/src/pages/about.astro b/src/pages/about.astro index c9dd5a5..b68bfee 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -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; --- -

How we work

{site.story.heading}

{site.story.copy}

Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.

{site.story.imageAlt}

Meet the team

Good people for good dogs.

{teamCards.map(({ member, Content }) =>

{member.data.role}

{member.data.name}

)}
+

How we work

{site.story.heading}

{site.story.copy}

Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.

{pageImages?.aboutStoryImage && {pageImages.aboutStoryImageAlt}}

Meet the team

Good people for good dogs.

{teamCards.map(({ member, Content }) =>
{member.data.image ? {`${member.data.name}, : }

{member.data.role}

{member.data.name}

)}
diff --git a/src/pages/contact.astro b/src/pages/contact.astro index cc93da4..db3b128 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -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; --- -

Happy Tails Pet Care

We would love to hear from you.

{site.contact.address}

{site.contact.phone}

{site.contact.email}

{site.contact.hours}

This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.

{site.contact.showMap &&
}
+

Happy Tails Pet Care

We would love to hear from you.

{site.contact.address}

{site.contact.phone}

{site.contact.email}

{site.contact.hours}

This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.

{site.contact.showMap &&
}
diff --git a/src/pages/index.astro b/src/pages/index.astro index fde9143..fde60a4 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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; --- - + {isBlog ?

The journal

Small notes for happier dog days.

{posts.map((post) =>

{post.data.tags.join(' · ')}

{post.data.title}

{post.data.description}

{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}
)}

This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.

: <>

The Happy Tails difference

A familiar face, a thoughtful routine, a very happy dog.

We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.

{site.features.map((feature) =>
{feature.icon}

{feature.title}

{feature.description}

)}
-
{site.story.imageAlt}

Our story

{site.story.heading}

{site.story.copy}

Meet the team →
+
{pageImages?.homeStoryImage && {pageImages.homeStoryImageAlt}}

Our story

{site.story.heading}

{site.story.copy}

Meet the team →

Loved by local families

Five-star care, shared in their own words.

{site.reviews.map((review) =>
★★★★★

“{review.quote}”

{review.author} Google review
)}

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.

}
diff --git a/src/pages/services.astro b/src/pages/services.astro index ecccb7f..b4bdfb9 100644 --- a/src/pages/services.astro +++ b/src/pages/services.astro @@ -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; --- -
{serviceCards.map(({ service, Content }) => )}
+
{serviceCards.map(({ service, Content }) => )}
diff --git a/src/styles/global.css b/src/styles/global.css index 4cec8d4..73d9b3b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; }