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
+44 -5
View File
@@ -1,7 +1,7 @@
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { extname, join, relative, sep } from 'node:path';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true });
@@ -23,8 +23,47 @@ for (const key of Object.keys(sites.sites)) {
if (!siteCheck(data)) throw new Error(`${key} site data rejected: ${ajv.errorsText(siteCheck.errors)}`);
}
for (const file of await readdir('public/images')) {
const info = await stat(join('public/images', file));
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${file}`);
async function filesUnder(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...await filesUnder(path));
else if (entry.isFile()) files.push(path);
else throw new Error(`Unsupported filesystem entry in public images: ${path}`);
}
return files;
}
console.log('Intake fixtures, deployment manifest, site data, and image limits are valid.');
const uploadRoot = join('public', 'images', 'uploads');
const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
const ascii = (value) => Buffer.from(value, 'ascii');
const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature);
const isRasterForExtension = (buffer, extension) => {
if (extension === '.jpg' || extension === '.jpeg') return startsWith(buffer, Buffer.from([0xff, 0xd8, 0xff]));
if (extension === '.png') return startsWith(buffer, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
if (extension === '.webp') return buffer.length >= 12 && buffer.subarray(0, 4).equals(ascii('RIFF')) && buffer.subarray(8, 12).equals(ascii('WEBP'));
if (extension === '.avif' && buffer.length >= 16 && buffer.subarray(4, 8).equals(ascii('ftyp'))) {
for (let offset = 8; offset + 4 <= Math.min(buffer.length, 64); offset += 4) {
const brand = buffer.subarray(offset, offset + 4).toString('ascii');
if (brand === 'avif' || brand === 'avis') return true;
}
}
return false;
};
for (const path of await filesUnder('public/images')) {
const imagePath = relative('public/images', path).split(sep).join('/');
const info = await stat(path);
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${imagePath}`);
const uploadPath = relative(uploadRoot, path);
const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`);
if (!isUpload) continue;
if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`);
const extension = extname(uploadPath).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`);
const buffer = await readFile(path);
if (!isRasterForExtension(buffer, extension)) throw new Error(`CMS media does not match its raster extension: ${imagePath}`);
}
console.log('Intake fixtures, deployment manifest, site data, and bounded raster images are valid.');
+15
View File
@@ -16,6 +16,21 @@ for (const { file, html } of pages) {
for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`));
}
const robots = await readFile(resolve(root, 'robots.txt'), 'utf8');
if (process.env.DEPLOY_ENV === 'production') {
if (!robots.includes('Allow: /') || !robots.includes('Sitemap: https://')) throw new Error(`${key} production robots rules are not indexable.`);
} else {
if (robots !== 'User-agent: *\nDisallow: /\n') throw new Error(`${key} preview robots rules must disallow all crawling.`);
for (const { file, html } of pages) if (!html.includes('<meta name="robots" content="noindex,nofollow">')) throw new Error(`${file} is missing preview no-index metadata.`);
}
await Promise.all(['admin/index.html', 'admin/config.yml', '_headers'].map((file) => access(resolve(root, file))));
const admin = await readFile(resolve(root, 'admin/index.html'), 'utf8');
if (!admin.includes('content="noindex,nofollow,noarchive"')) throw new Error('Admin shell is missing no-index metadata.');
if (!admin.includes('decap-cms@3.15.1/dist/decap-cms.js')) throw new Error('Admin shell is missing the pinned Decap CMS application.');
const headers = await readFile(resolve(root, '_headers'), 'utf8');
if (!/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers)) throw new Error('Admin response headers are missing the no-index rule.');
const home = await readFile(resolve(root, 'index.html'), 'utf8');
if (key === 'www') {
for (const { file, html } of pages) if (!html.includes('href="https://blog.3031023.xyz/"')) throw new Error(`${file} is missing the Happy Tails Journal link.`);
+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.');