Enable CMS-managed site images
This commit is contained in:
@@ -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
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user