fix(art): give both 2026-10-20 posts their artwork, and gate the build on it
Deploy / build (push) Successful in 34s
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.
This commit is contained in:
1 parent
557f312bc7
commit
0dae8b0daa
26 files changed
+229
-2
No files matched your search
@@ -0,0 +1,134 @@
|
||||
#!/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}`);
|
||||
Reference in new issue
Block a user