blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit ed5883878a7eb2b53db6df22c3c5c9291f5e6560
parent 29c40923b2cce5f862de8ba642805499ade4a03e
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Thu, 24 Sep 2026 12:17:41 +0900

fix: fail the build when an entry renders to nothing

Astro's glob loader catches any exception a Markdown render throws,
logs one line and stores the entry without HTML. The page around it
still builds and astro build exits 0, so the post ships as a title over
an empty column. A mermaid fence inside a blockquote, a list-indented
fence, or an image URL with a stray % all did this without tripping
the content gate.

Two layers now stop it:
- assertRendered() in src/lib/rendered.ts, called by the post page, /
and /code/ before rendering, throws for an entry with no HTML or an
empty body. Its message covers the cached case, where the loader
does not re-render the file and no error line appears.
- scripts/check-dist.mjs runs as postbuild and checks the output: one
non-empty .prose per post page, a non-empty about article, and one
non-empty .project__about per project.

Diffstat:
Mpackage.json | 1+
Ascripts/check-dist.mjs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/rendered.ts | 23+++++++++++++++++++++++
Msrc/pages/code/index.astro | 2++
Msrc/pages/index.astro | 2++
Msrc/pages/writing/[year]/[slug].astro | 2++
6 files changed, 87 insertion(+), 0 deletion(-)

diff --git a/package.json b/package.json
@@ -9,6 +9,7 @@
     "dev": "astro dev",
     "prebuild": "node scripts/check-content.mjs && node scripts/render-diagrams.mjs && node scripts/build-info.mjs",
     "build": "astro build",
+    "postbuild": "node scripts/check-dist.mjs",
     "preview": "astro preview",
     "check": "node scripts/check-content.mjs",
     "diagrams": "node scripts/render-diagrams.mjs"
diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs
@@ -0,0 +1,57 @@
+/**
+ * check-dist.mjs — the post-build check that every page meant to carry a body has one.
+ *
+ * The pages already refuse to build an entry that came back unrendered (src/lib/rendered.ts).
+ * This reads the output instead of the inputs, so it still holds for a page that renders
+ * content without going through that guard: it is the last look at what is about to be
+ * uploaded. npm runs it after `npm run build`, which is what CI calls.
+ *
+ * Each container must exist as well as be non-empty. A class renamed in a template would
+ * otherwise turn every check here into a silent pass.
+ */
+import { readdir, readFile } from 'node:fs/promises';
+import path from 'node:path';
+
+const DIST = 'dist';
+const errors = [];
+const err = (f, m) => errors.push(`${f}: ${m}`);
+const count = (s, re) => (s.match(re) ?? []).length;
+
+// Posts: dist/writing/<year>/<slug>/index.html, one <div class="prose"> each.
+const writing = path.join(DIST, 'writing');
+const years = (await readdir(writing, { withFileTypes: true })).filter((d) => d.isDirectory() && /^\d{4}$/.test(d.name));
+let posts = 0;
+for (const y of years) {
+  for (const s of await readdir(path.join(writing, y.name), { withFileTypes: true })) {
+    if (!s.isDirectory()) continue;
+    const rel = path.posix.join('writing', y.name, s.name, 'index.html');
+    const html = await readFile(path.join(DIST, rel), 'utf8');
+    posts++;
+    const n = count(html, /<div class="prose">/g);
+    if (n !== 1) err(rel, `expected one <div class="prose">, found ${n}`);
+    else if (/<div class="prose">\s*<\/div>/.test(html)) err(rel, 'the post body is empty');
+  }
+}
+
+// About: the whole of about.md goes into <article class="about">.
+{
+  const html = await readFile(path.join(DIST, 'index.html'), 'utf8');
+  if (!html.includes('<article class="about">')) err('index.html', 'no <article class="about">');
+  else if (/<article class="about">\s*<\/article>/.test(html)) err('index.html', 'the about body is empty');
+}
+
+// Code: one .project__about per project, each holding that file's body.
+let projects = 0;
+{
+  const html = await readFile(path.join(DIST, 'code', 'index.html'), 'utf8');
+  projects = count(html, /<li class="project"/g);
+  const bodies = count(html, /<div class="project__about"[^>]*>/g);
+  const empty = count(html, /<div class="project__about"[^>]*>\s*<\/div>/g);
+  if (bodies !== projects) err('code/index.html', `${projects} projects but ${bodies} .project__about blocks`);
+  if (empty) err('code/index.html', `${empty} project description(s) are empty`);
+}
+
+console.log(`check-dist: ${posts} post pages, about, ${projects} projects`);
+for (const e of errors) console.error(`  ERROR ${e}`);
+if (errors.length) { console.error(`\n${errors.length} error(s) — dist/ is not fit to deploy.`); process.exit(1); }
+console.log('  every body is present — passed');
diff --git a/src/lib/rendered.ts b/src/lib/rendered.ts
@@ -0,0 +1,23 @@
+/**
+ * Fail the build when a content entry comes back without a body.
+ *
+ * Astro's glob loader catches any exception a Markdown render throws, logs one line and
+ * stores the entry with `rendered` undefined (astro/dist/content/loaders/glob.js). `render()`
+ * then returns a Content component that emits nothing, while everything around it — title,
+ * byline, description, feed item, sitemap entry — still builds, and `astro build` exits 0.
+ * The post ships as a title over an empty column. A throw from page code does fail the
+ * build, so every page that renders an entry calls this first.
+ *
+ * The loader only re-renders a file whose contents changed, so a failure cached by an
+ * earlier build comes back with no log line at all. The message says how to get it back.
+ */
+export function assertRendered(entry: { id: string; filePath?: string; rendered?: { html: string } }): void {
+  const file = entry.filePath ?? entry.id;
+  if (!entry.rendered) {
+    throw new Error(
+      `${file} did not render. The cause is the "Error rendering" line logged above; if there ` +
+        `is none, an earlier build cached the failure. Rerun with \`npm run build -- --force\`.`,
+    );
+  }
+  if (!entry.rendered.html.trim()) throw new Error(`${file} rendered to an empty body.`);
+}
diff --git a/src/pages/code/index.astro b/src/pages/code/index.astro
@@ -2,6 +2,7 @@
 import Base from '../../layouts/Base.astro';
 import { getCollection, render } from 'astro:content';
 import { hasHangul, langOf } from '../../lib/hangul';
+import { assertRendered } from '../../lib/rendered';
 
 /*
  * Authorial order, and nothing else. `order` ascending is the whole sort: the reference's
@@ -22,6 +23,7 @@ const projects = (await getCollection('code'))
 
 /* The description is each file's markdown body, so every entry has to be rendered before
    the template can lay it out. Seven `render()` calls, resolved together. */
+projects.forEach(assertRendered);
 const entries = await Promise.all(
   projects.map(async (p) => ({ entry: p, Content: (await render(p)).Content })),
 );
diff --git a/src/pages/index.astro b/src/pages/index.astro
@@ -2,8 +2,10 @@
 import Base from '../layouts/Base.astro';
 import { getEntry, render } from 'astro:content';
 import { hasHangul } from '../lib/hangul';
+import { assertRendered } from '../lib/rendered';
 
 const entry = await getEntry('about', 'about');
+assertRendered(entry!);
 const { Content } = await render(entry!);
 
 /* Ask the body, not the metadata. This used to matter because about.md was an English
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -5,6 +5,7 @@ import type { GetStaticPaths } from 'astro';
 import { byHugoOrder, postUrl } from '../../../lib/order';
 import { proseShape, excerpt, longDate } from '../../../lib/prose';
 import { hasHangul, langOf } from '../../../lib/hangul';
+import { assertRendered } from '../../../lib/rendered';
 
 export const getStaticPaths: GetStaticPaths = async () => {
   const posts = (await getCollection('writing')).filter((p) => !p.data.draft).sort(byHugoOrder);
@@ -17,6 +18,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
 };
 
 const { post, newer, older } = Astro.props;
+assertRendered(post);
 const { Content, headings } = await render(post);
 
 const md = post.body ?? '';