Merge pull request 'Optimize CMS images during builds' (#3) from review/cms-image-optimization into main

Reviewed-on: SloanOps/3031023-xyz#3
This commit is contained in:
2026-09-04 16:14:26 +00:00
11 changed files with 136 additions and 22 deletions
+3 -1
View File
@@ -167,7 +167,9 @@ sites/www/content/**;sites/blog/content/**;public/images/uploads/*.jpg;public/im
Do not add a nested upload glob. The current Decap configuration writes media Do not add a nested upload glob. The current Decap configuration writes media
directly into the flat uploads directory, and the local verifier rejects nested directly into the flat uploads directory, and the local verifier rejects nested
media paths. Gitea file patterns are case-insensitive; CI verifies actual file media paths. Gitea file patterns are case-insensitive; CI verifies actual file
signatures and the 500 KiB limit before a preview can upload. signatures and a 25 MiB source safety ceiling before deployment. The build
re-encodes the deployed copy to at most 1920 pixels and 500 KiB and strips
embedded metadata.
Everything not listed is protected from CMS/client pushes on `cms` and Everything not listed is protected from CMS/client pushes on `cms` and
on every differently named branch, including: on every differently named branch, including:
+14 -7
View File
@@ -74,7 +74,8 @@ staging branch:
replace, or remove an image reference while retaining accessible image replace, or remove an image reference while retaining accessible image
descriptions and a layout fallback. URL insertion and SVG paths are descriptions and a layout fallback. URL insertion and SVG paths are
rejected. Builds additionally verify the extension, raster signature, flat rejected. Builds additionally verify the extension, raster signature, flat
upload path, and 500 KiB limit. upload path, and a 25 MiB source safety ceiling. Every build re-encodes the
public copy to at most 1920 pixels and 500 KiB and removes embedded metadata.
- `.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
@@ -151,10 +152,16 @@ 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
analytics through the CMS during this pilot. analytics through the CMS during this pilot.
Configure the default media library with a conservative file-size limit. Do Configure image fields with a 25 MiB source safety limit so ordinary camera
not accept executables, archives, HTML, scripts, or SVG uploads. Asset rights, photos can be uploaded. Do not accept executables, archives, HTML, scripts, or
alt text, crop, dimensions, and optimization remain operator review items even SVG uploads. The repository verifier applies the same safety ceiling to every
when the CMS accepts an upload. upload, including files added through Decap's standalone media gallery.
The build re-encodes every uploaded raster without changing its public URL. It
auto-orients and scales the deployed copy to at most 1920 pixels, targets a
500 KiB output ceiling, and strips EXIF and other embedded metadata. The source
file stays unchanged in the private repository. Asset rights, alt text, and
crop remain operator review items.
Decap's image widget applies its field `pattern` to the selected file's base Decap's image widget applies its field `pattern` to the selected file's base
name, not to the repository-relative media path. Keep that UI pattern limited name, not to the repository-relative media path. Keep that UI pattern limited
@@ -286,8 +293,8 @@ not record OAuth tokens or Cloudflare credentials.
| 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 | Add, replace, and remove a team portrait | The selected upload renders in the team card; removal uses the initials fallback. | | 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 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-10 | Upload a camera-sized raster image and select it in the fixed page-image record | File lands only in the approved media path; its deployed copy is at most 1920 pixels and 500 KiB, contains no embedded metadata, 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 upload over 25 MiB and a disallowed file type | The source safety limit rejects the oversized file; 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. |
| DC-14 | Attempt a direct push to `main` | Gitea rejects it. | | DC-14 | Attempt a direct push to `main` | Gitea rejects it. |
+1 -1
View File
@@ -12,6 +12,7 @@
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"astro": "~7.2.0", "astro": "~7.2.0",
"js-yaml": "4.3.2", "js-yaml": "4.3.2",
"sharp": "0.35.4",
"wrangler": "^4.0.0" "wrangler": "^4.0.0"
}, },
"engines": { "engines": {
@@ -4639,7 +4640,6 @@
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
+1
View File
@@ -19,6 +19,7 @@
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"astro": "~7.2.0", "astro": "~7.2.0",
"js-yaml": "4.3.2", "js-yaml": "4.3.2",
"sharp": "0.35.4",
"wrangler": "^4.0.0" "wrangler": "^4.0.0"
} }
} }
+7 -7
View File
@@ -147,7 +147,7 @@ collections:
pattern: ['^[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.'] pattern: ['^[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: 26214400
multiple: false multiple: false
- label: Display order - label: Display order
name: order name: order
@@ -183,7 +183,7 @@ collections:
allow_multiple: false allow_multiple: false
pattern: ['^[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.'] pattern: ['^[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: { max_file_size: 512000, multiple: false } config: { max_file_size: 26214400, multiple: false }
- label: Home hero image description - label: Home hero image description
name: homeHeroImageAlt name: homeHeroImageAlt
widget: string widget: string
@@ -196,7 +196,7 @@ collections:
allow_multiple: false allow_multiple: false
pattern: ['^[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.'] pattern: ['^[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: { max_file_size: 512000, multiple: false } config: { max_file_size: 26214400, multiple: false }
- label: Home story image description - label: Home story image description
name: homeStoryImageAlt name: homeStoryImageAlt
widget: string widget: string
@@ -209,7 +209,7 @@ collections:
allow_multiple: false allow_multiple: false
pattern: ['^[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.'] pattern: ['^[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: { max_file_size: 512000, multiple: false } config: { max_file_size: 26214400, multiple: false }
- label: About hero image description - label: About hero image description
name: aboutHeroImageAlt name: aboutHeroImageAlt
widget: string widget: string
@@ -222,7 +222,7 @@ collections:
allow_multiple: false allow_multiple: false
pattern: ['^[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.'] pattern: ['^[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: { max_file_size: 512000, multiple: false } config: { max_file_size: 26214400, multiple: false }
- label: About story image description - label: About story image description
name: aboutStoryImageAlt name: aboutStoryImageAlt
widget: string widget: string
@@ -235,7 +235,7 @@ collections:
allow_multiple: false allow_multiple: false
pattern: ['^[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.'] pattern: ['^[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: { max_file_size: 512000, multiple: false } config: { max_file_size: 26214400, multiple: false }
- label: Services hero image description - label: Services hero image description
name: servicesHeroImageAlt name: servicesHeroImageAlt
widget: string widget: string
@@ -248,7 +248,7 @@ collections:
allow_multiple: false allow_multiple: false
pattern: ['^[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.'] pattern: ['^[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: { max_file_size: 512000, multiple: false } config: { max_file_size: 26214400, multiple: false }
- label: Contact hero image description - label: Contact hero image description
name: contactHeroImageAlt name: contactHeroImageAlt
widget: string widget: string
+3
View File
@@ -12,5 +12,8 @@ const result = spawnSync('npm', ['run', 'build', '--', '--outDir', `dist/${key}`
}); });
if (result.status !== 0) process.exit(result.status ?? 1); if (result.status !== 0) process.exit(result.status ?? 1);
const images = spawnSync('node', ['scripts/optimize-images.mjs'], { env: { ...process.env, SITE_KEY: key }, stdio: 'inherit' });
if (images.status !== 0) process.exit(images.status ?? 1);
const seo = spawnSync('node', ['scripts/generate-seo.mjs'], { env: { ...process.env, SITE_KEY: key }, stdio: 'inherit' }); const seo = spawnSync('node', ['scripts/generate-seo.mjs'], { env: { ...process.env, SITE_KEY: key }, stdio: 'inherit' });
if (seo.status !== 0) process.exit(seo.status ?? 1); if (seo.status !== 0) process.exit(seo.status ?? 1);
+4
View File
@@ -0,0 +1,4 @@
export const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
export const maxSourceImageBytes = 25 * 1024 * 1024;
export const maxBuiltImageBytes = 500 * 1024;
export const maxBuiltImageDimension = 1920;
+75
View File
@@ -0,0 +1,75 @@
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { extname, resolve } from 'node:path';
import sharp from 'sharp';
import {
allowedUploadExtensions,
maxBuiltImageBytes,
maxBuiltImageDimension,
} from './image-policy.mjs';
const key = process.env.SITE_KEY;
if (!key || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(key)) throw new Error('SITE_KEY is required to optimize built media.');
const uploadRoot = resolve(`dist/${key}/images/uploads`);
const entries = await readdir(uploadRoot, { withFileTypes: true });
const qualitySteps = [82, 76, 70, 64, 58, 52, 46];
const dimensionSteps = [1920, 1600, 1440, 1280, 1024, 800];
function encode(pipeline, extension, quality) {
if (extension === '.jpg' || extension === '.jpeg') {
return pipeline.jpeg({ quality, progressive: true, chromaSubsampling: '4:2:0' });
}
if (extension === '.png') {
return pipeline.png({ compressionLevel: 9, effort: 7, palette: true, quality });
}
if (extension === '.webp') return pipeline.webp({ quality, effort: 6 });
if (extension === '.avif') return pipeline.avif({ quality, effort: 5, chromaSubsampling: '4:2:0' });
throw new Error(`Unsupported CMS image extension: ${extension}`);
}
async function optimize(source, extension) {
let smallest;
for (const dimension of dimensionSteps) {
for (const quality of qualitySteps) {
const pipeline = sharp(source, { failOn: 'warning', limitInputPixels: 100_000_000 })
.rotate()
.resize({
width: Math.min(dimension, maxBuiltImageDimension),
height: Math.min(dimension, maxBuiltImageDimension),
fit: 'inside',
withoutEnlargement: true,
});
const output = await encode(pipeline, extension, quality).toBuffer();
if (!smallest || output.length < smallest.length) smallest = output;
if (output.length <= maxBuiltImageBytes) return output;
}
}
throw new Error(`Unable to optimize an image below ${maxBuiltImageBytes} bytes; smallest result was ${smallest?.length ?? 0} bytes.`);
}
let inputBytes = 0;
let outputBytes = 0;
let optimizedCount = 0;
for (const entry of entries) {
if (!entry.isFile()) throw new Error(`Nested or special CMS media paths are not allowed: ${entry.name}`);
const extension = extname(entry.name).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${entry.name}`);
const path = resolve(uploadRoot, entry.name);
const source = await readFile(path);
const output = await optimize(source, extension);
await writeFile(path, output);
const metadata = await sharp(output).metadata();
if ((metadata.width ?? 0) > maxBuiltImageDimension || (metadata.height ?? 0) > maxBuiltImageDimension) {
throw new Error(`Optimized image dimensions exceed ${maxBuiltImageDimension}px: ${entry.name}`);
}
if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp) {
throw new Error(`Optimized image retained metadata: ${entry.name}`);
}
inputBytes += source.length;
outputBytes += output.length;
optimizedCount += 1;
}
console.log(`Optimized ${optimizedCount} built CMS images from ${inputBytes} to ${outputBytes} bytes; metadata removed.`);
+2 -3
View File
@@ -2,6 +2,7 @@ import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import { readdir, readFile, stat } from 'node:fs/promises'; import { readdir, readFile, stat } from 'node:fs/promises';
import { extname, join, relative, sep } from 'node:path'; import { extname, join, relative, sep } from 'node:path';
import { allowedUploadExtensions, maxSourceImageBytes } from './image-policy.mjs';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8')); const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true }); const ajv = new Ajv2020({ allErrors: true, strict: true });
@@ -36,7 +37,6 @@ async function filesUnder(directory) {
} }
const uploadRoot = join('public', 'images', 'uploads'); const uploadRoot = join('public', 'images', 'uploads');
const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
const ascii = (value) => Buffer.from(value, 'ascii'); const ascii = (value) => Buffer.from(value, 'ascii');
const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature); const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature);
const isRasterForExtension = (buffer, extension) => { const isRasterForExtension = (buffer, extension) => {
@@ -55,11 +55,10 @@ const isRasterForExtension = (buffer, extension) => {
for (const path of await filesUnder('public/images')) { for (const path of await filesUnder('public/images')) {
const imagePath = relative('public/images', path).split(sep).join('/'); const imagePath = relative('public/images', path).split(sep).join('/');
const info = await stat(path); 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 uploadPath = relative(uploadRoot, path);
const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`); const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`);
if (!isUpload) continue; if (!isUpload) continue;
if (info.size > maxSourceImageBytes) throw new Error(`CMS image exceeds the 25 MiB source safety limit: ${imagePath}`);
if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`); if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`);
const extension = extname(uploadPath).toLowerCase(); const extension = extname(uploadPath).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`); if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`);
+17 -2
View File
@@ -1,5 +1,7 @@
import { access, readFile } from 'node:fs/promises'; import { access, readFile, readdir, stat } from 'node:fs/promises';
import { resolve } from 'node:path'; import { extname, resolve } from 'node:path';
import sharp from 'sharp';
import { allowedUploadExtensions, maxBuiltImageBytes, maxBuiltImageDimension } from './image-policy.mjs';
const key = process.env.SITE_KEY; const key = process.env.SITE_KEY;
if (!key) throw new Error('SITE_KEY is required.'); if (!key) throw new Error('SITE_KEY is required.');
@@ -31,6 +33,19 @@ if (!admin.includes('decap-cms@3.15.1/dist/decap-cms.js')) throw new Error('Admi
const headers = await readFile(resolve(root, '_headers'), 'utf8'); 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.'); 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 builtUploads = resolve(root, 'images/uploads');
for (const entry of await readdir(builtUploads, { withFileTypes: true })) {
if (!entry.isFile()) throw new Error(`Built CMS media contains a nested or special path: ${entry.name}.`);
const extension = extname(entry.name).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`Built CMS media has a disallowed extension: ${entry.name}.`);
const path = resolve(builtUploads, entry.name);
const info = await stat(path);
if (info.size > maxBuiltImageBytes) throw new Error(`Built CMS image exceeds ${maxBuiltImageBytes} bytes: ${entry.name}.`);
const metadata = await sharp(path).metadata();
if ((metadata.width ?? 0) > maxBuiltImageDimension || (metadata.height ?? 0) > maxBuiltImageDimension) throw new Error(`Built CMS image exceeds ${maxBuiltImageDimension}px: ${entry.name}.`);
if (metadata.exif || metadata.icc || metadata.iptc || metadata.xmp) throw new Error(`Built CMS image retained metadata: ${entry.name}.`);
}
const home = await readFile(resolve(root, 'index.html'), 'utf8'); const home = await readFile(resolve(root, 'index.html'), 'utf8');
if (key === 'www') { 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.`); 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.`);
+9 -1
View File
@@ -1,6 +1,7 @@
import { readFile, readdir } from 'node:fs/promises'; import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import { maxSourceImageBytes } from './image-policy.mjs';
const fail = (message) => { throw new Error(message); }; const fail = (message) => { throw new Error(message); };
const assert = (condition, message) => { if (!condition) fail(message); }; const assert = (condition, message) => { if (!condition) fail(message); };
@@ -54,7 +55,7 @@ const optionalImageField = {
choose_url: false, choose_url: false,
allow_multiple: false, allow_multiple: false,
pattern: cmsImagePattern, pattern: cmsImagePattern,
media_library: { config: { max_file_size: 512000, multiple: false } }, media_library: { config: { max_file_size: maxSourceImageBytes, multiple: false } },
}; };
const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] }; const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] };
const expectedCollections = { const expectedCollections = {
@@ -185,6 +186,13 @@ for (const fragment of [
'featured: z.boolean().default(false)', 'featured: z.boolean().default(false)',
]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`); ]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`);
const buildScript = await readFile('scripts/build-site.mjs', 'utf8');
assert(buildScript.includes("['scripts/optimize-images.mjs']"), 'Every site build must optimize uploaded images before deployment.');
const imageOptimizer = await readFile('scripts/optimize-images.mjs', 'utf8');
for (const fragment of ['maxBuiltImageBytes', 'maxBuiltImageDimension', '.rotate()', 'metadata.exif', 'metadata.icc', 'metadata.iptc', 'metadata.xmp']) {
assert(imageOptimizer.includes(fragment), `Built-image optimization changed; review the CMS media contract for: ${fragment}`);
}
async function verifyContentPaths(directory) { async function verifyContentPaths(directory) {
const entries = await readdir(directory, { withFileTypes: true }); const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) { for (const entry of entries) {