#!/usr/bin/env node /** * Publish gate: every published post must carry its own artwork. * * Why this exists: the "one art, four targets" pipeline writes five files per * slug (banner .jpg/.webp, EN card, ZH card, two list thumbs) but the two * frontmatter fields that *point* at them (`ogImage`, `banner`) are added by * hand — they were bulk-migrated for the first 72 posts and nothing wrote them * for posts added later. Two posts shipped on 2026-10-20 with an empty * `ogImage`, so their pages advertised /og-default.jpg and rendered no hero * banner, and every related-post card showed the generic default. Nothing in * the build noticed. This script is that notice. * * Runs as the first step of `npm run build`, so a post that is text-complete * but not image-complete cannot reach nginx. * * Rules checked per non-draft post: * - `ogImage` present, language-correct (EN /og/.jpg, ZH /og/zh-.jpg) * - `banner` present (/banners/.jpg) — the same banner serves both languages * - every referenced file exists in public/, plus the derived banner .webp and * the two list thumbs the post list and related grid link to * * Usage: node scripts/check-assets.mjs (exit 1 with a list on any problem) */ import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { basename, join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const POSTS = join(ROOT, 'src', 'content', 'posts'); const PUBLIC = join(ROOT, 'public'); /** Minimal frontmatter reader: top-level `key: value` scalars only, which is all we need. */ function frontmatter(file) { const text = readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) return null; const out = {}; for (const line of m[1].split(/\r?\n/)) { const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); if (!kv) continue; let value = kv[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } out[kv[1]] = value; } return out; } const problems = []; const notes = []; /** Resolve a site-absolute path (/og/x.jpg) to a file on disk under public/. */ const publicFile = (urlPath) => join(PUBLIC, urlPath.replace(/^\//, '')); function requireFile(urlPath, label) { if (!existsSync(publicFile(urlPath))) problems.push(`${label}: missing file ${urlPath}`); } const files = [ ...readdirSync(POSTS) .filter((f) => f.endsWith('.md')) .map((f) => ({ dir: POSTS, file: f, lang: 'en' })), ...(existsSync(join(POSTS, 'zh')) ? readdirSync(join(POSTS, 'zh')) .filter((f) => f.endsWith('.md')) .map((f) => ({ dir: join(POSTS, 'zh'), file: f, lang: 'zh' })) : []), ]; let checked = 0; for (const { dir, file, lang } of files) { const path = join(dir, file); const slug = basename(file, '.md'); const fm = frontmatter(path); if (!fm) { problems.push(`${file}: no frontmatter block`); continue; } if (fm.draft === 'true') continue; checked++; const where = lang === 'zh' ? `zh/${file}` : file; const expectedOg = lang === 'zh' ? `/og/zh-${slug}.jpg` : `/og/${slug}.jpg`; const expectedBanner = `/banners/${slug}.jpg`; if (!fm.ogImage) { problems.push(`${where}: no og:image in frontmatter (expected ogImage: "${expectedOg}")`); } else if (fm.ogImage !== expectedOg) { problems.push(`${where}: ogImage is "${fm.ogImage}", expected "${expectedOg}"`); } else { requireFile(fm.ogImage, where); } if (!fm.banner) { problems.push(`${where}: no banner in frontmatter (expected banner: "${expectedBanner}")`); } else if (fm.banner !== expectedBanner) { problems.push(`${where}: banner is "${fm.banner}", expected "${expectedBanner}"`); } else { requireFile(fm.banner, where); requireFile(fm.banner.replace(/\.(png|jpe?g)$/, '.webp'), where); } // The post list serves 400w/800w WebP thumbs keyed by BASE slug (a ZH card is // zh-.jpg while its thumb is -.webp), so check both languages here. for (const w of [400, 800]) requireFile(`/og/thumb/${slug}-${w}.webp`, where); } // Every post should have a twin, and both should agree it exists. const enSlugs = new Set(files.filter((f) => f.lang === 'en').map((f) => basename(f.file, '.md'))); const zhSlugs = new Set(files.filter((f) => f.lang === 'zh').map((f) => basename(f.file, '.md'))); for (const s of enSlugs) if (!zhSlugs.has(s)) notes.push(`no ZH twin for ${s}`); for (const s of zhSlugs) if (!enSlugs.has(s)) notes.push(`no EN twin for zh/${s}`); if (problems.length) { console.error(`assets: ${problems.length} problem(s) across ${checked} published posts\n`); for (const p of problems) console.error(` ✗ ${p}`); if (notes.length) { console.error(''); for (const n of notes) console.error(` ! ${n}`); } console.error( '\nFix: cd C:/AI/ideogram4 && uv run --with pillow python post_art.py --slug --metaphor "..." --evidence "..."' + '\nthen add ogImage + banner to BOTH language files (see docs/design-guide.md, image policy).' ); process.exit(1); } console.log(`assets: ${checked} published posts OK (og card + banner + webp + 2 thumbs each)`); for (const n of notes) console.log(` ! ${n}`);