scripts/check-content.mjs (27738B)
1 /**
2 * check-content.mjs — the pre-deploy content gate.
3 *
4 * Since pushing is the only way to publish, this is the first of the site's safety nets.
5 * It runs in prebuild, before Astro, as does scripts/render-diagrams.mjs, which stops the
6 * build on a diagram it cannot bake; the render guards and scripts/check-dist.mjs come
7 * after.
8 *
9 * Strictness is staged through CONTENT_STRICT, a comma-separated list:
10 * deck, tags an empty deck, or empty tags, becomes an error instead of a warning
11 * all both, once the backfill is done. Raise it once and do not lower it again.
12 * urls,schema what CI passes today. URL, schema and missing-image checks are errors
13 * whatever the setting, so these two names switch nothing on; they are
14 * accepted so that value stays valid.
15 * Any other name stops the gate: a typo must not quietly leave a check switched off.
16 */
17 import { readdir, readFile } from 'node:fs/promises';
18 import { existsSync, readdirSync } from 'node:fs';
19 import path from 'node:path';
20 import { load as parseYaml } from 'js-yaml';
21
22 const SRC = process.env.CONTENT_DIR ?? '.';
23 const STRICT = new Set((process.env.CONTENT_STRICT ?? 'urls,schema').split(',').map((s) => s.trim()).filter(Boolean));
24 const on = (k) => STRICT.has('all') || STRICT.has(k);
25 const KEYS = new Set(['all', 'deck', 'tags', 'urls', 'schema']);
26 const unknownKeys = [...STRICT].filter((k) => !KEYS.has(k));
27 if (unknownKeys.length) {
28 console.error(`check-content: unknown CONTENT_STRICT name(s): ${unknownKeys.join(', ')} (known: ${[...KEYS].join(', ')})`);
29 process.exit(1);
30 }
31
32 const errors = [], warns = [];
33 const err = (f, m) => errors.push(`${f}: ${m}`);
34 const warn = (f, m) => warns.push(`${f}: ${m}`);
35 const req = (k, f, m) => (on(k) ? err(f, m) : warn(f, m));
36
37 /**
38 * Read a content file, dropping a leading UTF-8 BOM.
39 *
40 * Some Windows tools write one — Windows PowerShell's `Out-File -Encoding utf8` does —
41 * and it lands in front of the opening `---`. Astro strips it and parses the file normally,
42 * so a post carrying one builds and publishes correctly. Without this, every front-matter
43 * regex below misses on the very first character and the gate stops the build with
44 * `missing front matter` for a file whose front matter is plainly there: a message that
45 * states the opposite of what is true, on a file the build would have accepted. The gate is
46 * here to agree with the build, not to be stricter than it over an invisible byte.
47 */
48 const read = async (p) => (await readFile(p, 'utf8')).replace(/^\uFEFF/, '');
49
50 const asciiLower = (s) => s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
51 /** The year segment already keeps slugs away from sub-view names; this is a second line. */
52 const RESERVED = new Set(['essay', 'research', 'video', 'slides', 'podcast', 'index', 'page', 'tags', 'feed', 'about']);
53
54 /* A file name is a URL segment (writing) or an entry id (code), so it is held to what
55 survives as one: letters in any script, digits, - and _. Measured on a build: `#` in
56 the name dropped the post from every page, the feed and the sitemap with exit 0; `%`
57 shipped a URL GitHub Pages answers with 400; a space shipped a raw space in href. */
58 const FILENAME = /^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u;
59 const FILENAME_RULE = 'file name may use only letters, digits, - and _ (it becomes the address; #, %, ? or a space breaks or drops the page)';
60
61 /* The collection id is the file name with ASCII lowercased (src/content.config.ts), so
62 Arp.md and arp.md are one id and the loader keeps only one of them. The two cannot sit
63 side by side on Windows, but a commit made on Linux or in the web editor can add the
64 second, and the build would then drop a post without saying which. */
65 const idClash = (ids, id, rel) => {
66 if (ids.has(id)) err(rel, `has the same id as ${ids.get(id)} ("${id}"): the names differ only in letter case, and one of the two would not be built`);
67 else ids.set(id, rel);
68 };
69
70 /* 2025-02-30 matches YYYY-MM-DD, and the loader rolls it over to March 2 and publishes it
71 there; 2025-12-32 rolls into the next year's URL, which then never moves back. */
72 const realDate = (d) => {
73 const t = new Date(`${d}T00:00:00Z`);
74 return !Number.isNaN(t.getTime()) && t.toISOString().slice(0, 10) === d;
75 };
76
77 /* ── Images ──────────────────────────────────────────────────────────────────
78 Every /uploads/ path a document references must exist in public/uploads, spelled
79 exactly as on disk. Pages serves a case-sensitive file system, while existsSync on
80 Windows says yes to Image.png for image.png, so each path segment is looked up in its
81 directory listing instead.
82
83 Only references that start a URL count: after `(`, a quote, `<`, `=` or whitespace,
84 never after a host name, and never inside fenced code or a code span. A tutorial that
85 quotes `curl http://host/uploads/shell.php` is not naming an image this site serves.
86 The path is percent-decoded first, as the renderer does. A malformed escape such as
87 `100%.png` makes the renderer throw and the build stop at that page
88 (src/lib/rendered.ts), so it is an error here, where the message can name the path. */
89 const listings = new Map();
90 function listing(dir) {
91 if (!listings.has(dir)) {
92 let names = null;
93 try { names = new Set(readdirSync(dir)); } catch { /* missing, or not a directory */ }
94 listings.set(dir, names);
95 }
96 return listings.get(dir);
97 }
98 function existsExact(root, rel) {
99 let dir = root;
100 for (const part of rel.split('/')) {
101 if (!part || part === '.' || part === '..' || !listing(dir)?.has(part)) return false;
102 dir = path.join(dir, part);
103 }
104 return true;
105 }
106
107 /** The text outside fenced code and code spans. One pass over the input, no backtracking. */
108 function outsideCode(md) {
109 const kept = [];
110 let fence = null;
111 for (const line of md.split('\n')) {
112 const run = /^[\s>]*(`{3,}|~{3,})/.exec(line);
113 const rest = run ? line.slice(run.index + run[0].length) : '';
114 if (fence) {
115 if (run && run[1][0] === fence[0] && run[1].length >= fence.length && !rest.trim()) fence = null;
116 continue;
117 }
118 // A backtick fence cannot carry a backtick in its info string; that line is prose.
119 if (run && !(run[1][0] === '`' && rest.includes('`'))) { fence = run[1]; continue; }
120 kept.push(line);
121 }
122 // Code spans, paired within one paragraph: an opening run of n backticks closes at the
123 // next run of exactly n. Pairing across paragraphs would let two stray backticks hide
124 // everything between them.
125 return kept.join('\n').split(/\n[ \t]*\n/).map((block) => {
126 const runs = [...block.matchAll(/`+/g)];
127 const next = new Array(runs.length).fill(-1);
128 const last = new Map();
129 for (let i = runs.length - 1; i >= 0; i--) {
130 next[i] = last.get(runs[i][0].length) ?? -1;
131 last.set(runs[i][0].length, i);
132 }
133 let out = '', from = 0;
134 for (let i = 0; i < runs.length; i++) {
135 if (next[i] < 0) continue;
136 out += block.slice(from, runs[i].index);
137 from = runs[next[i]].index + runs[next[i]][0].length;
138 i = next[i];
139 }
140 return out + block.slice(from);
141 }).join('\n\n');
142 }
143
144 /* ── Sizes the renderer cannot take ──────────────────────────────────────────
145 Sätteri's smart punctuation costs bytes x marks within one inline run: a paragraph, a
146 list item, a heading. Measured: 85 KB took 1.4 s and 2 GB; from 88 KB of pasted JSON or
147 169 KB of unfenced log the render throws and the build stops at that page; past about
148 41,000 apostrophes in one run Node itself dies with no file named. The same text split
149 into paragraphs costs nothing. So each run outside code is held under 16 KB and 400
150 quote, & and ... marks, both far above any paragraph the corpus has. A table is held
151 under 20,000 cells: Sätteri has no cell limit, and an 18 KB table 3,072 columns wide
152 became a 94 MB page. Code fences are not runs; their long lines are left untokenised
153 instead (astro.config.mjs). */
154 const RUN_BYTES = 16 * 1024;
155 const RUN_MARKS = 400;
156 const TABLE_CELLS = 20_000;
157 const DELIMITER_ROW = /^\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$/;
158 const STARTS_RUN = /^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)|^#{1,6}(?:[ \t]|$)/;
159
160 function checkSizes(rel, body, firstLine) {
161 const blocks = [];
162 let block = null;
163 let fence = null;
164 body.replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => {
165 const run = /^[\s>]*(`{3,}|~{3,})/.exec(line);
166 const rest = run ? line.slice(run.index + run[0].length) : '';
167 if (fence) {
168 if (run && run[1][0] === fence[0] && run[1].length >= fence.length && !rest.trim()) fence = null;
169 return;
170 }
171 const text = line.replace(/^[ \t>]*/, '');
172 if (run && !(run[1][0] === '`' && rest.includes('`'))) { fence = run[1]; block = null; return; }
173 if (!text.trim()) { block = null; return; }
174 if (!block) blocks.push((block = { line: firstLine + i, lines: [] }));
175 block.lines.push({ line: firstLine + i, text });
176 });
177 for (const b of blocks) {
178 if (b.lines.some((l) => DELIMITER_ROW.test(l.text))) {
179 const columns = b.lines[0].text.replace(/^\||\|$/g, '').split('|').length;
180 const cells = columns * (b.lines.length - 1);
181 if (cells > TABLE_CELLS)
182 err(rel, `line ${b.line}: a table of ${cells.toLocaleString()} cells is over the ${TABLE_CELLS.toLocaleString()} the renderer can take; split it, or link the data as a file`);
183 continue;
184 }
185 const runs = [];
186 for (const l of b.lines) {
187 if (!runs.length || STARTS_RUN.test(l.text)) runs.push({ line: l.line, text: '' });
188 runs[runs.length - 1].text += `${l.text}\n`;
189 }
190 for (const r of runs) {
191 const bytes = Buffer.byteLength(r.text, 'utf8');
192 const marks = (r.text.match(/["'&]|\.\.\./g) ?? []).length;
193 if (bytes > RUN_BYTES || marks > RUN_MARKS)
194 err(rel, `line ${r.line}: a paragraph of ${(bytes / 1024).toFixed(1)} KB with ${marks} quote, & and ... marks is over the ${RUN_BYTES / 1024} KB / ${RUN_MARKS} limit, past which rendering slows quadratically and then fails; put a pasted log or JSON in a code fence, or split it into paragraphs`);
195 }
196 }
197 }
198
199 /* Two spellings, tried in this order at each position. `(</uploads/a b.png>)` is how
200 CommonMark writes a destination with a space in it (screenshots are named that way by
201 default), and it runs to the `>`; the bare form ends at the first space or delimiter. */
202 const UPLOAD_REF = /(?<=(?:^|[\s(])<)\/uploads\/([^<>\n]+)(?=>)|(?<=^|[\s(<"'=])\/uploads\/([^\s"'()<>\]]+)/gm;
203
204 function checkUploads(rel, raw) {
205 for (const m of outsideCode(raw).matchAll(UPLOAD_REF)) {
206 const key = (m[1] ?? m[2]).split(/[?#]/)[0].replace(/[.,]+$/, '');
207 let file;
208 try { file = decodeURI(key); } catch { err(rel, `image path is not valid percent-encoding: /uploads/${key}`); continue; }
209 if (!existsExact(path.join(SRC, 'public', 'uploads'), file))
210 err(rel, `referenced image is missing from public/uploads, or differs in case: /uploads/${key}`);
211 }
212 }
213
214 const files = [];
215 {
216 const d = path.join(SRC, 'content', 'writing');
217 /* Underscore files are deliberately NOT skipped, because Astro does not skip them: the
218 glob loader in src/content.config.ts matches `*.md`, so a post parked as `_wip.md` is
219 loaded, schema-checked and built into a page like any other. Skipping it here would
220 let the gate report "0 errors — passed" and the build then die on
221 InvalidContentEntryDataError, which names no field and points at line 0. Checking it
222 costs nothing — no file in this directory starts with `_`, and both templates live
223 at content/ top level, outside this glob — and it puts the URL-collision and
224 reserved-slug rules onto files that really do become URLs. The supported way to park
225 a post is `draft: true`, which every page and the feed honour. */
226 if (existsSync(d))
227 for (const f of await readdir(d)) if (f.endsWith('.md')) files.push(path.join(d, f));
228 }
229 if (files.length === 0) err('(all)', 'no markdown found under content — the checkout may have failed');
230
231 const seen = new Map();
232 const writingIds = new Map();
233 for (const abs of files) {
234 const rel = path.relative(SRC, abs).split(path.sep).join('/');
235 const raw = await read(abs);
236 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
237 if (!m) { err(rel, 'missing front matter'); continue; }
238 const fm = m[1];
239
240 /* Parse the front matter for real before reading it with regexes.
241 The checks below read values by pattern, which answers "what did the author type" but
242 never "is this YAML at all". That gap has one very likely victim: the template ships
243 `deck: ""`, and typing a quoted phrase inside those quotes gives
244 `deck: "Ratios: what's a "good" one?"`, which is not valid YAML. The regexes see a
245 non-empty deck and pass it; the build then fails elsewhere with a message about a
246 collection entry, naming neither the file nor the quote. Parsing here fails at the
247 file, with a line number, before Astro is reached. */
248 let parsed = null;
249 try {
250 parsed = parseYaml(fm);
251 if (parsed !== null && typeof parsed !== 'object') throw new Error('front matter is not a set of key: value fields');
252 } catch (e) {
253 const where = e && e.mark && e.mark.line != null ? ` (front matter line ${e.mark.line + 1})` : '';
254 err(rel, `front matter is not valid YAML${where}: ${String(e.message).split('\n')[0]}`);
255 err(rel, ' a quote inside a quoted value has to be escaped: write the whole value in single quotes, or double the inner ones');
256 continue;
257 }
258
259 /* An unquoted `#` opens a YAML comment, so `deck: Ratios of what # and why` keeps only
260 "Ratios of what" and loses the rest on the page, in the meta description and in the
261 feed. Nothing downstream can tell that from a deck that was always short, so the one
262 place it can be noticed is here, by comparing what was typed against what parsed. */
263 for (const [k, v] of Object.entries(parsed || {})) {
264 if (typeof v !== 'string') continue;
265 // The key is the author's text, so it is escaped before it becomes a pattern: unescaped,
266 // `c++:` throws a SyntaxError naming no file, and a key like `(x+x+)+y` backtracks for
267 // seconds per line.
268 const typed = (new RegExp('^' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':[ \\t]*(.*)$', 'm').exec(fm) || [])[1] || '';
269 const t = typed.trim();
270 if (t.includes('#') && !t.startsWith('"') && !t.startsWith("'") && v.length < t.length - 1)
271 warn(rel, `${k} is cut short at a "#": YAML read it as ${JSON.stringify(v)} — quote the whole value to keep the rest`);
272 }
273 /* `[ \\t]*`, and neither `\s*` nor `\\s*`.
274 `\s` inside a template literal is not an escape sequence — the backslash is dropped
275 and the class becomes a literal `s*`, which works on every value that does not begin
276 with an `s` immediately after the colon. Writing it `\\s*` fixes that and breaks
277 something worse: `\s` matches newlines, so on a key with nothing after it the class
278 runs past the end of the line and `(.*)` captures the NEXT key's line. A blank `deck:`
279 then reads as `type:` — non-empty, and the missing-deck warning silently stops firing.
280 Blank values are the template's normal state, so that is the case to get right.
281 A YAML key is separated from its value by spaces or tabs, on its own line. */
282 const get = (k) => (new RegExp(`^${k}:[ \\t]*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
283
284 const title = get('title');
285 const date = get('date');
286 if (!title) err(rel, 'missing title');
287 if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) err(rel, `date is not YYYY-MM-DD: ${date || '(none)'}`);
288 else if (!realDate(date)) err(rel, `date is not a real calendar date: ${date}`);
289
290 const slug = asciiLower(path.basename(abs, '.md'));
291 if (!FILENAME.test(path.basename(abs, '.md'))) err(rel, FILENAME_RULE);
292 if (RESERVED.has(slug)) err(rel, `slug collides with a reserved name: ${slug}`);
293 if (slug !== slug.normalize('NFC')) err(rel, 'filename is not NFC-normalised (decomposed Hangul jamo)');
294 idClash(writingIds, slug, rel);
295
296 const url = `/writing/${date.slice(0, 4)}/${slug}/`;
297 if (seen.has(url)) err(rel, `URL collision: ${url} (already taken by ${seen.get(url)})`);
298 else seen.set(url, rel);
299
300 /* Decks and tags are still being backfilled, so these are staged rather than hard
301 errors — and they have to stay staged while content/_template.md ships both of them
302 blank, or copying the template would produce a post the gate refuses.
303
304 Each counts three spellings of nothing as the same thing: the key absent, the key
305 with nothing after it (YAML null) and an explicitly empty value (`deck: ""` and
306 `tags: []`, which is what the template ships). The schema absorbs all three to the
307 same parsed value, so a gate that told them apart would be reporting on punctuation
308 rather than on content. */
309 if (!get('deck')) req('deck', rel, 'deck is empty');
310
311 /* Tags have two YAML spellings and only one of them is on the key's own line:
312 `tags: [a, b]` is visible to get(), a block sequence puts its items on the lines
313 below. Looking only for `tags: []` would let a post carrying no `tags:` key at all
314 pass the check in silence: the absent case, the first of the three above. */
315 const tagsInline = /^tags:[ \t]*(.*)$/m.exec(fm);
316 const tagsBlock = /^tags:[ \t]*\r?\n[ \t]*-[ \t]*\S/m.test(fm);
317 if (!tagsBlock && !(tagsInline && /[^\s[\],]/.test(tagsInline[1]))) req('tags', rel, 'tags is empty');
318
319 checkUploads(rel, raw);
320 checkSizes(rel, raw.slice(m[0].length), m[0].split('\n').length);
321 }
322
323 /* ────────────────────────────────────────────────────────────────────────────
324 content/_template.md — the blank every post is copied from.
325
326 It is checked, but NOT as a post: it sits at content/ top level, outside the writing
327 loop above. Every rule in that loop is a rule about a finished document — a title, a
328 real date, a slug that does not collide with another post's URL — and a template that
329 had to satisfy them would have to carry a title and a date, which is the one thing a
330 blank cannot do. Running the post rules over it would mean the template can only
331 exist in a state that is already a post.
332
333 What is worth checking is whether it is still a usable blank, and that is three
334 questions the post rules never ask. Is it well-formed, i.e. one `key: value` line per
335 field. Does it offer every field the schema has — a field added to the schema and not
336 to the template is a field no post will ever carry, because the template is where post
337 front matter comes from, and nothing else would ever report that. And is every value
338 actually blank: a template carrying `draft: true` and `date: 2026-01-01` produces
339 posts silently dated to the template's own birthday and silently withheld from the
340 site.
341
342 The file is optional the way content/code is: absent, this block does nothing, so a
343 checkout without it is checked exactly as if this block were not here.
344 ──────────────────────────────────────────────────────────────────────────── */
345
346 /* The field list of the `writing` collection in src/content.config.ts, duplicated here on
347 purpose and for the same reason isProjectUrl is duplicated below — this script runs in
348 `prebuild`, before Astro has parsed anything. Keep the two in step. */
349 const WRITING_FIELDS = ['title', 'titleKo', 'date', 'deck', 'type', 'tags', 'draft'];
350
351 {
352 const abs = path.join(SRC, 'content', '_template.md');
353 const rel = 'content/_template.md';
354 if (existsSync(abs)) {
355 const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(await read(abs));
356 if (!m) err(rel, 'missing front matter — the template is what post front matter is copied from');
357 else {
358 const fields = new Map();
359 let shaped = true;
360 for (const line of m[1].split(/\r?\n/)) {
361 /* A whole-line comment is allowed here. This is the one file whose job is to
362 explain itself, and forbidding the normal way to annotate YAML would be a strange
363 rule for it — content/_template-code.md already annotates itself that way. */
364 if (!line.trim() || line.trim().startsWith('#')) continue;
365 const kv = /^([A-Za-z][A-Za-z0-9_]*):[ \t]*(.*)$/.exec(line);
366 if (!kv) { err(rel, `front matter is not one "key: value" line per field: ${line.trim()}`); shaped = false; break; }
367 /* A trailing comment is not a value. `date: # YYYY-MM-DD` is a blank field with a
368 note beside it, and calling that filled would push the author into deleting the
369 hints this file exists to carry. */
370 fields.set(kv[1], kv[2].replace(/(^|\s)#.*$/, '').trim());
371 }
372 if (shaped) {
373 const missing = WRITING_FIELDS.filter((k) => !fields.has(k));
374 const extra = [...fields.keys()].filter((k) => !WRITING_FIELDS.includes(k));
375 /* Blank, in the three spellings the schema absorbs, plus the empty list `tags`
376 carries so that its shape is visible to whoever fills it in. */
377 const filled = [...fields].filter(([, v]) => !/^(|""|''|\[\s*\])$/.test(v)).map(([k]) => k);
378 if (missing.length) err(rel, `front matter is missing ${missing.join(', ')} — every field the schema offers belongs here, blank`);
379 if (extra.length) err(rel, `front matter offers ${extra.join(', ')}, which the writing schema does not have`);
380 /* A warning, not an error. A value left here is inherited unread by every post
381 copied from the template — a `draft: true` left in it produces posts that build
382 clean and are nowhere on the site — but it is also how an author keeps a default
383 they actually want. Say it on every build; do not stop the deploy. */
384 if (filled.length) warn(rel, `a value is left in ${filled.join(', ')} — every post copied from this template inherits it unread`);
385 }
386 }
387 }
388 }
389
390 /* ────────────────────────────────────────────────────────────────────────────
391 content/code — the Code index.
392
393 A collection this script does not know about is not "probably fine": an unlisted
394 directory is content that ships without any of the checks in this file. Astro's own
395 schema in src/content.config.ts already rejects a malformed `url`, a missing `name`
396 and a non-integer `order` — but only at build time, and this script runs in
397 `prebuild`, before that. What the schema cannot see at all is an empty body, because
398 the body is where the description lives here, and two projects claiming the same
399 position in a hand-ranked list.
400
401 The directory is optional on purpose: absent, the block does nothing, so a checkout
402 without it is checked exactly as if this block were not here. `files.length === 0`
403 above is the emptiness alarm, and it deliberately counts only content/writing — a
404 handful of project stubs is not evidence that the prose survived the checkout.
405 ──────────────────────────────────────────────────────────────────────────── */
406 const codeFiles = [];
407 {
408 const d = path.join(SRC, 'content', 'code');
409 /* Not skipping underscore files, for the reason the writing loop above spells out: the
410 code collection's loader is the same `*.md` glob, so it loads them too. */
411 if (existsSync(d))
412 for (const f of await readdir(d)) if (f.endsWith('.md')) codeFiles.push(path.join(d, f));
413 }
414
415 /** The same test src/content.config.ts applies, kept in step with it by hand. */
416 const isProjectUrl = (v) => {
417 if (v.startsWith('/')) return v.endsWith('/');
418 try {
419 const u = new URL(v);
420 return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname.includes('.');
421 } catch {
422 return false;
423 }
424 };
425
426 const orders = new Map();
427 const codeIds = new Map();
428 for (const abs of codeFiles) {
429 const rel = path.relative(SRC, abs).split(path.sep).join('/');
430 const base = path.basename(abs, '.md');
431 if (!FILENAME.test(base)) err(rel, FILENAME_RULE);
432 idClash(codeIds, asciiLower(base), rel);
433 const raw = await read(abs);
434 const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
435 if (!m) { err(rel, 'missing front matter'); continue; }
436 const [, fm, body] = m;
437 /* Local, and `[ \\t]*` for the reason the writing loop's copy spells out: `\s` in a
438 template literal degrades to a literal `s`, and `\\s` matches newlines, so a key with
439 nothing after it captures the next key's line — a blank `name:` would read as `url:`
440 and the missing-name error would never fire. */
441 const get = (k) => (new RegExp(`^${k}:[ \\t]*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
442
443 if (!get('name')) err(rel, 'missing name');
444
445 const url = get('url');
446 if (!url) err(rel, 'missing url');
447 else if (!isProjectUrl(url))
448 err(rel, `url is not an absolute http(s) URL with a dotted host, nor a site path ending in "/": ${url}`);
449
450 /* The description IS the body on this collection, so an empty file is an entry that
451 renders as a bare link with nothing under it. */
452 if (!body.trim()) err(rel, 'description is empty — the body of the file is the sentence the page prints');
453 checkUploads(rel, raw);
454 checkSizes(rel, body, raw.slice(0, raw.length - body.length).split('\n').length);
455
456 /* A duplicate `order` is a hard error rather than a warning. The page does break the
457 tie — by name, then by id, so the build is deterministic either way — but on a list
458 whose entire order is hand-written, two entries claiming one position means the page
459 is not in the order its author wrote, and the fix is one character. Keyed through
460 Number() so `07` and `7` collide, which is what YAML will hand the schema anyway. */
461 const rawOrder = get('order');
462 if (!/^-?\d+$/.test(rawOrder)) err(rel, `order is not an integer: ${rawOrder || '(none)'}`);
463 else {
464 const key = String(Number(rawOrder));
465 if (orders.has(key)) err(rel, `order ${key} is already taken by ${orders.get(key)}`);
466 else orders.set(key, rel);
467 }
468 }
469
470 // about.md is the home page: its portrait lives in /uploads/ like any post image, and its
471 // prose goes through the same renderer.
472 {
473 const abs = path.join(SRC, 'content', 'about.md');
474 if (existsSync(abs)) {
475 const raw = await read(abs);
476 const fm = /^---\r?\n[\s\S]*?\r?\n---/.exec(raw);
477 checkUploads('content/about.md', raw);
478 checkSizes('content/about.md', fm ? raw.slice(fm[0].length) : raw, fm ? fm[0].split('\n').length : 1);
479 }
480 }
481
482 console.log(`check-content: ${files.length} documents, ${seen.size} URLs, ${codeFiles.length} code entries, STRICT=${[...STRICT].join(',')}`);
483 for (const w of warns) console.log(` warn ${w}`);
484 for (const e of errors) console.error(` ERROR ${e}`);
485 if (errors.length) { console.error(`\n${errors.length} error(s) — stopping the build.`); process.exit(1); }
486 console.log(` ${warns.length} warning(s), 0 errors — passed`);