Deploy / build (push) Successful in 34s
The YuE2 and subtitles posts shipped text-only: render_from_art.mjs only READS ogImage/banner, and after the 2026-10-19 bulk migration no script had to write them, so both pages advertised /og-default.jpg and rendered no hero banner. - new metaphors (in build_captions.py, frozen here via sync_prompts_catalog.py), captions, art-meta evidence, banner plan and the five derived files per slug - frontmatter: ogImage + banner in EN and ZH for both posts - scripts/check-assets.mjs is now step 1 of npm run build, so CI fails when a published post lacks a language-correct og card, banner, banner .webp or 400/800 list thumb. It reported 16 problems on the unfixed tree, all here.
135 lines
5.3 KiB
JavaScript
135 lines
5.3 KiB
JavaScript
#!/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/<slug>.jpg, ZH /og/zh-<slug>.jpg)
|
|
* - `banner` present (/banners/<slug>.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-<slug>.jpg while its thumb is <slug>-<w>.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 <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}`);
|