Enable CMS-managed site images
@@ -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,
|
only static assets and a custom 404 page. They contain no custom domains,
|
||||||
routes, account IDs, or secrets.
|
routes, account IDs, or secrets.
|
||||||
|
|
||||||
`npm run verify:cms` checks the fixed Gitea branch/backend, all four Decap
|
`npm run verify:cms` checks the fixed Gitea branch/backend, the four Markdown
|
||||||
collection contracts, the text-first raster-media boundary, the pinned admin
|
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
|
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)
|
remain unapplied; see the [Gitea pilot settings](docs/decap-cms-gitea-setup.md)
|
||||||
for the exact review checklist.
|
for the exact review checklist.
|
||||||
|
|||||||
@@ -64,12 +64,16 @@ the pilot:
|
|||||||
value rather than a floating release range.
|
value rather than a floating release range.
|
||||||
- The backend is fixed to `SloanOps/3031023-xyz` and `cms-pilot` and contains
|
- 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.
|
only the public client ID for the non-confidential Gitea PKCE application.
|
||||||
- All four Markdown collections and every persisted frontmatter field are
|
- All four Markdown content collections and every persisted frontmatter field
|
||||||
modeled with the current Astro/Zod bounds. Markdown controls are text-first;
|
are modeled with the current Astro/Zod bounds. Markdown controls remain
|
||||||
image embedding and general file widgets are absent.
|
text-first; embedded images and general file widgets are absent.
|
||||||
- The team image field accepts an existing approved raster path or a bounded
|
- A fixed image-only Markdown record controls the primary home, About,
|
||||||
upload. URL insertion and SVG paths are rejected. Builds additionally verify
|
Services, and Contact page imagery without exposing either `site.json`.
|
||||||
the extension, raster signature, flat upload path, and 500 KiB limit.
|
- 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
|
- `.gitea/workflows/cms-content-validation.yml` runs on every pull request into
|
||||||
`main`, uses no secrets, and reports
|
`main`, uses no secrets, and reports
|
||||||
`CMS content validation / cms-content-validation (pull_request)` after
|
`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 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 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 |
|
| 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
|
Do not expose `sites.json`, either `site.json`, schemas, workflows, Wrangler
|
||||||
configuration, package files, source components, scripts, contact routing, or
|
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
|
alt text, crop, dimensions, and optimization remain operator review items even
|
||||||
when the CMS accepts an upload.
|
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
|
## Repository protection model
|
||||||
|
|
||||||
### `main`
|
### `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-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-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-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-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 | File lands only in the approved media path and renders on the relevant preview. |
|
| 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-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-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. |
|
| 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. |
|
||||||
|
|||||||
@@ -141,9 +141,10 @@ collections:
|
|||||||
- label: Approved raster image
|
- label: Approved raster image
|
||||||
name: image
|
name: image
|
||||||
widget: image
|
widget: image
|
||||||
|
required: false
|
||||||
choose_url: false
|
choose_url: false
|
||||||
allow_multiple: 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:
|
media_library:
|
||||||
config:
|
config:
|
||||||
max_file_size: 512000
|
max_file_size: 512000
|
||||||
@@ -166,6 +167,93 @@ collections:
|
|||||||
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
|
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
|
||||||
editor_components: []
|
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
|
- name: journal_posts
|
||||||
label: Journal — Posts
|
label: Journal — Posts
|
||||||
label_singular: Journal post
|
label_singular: Journal post
|
||||||
|
|||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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}.`);
|
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');
|
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 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') {
|
if (key === 'blog') {
|
||||||
const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist', 'weekend-care-prep'];
|
const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist', 'weekend-care-prep'];
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ const markdownField = {
|
|||||||
buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'],
|
buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'],
|
||||||
editor_components: [],
|
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 = {
|
const expectedCollections = {
|
||||||
primary_faq: {
|
primary_faq: {
|
||||||
folder: 'sites/www/content/faq',
|
folder: 'sites/www/content/faq',
|
||||||
@@ -75,16 +88,7 @@ const expectedCollections = {
|
|||||||
fields: {
|
fields: {
|
||||||
name: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
name: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
||||||
role: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
role: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
|
||||||
image: {
|
image: optionalImageField,
|
||||||
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 } },
|
|
||||||
},
|
|
||||||
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
|
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
|
||||||
draft: { widget: 'boolean', default: false },
|
draft: { widget: 'boolean', default: false },
|
||||||
body: markdownField,
|
body: markdownField,
|
||||||
@@ -106,8 +110,8 @@ const expectedCollections = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
assert(Array.isArray(config.collections), 'collections must be an array.');
|
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.');
|
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) {
|
for (const collection of config.collections.filter(({ name }) => expectedCollections[name])) {
|
||||||
const expected = expectedCollections[collection.name];
|
const expected = expectedCollections[collection.name];
|
||||||
expectSubset(collection, {
|
expectSubset(collection, {
|
||||||
folder: expected.folder,
|
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}`);
|
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) {
|
function visitField(field) {
|
||||||
assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`);
|
assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`);
|
||||||
if (field.fields) field.fields.forEach(visitField);
|
if (field.fields) field.fields.forEach(visitField);
|
||||||
if (field.field) visitField(field.field);
|
if (field.field) visitField(field.field);
|
||||||
}
|
}
|
||||||
config.collections.flatMap(({ fields }) => fields).forEach(visitField);
|
const allFields = config.collections.flatMap(({ fields, files }) => fields ?? files.flatMap((file) => file.fields));
|
||||||
const imageFields = config.collections.flatMap(({ fields }) => fields).filter(({ widget }) => widget === 'image');
|
allFields.forEach(visitField);
|
||||||
assert(imageFields.length === 1 && imageFields[0].name === 'image', 'Only the bounded team raster-image field may expose media selection.');
|
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');
|
const schema = await readFile('src/content.config.ts', 'utf8');
|
||||||
for (const fragment of [
|
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)',
|
'items: z.array(z.string().min(3).max(80)).min(1).max(8)',
|
||||||
'name: z.string().min(3).max(80)',
|
'name: z.string().min(3).max(80)',
|
||||||
'role: 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)',
|
'order: z.number().int().min(0).max(99).default(0)',
|
||||||
'draft: z.boolean().default(false)',
|
'draft: z.boolean().default(false)',
|
||||||
'featured: 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);
|
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');
|
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.');
|
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}`);
|
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.');
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: David Okonkwo
|
name: David Okonkwo
|
||||||
role: Overnight Care
|
role: Overnight Care
|
||||||
image: /images/david-okonkwo.jpg
|
image: /images/uploads/david-okonkwo.jpg
|
||||||
order: 40
|
order: 40
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Emily Chen
|
name: Emily Chen
|
||||||
role: Pack Walker
|
role: Pack Walker
|
||||||
image: /images/emily-chen.jpg
|
image: /images/uploads/emily-chen.jpg
|
||||||
order: 30
|
order: 30
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Marcus Rivera
|
name: Marcus Rivera
|
||||||
role: Lead Trainer
|
role: Lead Trainer
|
||||||
image: /images/marcus-rivera.jpg
|
image: /images/uploads/marcus-rivera.jpg
|
||||||
order: 20
|
order: 20
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: Sarah Mitchell
|
name: Sarah Mitchell
|
||||||
role: Founder & Head Groomer
|
role: Founder & Head Groomer
|
||||||
image: /images/sarah-mitchell.jpg
|
image: /images/uploads/sarah-mitchell.jpg
|
||||||
order: 10
|
order: 10
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
import { site } from '../lib/site-data.js';
|
import { site } from '../lib/site-data.js';
|
||||||
|
const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props;
|
||||||
---
|
---
|
||||||
<section class="hero">
|
<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-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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
const { eyebrow, title, lede, image, imageAlt } = Astro.props;
|
const { eyebrow, title, lede, image, imageAlt } = Astro.props;
|
||||||
---
|
---
|
||||||
<section class="page-intro">
|
<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>
|
<div><p class="eyebrow">{eyebrow}</p><h1>{title}</h1><p class="lede">{lede}</p></div>
|
||||||
{image && <img src={image} alt={imageAlt} loading="eager" />}
|
{image && <img src={image} alt={imageAlt} loading="eager" />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { defineCollection, z } from 'astro:content';
|
import { defineCollection, z } from 'astro:content';
|
||||||
import { glob } from 'astro/loaders';
|
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({
|
const blog = defineCollection({
|
||||||
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
|
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
@@ -40,10 +45,28 @@ const team = defineCollection({
|
|||||||
schema: z.object({
|
schema: z.object({
|
||||||
name: z.string().min(3).max(80),
|
name: z.string().min(3).max(80),
|
||||||
role: 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),
|
order: z.number().int().min(0).max(99).default(0),
|
||||||
draft: z.boolean().default(false),
|
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 };
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ import { isBlog, site } from '../lib/site-data.js';
|
|||||||
if (isBlog) return Astro.redirect('/');
|
if (isBlog) return Astro.redirect('/');
|
||||||
const team = (await getCollection('team', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
|
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 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>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import PageIntro from '../components/PageIntro.astro';
|
import PageIntro from '../components/PageIntro.astro';
|
||||||
import LocationMap from '../components/LocationMap.astro';
|
import LocationMap from '../components/LocationMap.astro';
|
||||||
|
import { getCollection } from 'astro:content';
|
||||||
import { isBlog, site } from '../lib/site-data.js';
|
import { isBlog, site } from '../lib/site-data.js';
|
||||||
if (isBlog) return Astro.redirect('/');
|
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>
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import Hero from '../components/Hero.astro';
|
|||||||
import { isBlog, site } from '../lib/site-data.js';
|
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 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 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>
|
<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> : <>
|
{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"><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} />
|
<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>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import { isBlog, site } from '../lib/site-data.js';
|
|||||||
if (isBlog) return Astro.redirect('/');
|
if (isBlog) return Astro.redirect('/');
|
||||||
const services = (await getCollection('services', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
|
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 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>
|
||||||
|
|||||||
@@ -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; }
|
.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 { 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,.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; }
|
.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; }
|
.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; }
|
.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 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; }
|
.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 { 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-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; }
|
.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; }
|
.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; }
|
.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; }
|
||||||
|
|||||||