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
+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.');