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 ?? '';
|