Add preview-only Decap CMS pilot

This commit is contained in:
Kevin
2026-09-04 00:33:03 -04:00
parent ee1010d751
commit 7eeb9e1e01
12 changed files with 759 additions and 10 deletions
+190
View File
@@ -0,0 +1,190 @@
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import yaml from 'js-yaml';
const fail = (message) => { throw new Error(message); };
const assert = (condition, message) => { if (!condition) fail(message); };
const same = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
function expectSubset(actual, expected, path) {
assert(actual && typeof actual === 'object', `${path} must be an object.`);
for (const [key, value] of Object.entries(expected)) {
const currentPath = `${path}.${key}`;
if (value && typeof value === 'object' && !Array.isArray(value)) expectSubset(actual[key], value, currentPath);
else assert(same(actual[key], value), `${currentPath} must be ${JSON.stringify(value)}.`);
}
}
const configPath = 'public/admin/config.yml';
const config = yaml.load(await readFile(configPath, 'utf8'));
const expectedOrigin = 'https://cms-pilot-3031023-xyz-www.ksloan90.workers.dev';
const expectedBackend = {
name: 'gitea',
repo: 'SloanOps/3031023-xyz',
branch: 'cms/pilot',
api_root: 'https://git.sloanops.com/api/v1',
base_url: 'https://git.sloanops.com',
auth_endpoint: 'https://git.sloanops.com/login/oauth/authorize',
};
expectSubset(config.backend, expectedBackend, 'backend');
assert(same(Object.keys(config.backend).sort(), [...Object.keys(expectedBackend), 'app_id'].sort()), 'The backend must not contain secret-bearing or unreviewed options.');
assert(typeof config.backend.app_id === 'string' && /^[A-Za-z0-9._-]+$/.test(config.backend.app_id), 'backend.app_id must be a non-confidential public client ID or the review placeholder.');
assert(config.publish_mode === 'simple', 'The Gitea pilot must use simple mode on its fixed review branch.');
assert(config.site_url === expectedOrigin && config.display_url === expectedOrigin, 'The CMS must use the stable primary preview origin.');
assert(config.local_backend === undefined, 'The pilot configuration must not enable an alternate local backend.');
expectSubset(config, {
media_folder: 'public/images/uploads',
public_folder: '/images/uploads',
}, 'config');
assert(config.media_library === undefined, 'Top-level media_library is reserved for registered external libraries; bound the built-in uploader on the image field.');
const markdownField = {
widget: 'markdown',
buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'],
editor_components: [],
};
const expectedCollections = {
primary_faq: {
folder: 'sites/www/content/faq',
identifier_field: 'question',
fields: {
question: { widget: 'string', pattern: ['^.{12,180}$', 'Use 12–180 characters.'] },
category: { widget: 'string', default: 'General', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] },
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
primary_services: {
folder: 'sites/www/content/services',
identifier_field: 'title',
fields: {
title: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
price: { widget: 'string', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] },
items: { widget: 'list', min: 1, max: 8, field: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] } },
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
featured: { widget: 'boolean', default: false },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
primary_team: {
folder: 'sites/www/content/team',
identifier_field: 'name',
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 } },
},
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
journal_posts: {
folder: 'sites/blog/content/posts',
identifier_field: 'title',
fields: {
title: { widget: 'string', pattern: ['^.{12,100}$', 'Use 12–100 characters.'] },
description: { widget: 'text', pattern: ['^[\\s\\S]{30,180}$', 'Use 30–180 characters.'] },
pubDate: { widget: 'date', format: 'YYYY-MM-DD' },
updatedDate: { widget: 'date', format: 'YYYY-MM-DD', required: false },
tags: { widget: 'list', min: 1, max: 4, field: { widget: 'string', pattern: ['^.{2,32}$', 'Use 2–32 characters.'] } },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
};
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) {
const expected = expectedCollections[collection.name];
expectSubset(collection, {
folder: expected.folder,
identifier_field: expected.identifier_field,
create: true,
delete: true,
extension: 'md',
format: 'frontmatter',
slug: '{{slug}}',
}, `collections.${collection.name}`);
const fields = Object.fromEntries(collection.fields.map((field) => [field.name, field]));
assert(same(Object.keys(fields).sort(), Object.keys(expected.fields).sort()), `${collection.name} must preserve every modeled frontmatter field and body.`);
for (const [fieldName, fieldContract] of Object.entries(expected.fields)) expectSubset(fields[fieldName], fieldContract, `${collection.name}.${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 schema = await readFile('src/content.config.ts', 'utf8');
for (const fragment of [
'title: z.string().min(12).max(100)',
'description: z.string().min(30).max(180)',
'updatedDate: z.coerce.date().optional()',
'tags: z.array(z.string().min(2).max(32)).min(1).max(4)',
"question: z.string().min(12).max(180)",
"category: z.string().min(2).max(48).default('General')",
'title: z.string().min(3).max(80)',
'price: z.string().min(2).max(48)',
'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/')",
'order: z.number().int().min(0).max(99).default(0)',
'draft: z.boolean().default(false)',
'featured: z.boolean().default(false)',
]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`);
async function verifyContentPaths(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
assert(/^[a-z0-9][a-z0-9-]*$/.test(entry.name), `Content directory is outside the Markdown slug contract: ${path}`);
await verifyContentPaths(path);
} else {
assert(entry.isFile(), `Special content paths are outside the pilot contract: ${path}`);
assert(/^[a-z0-9][a-z0-9-]*\.md$/.test(entry.name), `Content filename is outside the Markdown slug contract: ${path}`);
}
}
}
for (const directory of Object.values(expectedCollections).map(({ folder }) => folder)) await verifyContentPaths(directory);
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('href="/admin/config.yml"') && admin.includes('rel="cms-config-url"'), 'The admin shell must load the reviewed configuration.');
assert(admin.includes('https://unpkg.com/decap-cms@3.15.1/dist/decap-cms.js'), 'The admin shell must pin Decap CMS 3.15.1 exactly.');
assert(admin.includes('sha384-in6eHztHveqQ7uMZ1fDaKlDmacQLFuLH2wWrFTiymyuS8zQ5bixwL8U3AeRi8h/L'), 'The Decap bundle must retain its verified SHA-384 integrity value.');
assert(!/decap-cms@(latest|[~^*])/.test(admin), 'Floating Decap CMS versions are forbidden.');
const headers = await readFile('public/_headers', 'utf8');
assert(/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers), 'Admin responses must include an X-Robots-Tag no-index rule.');
const workflow = yaml.load(await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8'));
assert(workflow.name === 'CMS content validation', 'The validation workflow display name is part of the required status context.');
assert(workflow.on?.pull_request && same(workflow.on.pull_request.branches, ['main']), 'The validation workflow must run for every pull request into main.');
assert(workflow.on.pull_request.paths === undefined, 'The required validation workflow must not use path filters.');
assert(workflow.jobs?.validate?.name === 'cms-content-validation', 'The validation job name is part of the required status context.');
const workflowText = await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8');
assert(!workflowText.includes('secrets.'), 'CMS pull-request validation must remain secret-free.');
for (const command of ['npm run verify:cms', 'SITE_KEY=www npm run build:site', 'SITE_KEY=www npm run verify:build', 'SITE_KEY=blog npm run build:site', 'SITE_KEY=blog npm run verify:build']) {
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.');