blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 081303f45cb37c39022d82747768c6d75aeb2eaa
parent ff8ae2c50d13d5478eaeda4f91cdd30af1faf337
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 21:22:55 +0900

chore: correct script comments, rewrite diagrams.mjs on re-subset, ship the OFL

subset-fonts.py rewrote the hashed font names in fonts.css, Base.astro
and tokens.css but not in src/lib/diagrams.mjs, whose DIAGRAM_FONTS is
both what the diagram renderer loads and part of every diagram's cache
key. After a re-subset the keys stayed put on deleted files and the next
render stopped on a missing font. diagrams.mjs is now a target (the
existing pattern matches its public/fonts/ spelling), files are written
with LF instead of the platform default, and the script ends by saying to
run npm run diagrams and commit src/lib/diagrams/. The note in
diagrams.mjs that said the list had to be edited by hand now says so, and
the docstring states the Python 3.10 that write_text(newline=) needs.

Pretendard is SIL OFL 1.1 and was redistributed without its licence. The
upstream LICENSE is added verbatim as fonts-src/OFL-Pretendard.txt, and
both licences now sit beside the served subsets in public/fonts/, so
/fonts/OFL-Lora.txt and /fonts/OFL-Pretendard.txt are published. They
are the only change to dist/.

.gitignore gains Thumbs.db, desktop.ini, .env* and *.log.

