Files
hoelee-blog/scripts/check-assets.mjs
T
hoelee 0dae8b0daa
Deploy / build (push) Successful in 34s
fix(art): give both 2026-10-20 posts their artwork, and gate the build on it
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.
2026-10-20 23:37:52 +08:00

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}`);