blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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