Comments, in all five scripts:
- False or stale claims: the "_ skip" that does not exist, the template's
blanks (it ships "" and [], not YAML null), a malformed image path or
oversize paragraph "shipping the post empty" where the render guard now
stops the build, "28 to 80 times the largest paragraph" (measured: 6.7x
the bytes, 36x the marks), the only-safety-net claim, the tags-absent
case (every post carries the key), the build serving a stale diagram
(npm run build passes --force; dev and a bare build do not), the old
deploy-hook story in build-info, and in subset-fonts the weights (links,
nav and wordmark are 400; 700 is kickers, table headers, the byline and
strong), the em and ligature counts, the italic claim and the page count
in the summary line.
- The LATIN note now says the scan read source text and so missed the
footer's &copy;, which Lora has and the subset leaves out.
- Process narration ("used to", "until recently", "as this did", "the
outgoing sans", the retired faces) rewritten as present-tense reasons.
- "see the section near the end" names the section; "cannot disagree"
reads "kept in step".

Diffstat:
M.gitignore | 4++++
Afonts-src/OFL-Pretendard.txt | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apublic/fonts/OFL-Lora.txt | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apublic/fonts/OFL-Pretendard.txt | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/build-info.mjs | 10+++++++---
Mscripts/check-content.mjs | 97++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mscripts/check-dist.mjs | 2+-
Mscripts/render-diagrams.mjs | 14++++++++------
Mscripts/subset-fonts.py | 97+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Msrc/lib/diagrams.mjs | 7+++----
10 files changed, 433 insertion(+), 99 deletion(-)

diff --git a/.gitignore b/.gitignore
@@ -4,3 +4,7 @@ dist/
 public/build-info.json
 .DS_Store
 .fonts-work/
+Thumbs.db
+desktop.ini
+.env*
+*.log
diff --git a/fonts-src/OFL-Pretendard.txt b/fonts-src/OFL-Pretendard.txt
@@ -0,0 +1,104 @@
+Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
+with Reserved Font Name 'Pretendard'.
+
+Copyright 2014-2021 Adobe (http://www.adobe.com/),
+with Reserved Font Name 'Source'.
+Source is a trademark of Adobe in the United States and/or other countries.
+
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter),
+with Reserved Font Name 'Inter'.
+
+Copyright 2021 The M+ FONTS Project Authors (https://github.com/coz-m/MPLUS_FONTS),
+with Reserved Font Name 'M PLUS 1'.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/OFL-Lora.txt b/public/fonts/OFL-Lora.txt
@@ -0,0 +1,93 @@
+Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/public/fonts/OFL-Pretendard.txt b/public/fonts/OFL-Pretendard.txt
@@ -0,0 +1,104 @@
+Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard),
+with Reserved Font Name 'Pretendard'.
+
+Copyright 2014-2021 Adobe (http://www.adobe.com/),
+with Reserved Font Name 'Source'.
+Source is a trademark of Adobe in the United States and/or other countries.
+
+Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter),
+with Reserved Font Name 'Inter'.
+
+Copyright 2021 The M+ FONTS Project Authors (https://github.com/coz-m/MPLUS_FONTS),
+with Reserved Font Name 'M PLUS 1'.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/scripts/build-info.mjs b/scripts/build-info.mjs
@@ -1,9 +1,13 @@
 /**
  * build-info.mjs — lets the site state its own provenance.
  *
- * A deploy pipeline cannot be asked "is my commit live yet?", because the hook that starts
- * it carries no payload back. So the question is put to the site instead: it publishes the
- * commit it was built from, and a check reads that.
+ * "Is my commit live yet?" is answered by the site itself rather than by the pipeline that
+ * deployed it: every build publishes /build-info.json with the commit it was built from and
+ * when, so the answer is whatever the live site serves.
+ *
+ * Content and code share one repository, so contentSha and appSha name the same commit.
+ * CONTENT_DIR and CONTENT_SHA can still override the content side; nothing in this
+ * repository or its workflow sets them.
  */
 import { writeFile, mkdir } from 'node:fs/promises';
 import { execFileSync } from 'node:child_process';
diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs
@@ -1,7 +1,10 @@
 /**
  * check-content.mjs — the pre-deploy content gate.
  *
- * Since pushing is the only way to publish, this is the only safety net the site has.
+ * Since pushing is the only way to publish, this is the first of the site's safety nets.
+ * It runs in prebuild, before Astro, as does scripts/render-diagrams.mjs, which stops the
+ * build on a diagram it cannot bake; the render guards and scripts/check-dist.mjs come
+ * after.
  *
  * Strictness is staged through CONTENT_STRICT, a comma-separated list:
  *   deck, tags   an empty deck, or empty tags, becomes an error instead of a warning
@@ -34,7 +37,7 @@ const req = (k, f, m) => (on(k) ? err(f, m) : warn(f, m));
 /**
  * Read a content file, dropping a leading UTF-8 BOM.
  *
- * Windows editors write one by default — PowerShell's own `Out-File -Encoding utf8` does —
+ * Some Windows tools write one — Windows PowerShell's `Out-File -Encoding utf8` does —
  * and it lands in front of the opening `---`. Astro strips it and parses the file normally,
  * so a post carrying one builds and publishes correctly. Without this, every front-matter
  * regex below misses on the very first character and the gate stops the build with
@@ -81,7 +84,8 @@ const realDate = (d) => {
    never after a host name, and never inside fenced code or a code span. A tutorial that
    quotes `curl http://host/uploads/shell.php` is not naming an image this site serves.
    The path is percent-decoded first, as the renderer does. A malformed escape such as
-   `100%.png` makes the renderer throw and the post ship empty, so it is an error here. */
+   `100%.png` makes the renderer throw and the build stop at that page
+   (src/lib/rendered.ts), so it is an error here, where the message can name the path. */
 const listings = new Map();
 function listing(dir) {
   if (!listings.has(dir)) {
@@ -140,13 +144,13 @@ function outsideCode(md) {
 /* ── Sizes the renderer cannot take ──────────────────────────────────────────
    Sätteri's smart punctuation costs bytes x marks within one inline run: a paragraph, a
    list item, a heading. Measured: 85 KB took 1.4 s and 2 GB; from 88 KB of pasted JSON or
-   169 KB of unfenced log the render throws and the post would ship empty; past about
+   169 KB of unfenced log the render throws and the build stops at that page; past about
    41,000 apostrophes in one run Node itself dies with no file named. The same text split
    into paragraphs costs nothing. So each run outside code is held under 16 KB and 400
-   quote, & and ... marks, 28 to 80 times the largest paragraph the corpus has. A table
-   is held under 20,000 cells: Sätteri has no cell limit, and an 18 KB table 3,072 columns
-   wide became a 94 MB page. Code fences are not runs; their long lines are capped in
-   astro.config.mjs instead. */
+   quote, & and ... marks, both far above any paragraph the corpus has. A table is held
+   under 20,000 cells: Sätteri has no cell limit, and an 18 KB table 3,072 columns wide
+   became a 94 MB page. Code fences are not runs; their long lines are left untokenised
+   instead (astro.config.mjs). */
 const RUN_BYTES = 16 * 1024;
 const RUN_MARKS = 400;
 const TABLE_CELLS = 20_000;
@@ -212,11 +216,11 @@ const files = [];
   const d = path.join(SRC, 'content', 'writing');
   /* Underscore files are deliberately NOT skipped, because Astro does not skip them: the
      glob loader in src/content.config.ts matches `*.md`, so a post parked as `_wip.md` is
-     loaded, schema-checked and built into a page like any other. Skipping it here only
-     meant the gate reported "0 errors — passed" and the build then died on
+     loaded, schema-checked and built into a page like any other. Skipping it here would
+     let the gate report "0 errors — passed" and the build then die on
      InvalidContentEntryDataError, which names no field and points at line 0. Checking it
-     costs nothing — there are no underscore files in this directory, and both templates
-     live at content/ top level, outside this glob — and it puts the URL-collision and
+     costs nothing — no file in this directory starts with `_`, and both templates live
+     at content/ top level, outside this glob — and it puts the URL-collision and
      reserved-slug rules onto files that really do become URLs. The supported way to park
      a post is `draft: true`, which every page and the feed honour. */
   if (existsSync(d))
@@ -259,21 +263,21 @@ for (const abs of files) {
   for (const [k, v] of Object.entries(parsed || {})) {
     if (typeof v !== 'string') continue;
     // The key is the author's text, so it is escaped before it becomes a pattern: unescaped,
-    // `c++:` threw a SyntaxError naming no file, and a key like `(x+x+)+y` backtracked for
+    // `c++:` throws a SyntaxError naming no file, and a key like `(x+x+)+y` backtracks for
     // seconds per line.
     const typed = (new RegExp('^' + k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ':[ \\t]*(.*)$', 'm').exec(fm) || [])[1] || '';
     const t = typed.trim();
     if (t.includes('#') && !t.startsWith('"') && !t.startsWith("'") && v.length < t.length - 1)
       warn(rel, `${k} is cut short at a "#": YAML read it as ${JSON.stringify(v)} — quote the whole value to keep the rest`);
   }
-  /* `[ \\t]*`, and neither `\s*` nor the literal `s*` this used to be.
+  /* `[ \\t]*`, and neither `\s*` nor `\\s*`.
      `\s` inside a template literal is not an escape sequence — the backslash is dropped
      and the class becomes a literal `s*`, which works on every value that does not begin
      with an `s` immediately after the colon. Writing it `\\s*` fixes that and breaks
      something worse: `\s` matches newlines, so on a key with nothing after it the class
      runs past the end of the line and `(.*)` captures the NEXT key's line. A blank `deck:`
      then reads as `type:` — non-empty, and the missing-deck warning silently stops firing.
-     Now that blank values are the template's normal state, that is the case to get right.
+     Blank values are the template's normal state, so that is the case to get right.
      A YAML key is separated from its value by spaces or tabs, on its own line. */
   const get = (k) => (new RegExp(`^${k}:[ \\t]*(.*)$`, 'm').exec(fm)?.[1] ?? '').trim().replace(/^["']|["']$/g, '');
 
@@ -294,19 +298,20 @@ for (const abs of files) {
   else seen.set(url, rel);
 
   /* Decks and tags are still being backfilled, so these are staged rather than hard
-     errors — and they have to stay staged now that content/_template.md ships both of
-     them blank, or copying the template would produce a post the gate refuses.
+     errors — and they have to stay staged while content/_template.md ships both of them
+     blank, or copying the template would produce a post the gate refuses.
 
      Each counts three spellings of nothing as the same thing: the key absent, the key
-     with nothing after it (YAML null, which is what the template ships) and an explicitly
-     empty value. The schema absorbs all three to the same parsed value, so a gate that
-     told them apart would be reporting on punctuation rather than on content. */
+     with nothing after it (YAML null) and an explicitly empty value (`deck: ""` and
+     `tags: []`, which is what the template ships). The schema absorbs all three to the
+     same parsed value, so a gate that told them apart would be reporting on punctuation
+     rather than on content. */
   if (!get('deck')) req('deck', rel, 'deck is empty');
 
   /* Tags have two YAML spellings and only one of them is on the key's own line:
      `tags: [a, b]` is visible to get(), a block sequence puts its items on the lines
-     below. Looking only for `tags: []`, as this did, meant a post carrying no `tags:` key
-     at all passed the check in silence — the absent case, not a rare one here. */
+     below. Looking only for `tags: []` would let a post carrying no `tags:` key at all
+     pass the check in silence: the absent case, the first of the three above. */
   const tagsInline = /^tags:[ \t]*(.*)$/m.exec(fm);
   const tagsBlock = /^tags:[ \t]*\r?\n[ \t]*-[ \t]*\S/m.test(fm);
   if (!tagsBlock && !(tagsInline && /[^\s[\],]/.test(tagsInline[1]))) req('tags', rel, 'tags is empty');
@@ -318,24 +323,24 @@ for (const abs of files) {
 /* ────────────────────────────────────────────────────────────────────────────
    content/_template.md — the blank every post is copied from.
 
-   It is checked, but NOT as a post, and the `_` skip in the loop above stays exactly as
-   it is. Every rule up there is a rule about a finished document — a title, a real date,
-   a slug that does not collide with another post's URL — and a template that had to
-   satisfy them would have to carry a title and a date, which is the one thing a blank
-   cannot do. Running the post rules over it would mean the template can only exist in a
-   state that is already a post.
+   It is checked, but NOT as a post: it sits at content/ top level, outside the writing
+   loop above. Every rule in that loop is a rule about a finished document — a title, a
+   real date, a slug that does not collide with another post's URL — and a template that
+   had to satisfy them would have to carry a title and a date, which is the one thing a
+   blank cannot do. Running the post rules over it would mean the template can only
+   exist in a state that is already a post.
 
    What is worth checking is whether it is still a usable blank, and that is three
    questions the post rules never ask. Is it well-formed, i.e. one `key: value` line per
    field. Does it offer every field the schema has — a field added to the schema and not
    to the template is a field no post will ever carry, because the template is where post
    front matter comes from, and nothing else would ever report that. And is every value
-   actually blank: this file shipped `draft: true` and `date: 2026-01-01` until recently,
-   which meant copying it produced a post silently dated to the template's own birthday
-   and silently withheld from the site.
+   actually blank: a template carrying `draft: true` and `date: 2026-01-01` produces
+   posts silently dated to the template's own birthday and silently withheld from the
+   site.
 
    The file is optional the way content/code is: absent, this block does nothing, so a
-   checkout without it fails no differently than before.
+   checkout without it is checked exactly as if this block were not here.
    ──────────────────────────────────────────────────────────────────────────── */
 
 /* The field list of the `writing` collection in src/content.config.ts, duplicated here on
@@ -373,9 +378,9 @@ const WRITING_FIELDS = ['title', 'titleKo', 'date', 'deck', 'type', 'tags', 'dra
         if (missing.length) err(rel, `front matter is missing ${missing.join(', ')} — every field the schema offers belongs here, blank`);
         if (extra.length) err(rel, `front matter offers ${extra.join(', ')}, which the writing schema does not have`);
         /* A warning, not an error. A value left here is inherited unread by every post
-           copied from the template — which is how `draft: true` once produced posts that
-           built clean and were nowhere on the site — but it is also how an author keeps a
-           default they actually want. Say it on every build; do not stop the deploy. */
+           copied from the template — a `draft: true` left in it produces posts that build
+           clean and are nowhere on the site — but it is also how an author keeps a default
+           they actually want. Say it on every build; do not stop the deploy. */
         if (filled.length) warn(rel, `a value is left in ${filled.join(', ')} — every post copied from this template inherits it unread`);
       }
     }
@@ -385,18 +390,18 @@ const WRITING_FIELDS = ['title', 'titleKo', 'date', 'deck', 'type', 'tags', 'dra
 /* ────────────────────────────────────────────────────────────────────────────
    content/code — the Code index.
 
-   A collection this script does not know about is not "probably fine": the gate is the
-   only thing standing between a push and the live site, so an unlisted directory is
-   content that ships unchecked. Astro's own schema in src/content.config.ts already
-   rejects a malformed `url`, a missing `name` and a non-integer `order` — but only at
-   build time, and this script runs in `prebuild`, before that. What the schema cannot see
-   at all is an empty body, because the body is where the description lives here, and two
-   projects claiming the same position in a hand-ranked list.
+   A collection this script does not know about is not "probably fine": an unlisted
+   directory is content that ships without any of the checks in this file. Astro's own
+   schema in src/content.config.ts already rejects a malformed `url`, a missing `name`
+   and a non-integer `order` — but only at build time, and this script runs in
+   `prebuild`, before that. What the schema cannot see at all is an empty body, because
+   the body is where the description lives here, and two projects claiming the same
+   position in a hand-ranked list.
 
    The directory is optional on purpose: absent, the block does nothing, so a checkout
-   without it fails no differently than before. `files.length === 0` above is the
-   emptiness alarm, and it deliberately still counts only content/writing — seven project
-   stubs are not evidence that the prose survived the checkout.
+   without it is checked exactly as if this block were not here. `files.length === 0`
+   above is the emptiness alarm, and it deliberately counts only content/writing — a
+   handful of project stubs is not evidence that the prose survived the checkout.
    ──────────────────────────────────────────────────────────────────────────── */
 const codeFiles = [];
 {
@@ -407,7 +412,7 @@ const codeFiles = [];
     for (const f of await readdir(d)) if (f.endsWith('.md')) codeFiles.push(path.join(d, f));
 }
 
-/** The same test src/content.config.ts applies, so the two cannot disagree about a link. */
+/** The same test src/content.config.ts applies, kept in step with it by hand. */
 const isProjectUrl = (v) => {
   if (v.startsWith('/')) return v.endsWith('/');
   try {
diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs
@@ -1,7 +1,7 @@
 /**
  * check-dist.mjs — the post-build checks on what is about to be uploaded: every page meant
  * to carry a body has one, and every page's Content-Security-Policy allows what the page
- * actually inlines (see the section near the end).
+ * actually inlines (see the Content-Security-Policy section).
  *
  * 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
diff --git a/scripts/render-diagrams.mjs b/scripts/render-diagrams.mjs
@@ -199,8 +199,8 @@ async function connect(wsUrl) {
 
 /**
  * `promise`, or a rejection after `ms`. The timer is cleared either way: a bare
- * Promise.race leaves it pending, and Node then stays alive until it fires, which held
- * every successful render open for the full two-minute render budget.
+ * Promise.race leaves it pending, and Node then stays alive until it fires, which would
+ * hold every successful render open for the full two-minute render budget.
  */
 function within(promise, ms, message) {
   let timer;
@@ -355,10 +355,12 @@ process.exit(0);
 
 /**
  * Astro's content layer caches rendered Markdown keyed on the *Markdown* file's digest, and
- * the SVG is inlined into that HTML. Re-bake a diagram without touching the prose and the
- * next build happily serves the previous picture — the one failure this whole design exists
- * to prevent. So a bake drops the store: it is a cache, the rebuild costs a second, and it
- * only happens on the rare run that changed a diagram in the first place.
+ * the SVG is inlined into that HTML. `npm run build` passes --force and re-renders every
+ * entry regardless, but `astro dev` and a build without --force read the store: re-bake a
+ * diagram without touching the prose and they serve the previous picture — the one failure
+ * this whole design exists to prevent. So a bake drops the store: it is a cache, the
+ * rebuild costs a second, and it only happens on the rare run that changed a diagram in
+ * the first place.
  */
 async function invalidateRenderedMarkdown() {
   for (const store of ['node_modules/.astro/data-store.json', '.astro/data-store.json']) {
diff --git a/scripts/subset-fonts.py b/scripts/subset-fonts.py
@@ -1,21 +1,28 @@
 #!/usr/bin/env python3
 """Rebuild the shipped web fonts from the variable masters in fonts-src/.
 
-fonts-src/ is deliberately outside public/: Astro copies public/ verbatim, so the
-masters used to be published alongside the subsets - 294,772B of fonts in dist/fonts/
+fonts-src/ is deliberately outside public/: Astro copies public/ verbatim, so masters
+kept there would be published alongside the subsets - 294,772B of masters in dist/fonts/
 for a site that references two files totalling 140,436B.
 
 Maintenance tool, not a build step: the outputs are committed to public/fonts/ and the
 GitHub Actions build never runs this. Re-run it when the corpus gains characters the
-subsets do not cover, then commit the new files and the hashes printed at the end.
+subsets do not cover. It rewrites every live reference to the old file names (see
+rewrite(); the measured-results table below keeps the names it was measured with); then
+run `npm run diagrams`, whose cache is keyed to those names, and commit the new fonts,
+the rewritten files and src/lib/diagrams/ together. Needs Python 3.10 or later.
 
     python -m pip install "fonttools[woff]" brotli
     python scripts/subset-fonts.py
 
-Two faces, two scripts. Lora is the only Latin face on the site - body, headings, links,
-nav and the wordmark all resolve to it - and Pretendard covers Hangul. Lora has no
-Hangul and Pretendard's Latin is switched off by unicode-range, so the two never compete
-for a codepoint.
+Both faces are under the SIL Open Font License 1.1, which requires the licence to travel
+with every copy: OFL-Lora.txt and OFL-Pretendard.txt sit beside the masters in fonts-src/
+and beside the subsets in public/fonts/, and this script leaves them alone.
+
+Two faces, two scripts. Lora is the only Latin face the site ships - body, headings,
+links, nav and the wordmark all resolve to it; code uses the reader's system monospace -
+and Pretendard covers Hangul. Lora has no Hangul and Pretendard's Latin is switched off
+by unicode-range, so the two never compete for a codepoint.
 
 Why each flag is here, measured on this repo's masters by removing one flag at a time
 from the recipe below and re-encoding in a single session, so the pairs below compare
@@ -23,7 +30,7 @@ like with like:
 
   unicodes   The dominant saving on Lora, and the only large one. The master carries 778
              codepoints - 318 Latin Extended and Vietnamese, 242 Cyrillic, 20 combining
-             marks, 6 Greek - against the 105 the whole built corpus paints. Cutting to
+             marks, 6 Greek - against the 105 kept in LATIN below. Cutting to
              LATIN takes 71,504 -> 22,288. On Pretendard the same idea drops the Latin the
              unicode-range gate had already made unreachable: 130,528 -> 118,212.
 
@@ -36,15 +43,14 @@ like with like:
 
   features   kern + liga, nothing else. GPOS also carries mark attachment and script
              shaping this site never invokes; dropping the rest takes Lora 23,268 ->
-             22,288. On Pretendard it is now worth 28B, i.e. nothing: once the Latin is
+             22,288. On Pretendard it is worth 28B, i.e. nothing: once the Latin is
              gone, Hangul has no other features left to drop. Kept for both so the recipe
              is one recipe.
 
-             liga is the one the outgoing sans had no use for. Lora's f has a prominent
-             hook that runs into the dot of a following i, and the built corpus renders 61
-             `fi`, 52 `fl` and 17 `ffl` sequences. Lora's liga covers f+i and f+l and
-             costs 396B over kern alone. Dropping it would save a third of a kilobyte by
-             breaking 113 words.
+             liga is kept for Lora's f, whose prominent hook runs into the dot of a
+             following i, and the built corpus renders many `fi`, `fl` and `ffl`
+             sequences. Lora's liga covers f+i and f+l and costs 396B over kern alone.
+             Dropping it would save a third of a kilobyte by breaking every one of them.
 
 Measured results (bytes), as shipped:
     lora-var.woff2          84,764 -> latin-9bba8f25.woff2    22,300   (-74%)
@@ -60,20 +66,23 @@ above are quoted to compare against each other, never as a checksum.
 Not shipped, deliberately:
 
     Lora-Italic (wght 400-700, same recipe)   ~23,400   0 renders in the whole corpus.
-        The corpus holds 25 markdown emphasis runs. satteri-figure.mjs lifts 15 of them
-        out as figure captions, which are upright by design, and the 10 that survive as
-        <em> are all on documents that resolve to lang="ko", where [slug].astro sets
+        Emphasis that satteri-figure.mjs lifts out as a figure caption is upright by
+        design, and every <em> left in dist/ is on a document that resolves to
+        lang="ko", where [slug].astro sets
         `.post[lang='ko'] .prose em { font-style: normal; font-weight: 500 }` because
-        Pretendard has no italic. Nothing on the site requests font-style: italic, so
-        nothing synthesises one either - this is 23KB that would never be fetched.
-        If an English post ever uses emphasis, put Lora-Italic-wght back in fonts-src/ and
-        add one JOBS row with font-style: italic in fonts.css; the recipe is unchanged.
+        Pretendard has no italic. The same file's `.prose em { font-style: italic }` is
+        therefore overridden on every <em>, and dist/ holds no <i> or <cite>, so no page requests
+        an italic and none is synthesised - this is 23KB that would never be fetched.
+        If an English post ever uses emphasis, put a Lora-Italic master in fonts-src/,
+        add one JOBS row here and an @font-face with font-style: italic in fonts.css;
+        the recipe is unchanged.
 
     Three pinned weights instead of one axis   11,872 + 12,396 + 11,912 = 36,180, against
-        22,288 for the axis. The design reaches 400 (body), 500 (headings, Korean
-        emphasis) and 700 (links, kickers, nav, wordmark), and Lora's axis is exactly
-        400-700, so all three are real instances. One variable file carrying all three is
-        13,892B cheaper than three static ones and is one request instead of three.
+        22,288 for the axis. The design reaches 400 (body, links, nav, wordmark), 500
+        (headings, Korean emphasis) and 700 (kickers, table headers, the byline's name,
+        strong), and Lora's axis is exactly 400-700, so all three are real instances.
+        One variable file carrying all three is 13,892B cheaper than three static ones
+        and is one request instead of three.
 """
 import hashlib, os, pathlib, sys
 from fontTools.ttLib import TTFont
@@ -82,22 +91,21 @@ from fontTools import subset
 
 ROOT = pathlib.Path(__file__).resolve().parent.parent
 SRC = ROOT / "fonts-src"          # variable masters, never published
-OUT = ROOT / "public" / "fonts"    # the two files the site actually serves
+OUT = ROOT / "public" / "fonts"    # the two faces the site serves, beside their licences
 TMP = ROOT / ".fonts-work"
 
 # The five Hangul blocks, identical to the unicode-range in fonts.css.
 KO = "U+1100-11FF,U+3130-318F,U+A960-A97F,U+AC00-D7A3,U+D7B0-D7FF"
 
-# Printable ASCII plus the ten non-ASCII marks the built corpus actually paints. This is
-# the exact set, not an estimate: every .html in dist/ plus every .md in content/ was
-# scanned, and the non-Hangul result is these 105 codepoints. The retired Inter and
-# Newsreader masters had already been cut the same way, to 107 and 104, so nothing the
-# site renders today is lost - and U+2022 and U+2248 are gained, because Newsreader
-# carried no bullet and neither face carried an approximation sign.
+# Printable ASCII plus ten non-ASCII marks. Every .html in dist/ plus every .md in
+# content/ was scanned as source text, and the non-Hangul characters Lora has come to
+# exactly these 105 codepoints. A character written as an
+# entity is not seen that way: the footer's `&copy;` (Base.astro) paints U+00A9, which
+# Lora has and this set leaves out, so the sign is drawn from the fallback face.
 #
 # 27 further non-Hangul codepoints appear in the corpus and are in no shipped face:
-# arrows, box drawing, circled digits, kana and emoji. They fall to a system font, which
-# is what they do today as well. Adding them is not an option - Lora has none of them.
+# arrows, box drawing, circled digits, kana, a Han character, symbols and emoji. They
+# fall to a system font. Adding them is not an option - Lora has none of them.
 LATIN = "U+0020-007E,U+00B7,U+2013-2014,U+2018-2019,U+201C-201D,U+2022,U+2026,U+2248"
 
 JOBS = [
@@ -148,10 +156,10 @@ def build(master, stem, limits, unicodes):
 
 
 def rewrite(names):
-    """Point fonts.css, Base.astro and tokens.css at the files that were just written.
+    """Point fonts.css, Base.astro, tokens.css and diagrams.mjs at the files just written.
 
     woff2 encoding is not byte-deterministic, so the digest moves on every run even when
-    the input does not. Hand-syncing filenames across three files after each rebuild is a
+    the input does not. Hand-syncing filenames across four files after each rebuild is a
     guaranteed source of a silent 404 and a page rendered entirely in fallback, so the
     script owns the references instead of documenting them.
     """
@@ -161,16 +169,23 @@ def rewrite(names):
         ROOT / "src" / "layouts" / "Base.astro",
         # tokens.css cites the shipped Latin file by its hashed name in the type-scale
         # derivation comment, so it goes stale on every rebuild exactly like the other
-        # two. It carries no url(), only prose, so a stale name here is a misleading
+        # files. It carries no url(), only prose, so a stale name here is a misleading
         # citation rather than a 404 - but it is the same hand-sync this function exists
         # to remove.
         ROOT / "src" / "styles" / "tokens.css",
+        # diagrams.mjs names both files in DIAGRAM_FONTS (as public/fonts/..., which the
+        # pattern below matches), and that list is both what render-diagrams.mjs loads
+        # into the browser and part of every diagram's cache key. Left stale, the keys do
+        # not move, so the committed SVGs stay keyed to fonts that no longer exist, and
+        # the next diagram render stops on a missing font file.
+        ROOT / "src" / "lib" / "diagrams.mjs",
     ]
     for t in targets:
         text = t.read_text(encoding="utf-8")
         for stem, name in names.items():
             text = re.sub(rf"/fonts/{stem}-[0-9a-f]{{8}}\.woff2", f"/fonts/{name}", text)
-        t.write_text(text, encoding="utf-8")
+        # LF, as .gitattributes keeps the working tree; the default writes CRLF on Windows.
+        t.write_text(text, encoding="utf-8", newline="\n")
         print(f"  updated {t.relative_to(ROOT)}")
 
 
@@ -183,9 +198,13 @@ def main():
         print(f"{name:<34}{size:>9,}")
     print("-" * 43)
     print(f"{'latin only (english route)':<34}{sizes['latin']:>9,}")
-    print(f"{'+ korean face (31 of 35 pages)':<34}{sizes['latin'] + sizes['ko']:>9,}")
+    print(f"{'+ korean face (korean route)':<34}{sizes['latin'] + sizes['ko']:>9,}")
     print()
     rewrite(names)
+    print()
+    print("Next: run `npm run diagrams`. The diagram cache is keyed to these file names,")
+    print("so every diagram re-renders; commit src/lib/diagrams/ with the fonts and the")
+    print("files updated above.")
 
 
 if __name__ == "__main__":
diff --git a/src/lib/diagrams.mjs b/src/lib/diagrams.mjs
@@ -16,10 +16,9 @@
  * pixel — schema version, mermaid config, palette, and the content-hashed font filenames.
  * Nothing here needs a manifest: the key *is* the filename.
  *
- * Re-subsetting a face changes its filename, and so every key, only once DIAGRAM_FONTS
- * below names the new file. scripts/subset-fonts.py rewrites the name in fonts.css,
- * Base.astro and tokens.css but not here, and deletes the old file, so until this list is
- * edited by hand the keys stay the same and the next render fails on a missing font.
+ * Re-subsetting a face changes its filename, and with it every key: scripts/subset-fonts.py
+ * rewrites the name in DIAGRAM_FONTS below as well as in fonts.css, Base.astro and
+ * tokens.css, so the next `npm run diagrams` re-bakes every diagram against the new file.
  */
 import { createHash } from 'node:crypto';
 import { readFileSync } from 'node:fs';