diff --git a/astro.config.mjs b/astro.config.mjs
@@ -50,15 +50,16 @@ const DIAGRAM_STYLE_HASHES = [...diagramCopies('content')].flatMap(([key, n]) =>
});
/**
- * Grammars preloaded into the Shiki highlighter: the fence languages the corpus uses.
+ * Grammars preloaded into the Shiki highlighter: the fence languages the corpus uses, plus
+ * json beside jsonc.
*
* A warm-up list, NOT a gate — measured rather than assumed. A fence in a language outside
* it still highlights (```rust emits data-language="rust" with real tokens), and a fence in
* a language that exists nowhere logs `[Shiki] The language "notalanguage" doesn't exist,
* falling back to "plaintext"`, emits data-language="plaintext" and leaves the build green.
- * An earlier note here claimed the opposite — that anything outside the list "fails the
- * build" — and nothing in this pipeline does that. What the list buys is that the languages
- * the corpus does use are resolved once at startup instead of on first sight.
+ * Nothing in this pipeline fails the build over a fence language. What the list buys is
+ * that the languages the corpus does use are resolved once at startup instead of on first
+ * sight.
* Aliases already ship in the Shiki bundle: bash/sh/zsh resolve to shellscript,
* console resolves to shellsession.
*
@@ -71,18 +72,6 @@ const LANGS = [
'ini', 'jsonc', 'json', 'xml', 'python',
];
-/**
- * Code block wrapper. Wraps `<pre>` in `<figure class="code">` and labels the language.
- *
- * Do not match on the class: it catches nothing. Astro's built-in transformer runs
- * before user transformers and rewrites `shiki` to `astro-code` in `class`
- * (the pre hook in @astrojs/internal-helpers/dist/shiki.js). The only stable contract
- * is `dataLanguage`, which that same hook sets directly.
- *
- * The root hook must leave **exactly one** child. Astro reads only the first child of
- * root, via `codeToHast(...).then((root) => root.children[0])`
- * (@astrojs/markdown-satteri/dist/satteri-processor.js).
- */
/**
* Fence words whose grammar is not what the word says. `langAlias` below hands the
* grammar to Shiki, but Shiki writes `dataLanguage` from the *unresolved* word, so
@@ -96,8 +85,8 @@ const ALIAS = { vi: 'ini' };
* Lines of 2,000 characters or more are not tokenised. Shiki checks its 500 ms time limit
* between scans, and a single scan over a long bash line is already superlinear, so the
* limit does not bound it: measured, a 20 KB printf line took 3.0 s and a 50 KB one 19.8 s,
- * and a few KB of `<<` threw RangeError, which emptied the post. Such a line now renders as
- * one uncoloured token. The corpus's longest fence line is 1,639 characters.
+ * and a few KB of `<<` threw RangeError, which emptied the post. Such a line renders as one
+ * uncoloured token. The corpus's longest fence line is 1,639 characters.
*/
const lineCap = {
name: 'sqzer:line-cap',
@@ -106,6 +95,18 @@ const lineCap = {
},
};
+/**
+ * Code block wrapper. Wraps `<pre>` in `<figure class="code">` and labels the language.
+ *
+ * Do not match on the class: it catches nothing. Astro's built-in transformer runs
+ * before user transformers and rewrites `shiki` to `astro-code` in `class`
+ * (the pre hook in @astrojs/internal-helpers/dist/shiki.js). The only stable contract
+ * is `dataLanguage`, which that same hook sets directly.
+ *
+ * The root hook must leave **exactly one** child. Astro reads only the first child of
+ * root, via `codeToHast(...).then((root) => root.children[0])`
+ * (@astrojs/markdown-satteri/dist/satteri-processor.js).
+ */
const codeBlock = {
name: 'sqzer:code-block',
root(root) {
@@ -146,9 +147,9 @@ const codeBlock = {
export default defineConfig({
site: 'https://blog.sqzer.com',
- /* The Hugo site this replaced shipped a sitemap and the migration lost it. robots.txt
- names it, so it has to exist: a robots line pointing at a 404 is the same defect as
- the <head> feed link that pointed at nothing. */
+ /* The Hugo site this replaced shipped a sitemap, and robots.txt (src/pages/robots.txt.ts)
+ names one, so it has to exist: a robots line pointing at a 404 is a dead link handed
+ to every crawler that reads the file. */
integrations: [sitemap()],
output: 'static',
// Every canonical URL ends in a trailing slash; the directory format emits dist/<path>/index.html.
@@ -210,8 +211,8 @@ export default defineConfig({
* Parser flags. Astro hands Sätteri only `{ gfm, smartPunctuation }`. Every other
* entry in `Features` defaults off except `frontmatter`, which Sätteri turns on unless
* told otherwise (`features.frontmatter ?? true` in satteri/dist/compile.js). What is
- * listed here is the whole of the difference from stock, and each line is a defect
- * that was measurable in dist/.
+ * listed here is the whole of the difference from stock, and each entry says what it
+ * fixes or makes possible.
*/
features: {
/*
@@ -226,29 +227,28 @@ export default defineConfig({
frontmatter: false,
/*
* Smart punctuation, minus the dash rule. Quotes and ellipses are worth having in
- * 70-odd lines of prose; the dash rule is not, because it does not know what a CLI
- * flag is. Measured in the built site before this line: journalctl's
- * `#### 시간 범위 지정 (--since, --until)` shipped as
+ * prose; the dash rule is not, because it does not know what a CLI flag is. With it
+ * on, journalctl's `#### 시간 범위 지정 (--since, --until)` renders as
* `(–since, –until)`, an en dash a reader cannot paste into a shell. Only prose
- * outside backticks was ever affected — code spans and fences are untouched — but on
- * a sysadmin blog a flag named in a heading is normal writing, not markup.
+ * outside backticks is affected — code spans and fences are untouched — but on a
+ * sysadmin blog a flag named in a heading is normal writing, not markup.
*/
smartPunctuation: { dashes: false },
/*
* `## Heading { #id }` sets the anchor. Without it the braces render as literal
* text in the heading AND in the contents list, and the generated slug swallows
- * them (`…-custom-id-`). Now that every post is written to carry a contents rail,
+ * them (`…-custom-id-`). The contents list and rail link to headings by anchor, so
* an author needs a way to keep an anchor stable across a retitling. No corpus
* heading contains a brace, so nothing existing changes shape.
*/
headingAttributes: true,
/*
* Not enabled, and each is a decision rather than an oversight:
- * math 0 real formulas in the corpus. The 2 `$…$` hits are shell
- * variables, and `singleDollarTextMath` is on by default — turning
- * math on would eat them.
+ * math no post contains a formula, and `singleDollarTextMath` is on by
+ * default — turning math on would read a `$…$` pair in prose, two
+ * shell variables or two prices, as one.
* subscript `~sub~` collides head-on with GFM strikethrough, which is the
- * very collision satteri-tilde exists to undo.
+ * very collision satteri-korean exists to undo.
* superscript `^x^` is unused, and ^ appears in regexes and shell prose.
* directive `:::note` blocks need styling per directive to mean anything.
* wikilinks no wiki.
@@ -264,10 +264,11 @@ export default defineConfig({
* highlighting intact so satteriDiagram can swap in the SVG that
* scripts/render-diagrams.mjs baked from it.
*
- * `math` was listed here too and is gone because it never did anything. Astro ORs its
- * own `defaultExcludeLanguages = ["math"]` into this check unconditionally
- * (@astrojs/internal-helpers/dist/markdown.js), so math is excluded whether or not it
- * is named, and no value of this option — and no langAlias — can un-exclude it.
+ * `math` is not listed because naming it does nothing. Astro ORs its own
+ * `defaultExcludeLanguages = ["math"]` (@astrojs/internal-helpers/dist/markdown.js) into
+ * this check unconditionally (the highlight plugin in
+ * @astrojs/markdown-satteri/dist/satteri-processor.js), so math is excluded whether or
+ * not it is named, and no value of this option — and no langAlias — can un-exclude it.
*
* What that costs, measured: an excluded language skips the highlight plugin, so a
* ```math fence is the one construct in the language that arrives as a bare
@@ -275,8 +276,9 @@ export default defineConfig({
* lands on the plate, including the two cases that look like they might not — an
* indented code block and a fence with no language both come through as
* figure.code[data-language=plaintext]. The corpus has 0 math fences and math parsing
- * is off, so this is left alone rather than fixed with a plugin; `.prose pre` in the
- * article template keeps the bare case from scrolling the page sideways.
+ * is off, so this is left alone rather than fixed with a plugin;
+ * `.prose pre:not(.code *)` in the article template keeps the bare case from scrolling
+ * the page sideways.
*/
syntaxHighlight: { type: 'shiki', excludeLangs: ['mermaid'] },
shikiConfig: {
diff --git a/src/content.config.ts b/src/content.config.ts
@@ -6,8 +6,9 @@ export const asciiLower = (s: string): string =>
s.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
/**
- * YAML parses `2025-12-07` into a Date. It must be read back in UTC: reading it in local
- * time shifts roughly half the corpus by a day, which would also move every post URL.
+ * YAML parses `2025-12-07` into a Date at midnight UTC. It must be read back in UTC: read
+ * in local time west of UTC, every date falls back a day, which would also move any post
+ * dated January 1 into the previous year's URL.
*/
const dateString = z.union([z.string(), z.date()]).transform((v) =>
typeof v === 'string' ? v.slice(0, 10) : v.toISOString().slice(0, 10),
@@ -46,7 +47,10 @@ const writing = defineCollection({
date: dateString,
/** In an index without images the deck carries the entry. May be empty until backfilled. */
deck: optText,
- /** Sub-views split on this value rather than on a URL segment. Untyped is the norm. */
+ /**
+ * Published as data-type on the /writing/ entry, which is what a later split into
+ * sub-views would key on rather than a URL segment. Untyped is the norm.
+ */
type: blankable(z.enum(['essay', 'research']).optional()),
/** A blank `tags:` and `tags: []` mean the same thing, and neither prints a tag list. */
tags: blankable(z.array(z.string()).default([])),
diff --git a/src/lib/diagrams.mjs b/src/lib/diagrams.mjs
@@ -14,8 +14,12 @@
*
* The cache key is a hash over the fence source *and* everything else that could move a
* pixel — schema version, mermaid config, palette, and the content-hashed font filenames.
- * Re-subset a face and its filename changes, so every diagram invalidates itself. Nothing
- * here needs a manifest: the key *is* the filename.
+ * 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.
*/
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -95,7 +99,7 @@ const ACCENT_WASH = '#f7ebeb'; /* --crimson-wash */
* Height is the price, and it is the right one: a page scrolls down already.
*
* It does re-break text the author did not break, which is the thing flowchart.wrappingWidth
- * above is set to 400 to avoid. The two are not the same call. A <br/> in a flowchart label
+ * is set to 400 to avoid. The two are not the same call. A <br/> in a flowchart label
* is the author drawing a box; a long sentence on an arrow is prose, and mermaid breaks it on
* spaces, so the Korean labels here wrap where their words already end.
*/
@@ -182,12 +186,15 @@ export const DIAGRAM_CSS = ['.edgeLabel rect{opacity:1;}'];
* Make one rendered SVG fit to be *inlined into HTML*, which is not the same document type
* mermaid serialised it for, and append the rules above.
*
- * The bug this fixes is silent. Mermaid serialises as XML, so a child combinator inside its
- * stylesheet comes out escaped: `.noteText>tspan`. Re-parsed by an HTML parser, a <style>
- * element's content is raw text — nothing decodes the entity, the selector is invalid, and
- * because one bad selector voids the whole comma-separated group it takes `.noteText` down
- * with it. Five rules per sequence diagram, covering note, loop, label and actor text. They
- * happen to be black on black here, so it cost nothing this time; it would not stay that way.
+ * Mermaid serialises as XML, so a child combinator inside its stylesheet comes out escaped:
+ * `.noteText>tspan`. Inside an inline <svg> the HTML parser decodes that again, so the
+ * browser reads the selector correctly either way. The decode here is for the
+ * Content-Security-Policy: astro.config.mjs hashes each diagram's <style> text as it stands
+ * in the file, the browser hashes the text it parsed, and the two agree only once the file
+ * holds the decoded text. A mismatch drops the whole stylesheet without a sound.
+ * Five rules per sequence diagram use the combinator: actor, label, loop, section-title and
+ * note text. They are not decoration: without `text.actor>tspan` the participant names
+ * inherit the actor box's sand fill and vanish into it.
*/
export function finishDiagram(svg, id) {
const rules = DIAGRAM_CSS.map((rule) => `#${id} ${rule}`).join('');
diff --git a/src/lib/hangul.ts b/src/lib/hangul.ts
@@ -24,12 +24,13 @@ const HANGUL_ALL = new RegExp(HANGUL.source, 'g');
*
* It follows the body, not the title, and is measured on prose only. The label drives
* leading, line breaking and speech, so it has to describe what is read. Keying off the
- * title would mislabel AI Odyssey and VTZero; counting Hangul against every character
- * would mislabel the Korean technical posts, whose shell transcripts and URLs swamp the
- * prose — measured that way sysmon lands at 0.14 and journalctl at 0.29, both plainly
- * Korean documents. With fences and link targets dropped and Hangul weighed against Latin
- * letters, the corpus splits with nothing near the line: the two English essays sit at
- * 0.25 and 0.28, and the other 28 posts all sit at 0.50 or above.
+ * title would mislabel the seven Korean posts with Latin titles, VTZero and journalctl
+ * among them; counting Hangul against every non-space character would mislabel the Korean
+ * technical posts, whose shell transcripts and URLs swamp the prose — measured that way
+ * sysmon lands at 0.14 and journalctl at 0.29, both plainly Korean documents. With fences
+ * and link targets dropped and Hangul weighed against Latin letters, the corpus splits with
+ * nothing near the line: the two English essays sit at 0.25 and 0.28, and the other 28
+ * posts all sit at 0.49 or above.
*/
export function docLang(md: string): 'en' | 'ko' {
// Linear on any input; see the note on LINK in prose.ts.
diff --git a/src/lib/order.ts b/src/lib/order.ts
@@ -1,9 +1,10 @@
/**
* Ordering: date DESC, then title by Intl.Collator('en'), then path.
*
- * This single order drives the index, the feed and the sitemap at once, so it must never
- * be delegated to filesystem or database order. Sorting by raw code points instead would
- * put `TSO` ahead of `journalctl` and silently reshuffle a whole year of posts.
+ * This single order drives the Writing index and the feed at once, so it must never be
+ * delegated to filesystem or database order. Sorting by raw code points instead would put
+ * `TSO` ahead of `journalctl`, two posts that share a date, because code points order every
+ * capital before every lowercase letter.
*/
const collator = new Intl.Collator('en');
diff --git a/src/lib/prose.ts b/src/lib/prose.ts
@@ -39,11 +39,8 @@ function outsideFences(md: string): string {
}
/*
- * `readingMinutes` stood here — 500 Korean characters plus 230 Latin words per minute,
- * printed as "N min read" in the article header. The header no longer carries a reading
- * estimate (the reference prints a title, a byline and a date and nothing else), and the
- * article template was its only caller, so it is deleted rather than left as an export
- * with no importer. `plain()` above is still read by both functions below.
+ * No reading-time estimate is derived here: the reference's article header is a title, a
+ * byline and a date, with no estimate of reading time, and this site's header follows it.
*/
export type ProseShape = 'essay' | 'technical';
@@ -58,14 +55,15 @@ export type ProseRhythm = 'flow' | 'staccato';
*
* rhythm:
* staccato — no subheadings, median paragraph <= 60 characters, 8+ paragraphs.
- * A 728px column at 20px fits about 36 Korean characters per line, so a
- * 60-character median is 1.7 lines. One-line paragraphs stacked at 24px
- * intervals stop reading as prose and start reading as verse.
- * (i-have-to: 69 paragraphs, median 40 chars; will-the-next: 92 at 45.)
+ * The 928px column at 16px holds more than 60 characters of Korean prose
+ * per line, so a 60-character median is a paragraph of one line.
+ * One-line paragraphs stacked one gap apart stop reading as prose and
+ * start reading as verse.
+ * (i-have-to: 69 paragraphs, median 40 chars; will-the-next: 92 at 46.)
* These switch from blank-line breaks to indented paragraphs in CSS.
* flow — everything else; keeps blank-line paragraph breaks.
*
- * Measured split: staccato 11, technical 11, flow-essay 8 = 30.
+ * Measured split: staccato 10, technical 11, flow-essay 9 = 30.
*/
export function proseShape(md: string): {
shape: ProseShape;
@@ -96,9 +94,9 @@ export function proseShape(md: string): {
}
/**
- * Fallback excerpt used where a deck is missing. Because `deck` is empty on all 30 posts,
- * every page currently ships an empty `<meta name="description">` — a real defect in the
- * deployed build.
+ * Fallback excerpt used where a deck is missing, which is every post while `deck` is empty
+ * on all 30: the meta description, the JSON-LD description and the feed item all come from
+ * here.
*/
export function excerpt(md: string, max = 160): string {
const body = outsideFences(plain(md));
@@ -147,7 +145,7 @@ function readable(md: string): string {
return out + inline(s.slice(from));
}
-const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
+const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
/** Prose outside code spans. */
const inline = (t: string): string =>
@@ -159,7 +157,7 @@ const inline = (t: string): string =>
.replace(/&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z]+);/g, (m, e: string) => {
if (e[0] !== '#') return NAMED[e] ?? m;
const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
- return n > 0 && n <= 0x10ffff && (n < 0xd800 || n > 0xdfff) ? String.fromCodePoint(n) : '�';
+ return n > 0 && n <= 0x10ffff && (n < 0xd800 || n > 0xdfff) ? String.fromCodePoint(n) : '\ufffd';
});
/** Date formatting, pinned to UTC: reading in local time shifts corpus dates by a day. */
diff --git a/src/lib/satteri-diagram.mjs b/src/lib/satteri-diagram.mjs
@@ -3,21 +3,23 @@
*
* This is a cache lookup and nothing else. `mermaid` is in syntaxHighlight.excludeLangs, so
* the highlight plugin returns before Shiki ever sees the block and the original
- * <pre><code data-lang="mermaid"> arrives here intact; the picture itself was drawn once, in
- * a browser, by scripts/render-diagrams.mjs, and committed under a hash of its source. No
- * browser, no async, no mermaid import, and nothing at all for the reader to download.
+ * <pre><code class="language-mermaid"> arrives here intact; the picture itself was drawn
+ * once, in a browser, by scripts/render-diagrams.mjs, and committed under a hash of its
+ * source. No browser, no async, no mermaid import, and nothing at all for the reader to
+ * download.
*
- * The output is one <figure class="diagram"> holding the SVG. It is still focusable, but it
- * is no longer a scroll region: base.css scales the picture into the column now, and the
- * baked SVG keeps the width/height attributes that give it a ratio to scale by (see the
- * useMaxWidth note in diagrams.mjs). What the tabindex buys is a keyboard reader landing on
- * the figure - the drawing is vector, so a reader who lands on it can zoom it losslessly.
+ * The output is one <figure class="diagram"> holding the SVG. It is focusable but not a
+ * scroll region: base.css scales the picture into the column, and the baked SVG keeps the
+ * width/height attributes that give it a ratio to scale by (see the useMaxWidth note in
+ * diagrams.mjs). What the tabindex buys is a keyboard reader landing on the figure - the
+ * drawing is vector, so a reader who lands on it can zoom it losslessly.
*/
import { diagramCopy, diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
/**
* A build with a fence that was never rendered is a broken build, not a warning: the
- * prebuild step renders every fence in the corpus, so a miss here means it did not run.
+ * prebuild step renders every fence its scan finds, so a miss here means it did not run or
+ * did not see that fence.
* `astro dev` skips prebuild, though, and an author who has just typed a new diagram wants
* to keep typing — so the dev server shows the source with a note instead of dying.
*/
@@ -68,12 +70,13 @@ export default function satteriDiagram() {
if (!svg) {
const message =
`No baked diagram for this mermaid fence (${diagramFile(key)}). Run: npm run diagrams`;
- // Measured, not assumed: Astro's glob loader catches a render error, logs it as
- // [glob-loader] ERROR, and finishes the build with exit 0 and that one post's body
- // empty. `process.exitCode = 1` here does not survive the CLI either. So this throw
- // is a loud line in the log, not a gate — the gate is `npm run prebuild`, which
- // exits 1 when a fence has no SVG and no browser to make one, and npm then never
- // reaches `astro build`. That is why the workflow calls `npm run build`.
+ // Measured, not assumed: Astro's glob loader catches a render error, logs it and
+ // stores the entry with no body, and `process.exitCode = 1` here does not survive
+ // the CLI. What turns this throw into a failed build is the page's render guard
+ // (src/lib/rendered.ts). The first gate is `npm run prebuild`, which exits 1 when
+ // a fence its scan finds has no SVG and no browser to make one, and npm then never
+ // reaches `astro build`. That is why the workflow calls `npm run build`. A fence
+ // the scan does not see, such as one inside a blockquote, reaches this throw.
if (!DEV) throw new Error(`satteri-diagram: ${message}`);
return {
type: 'element',
@@ -100,10 +103,10 @@ export default function satteriDiagram() {
properties: {
className: ['diagram'],
'data-diagram': kind || 'diagram',
- // Not for scrolling any more - see the header. A labelled group a reader can
- // reach, and stop on, before zooming into the one thing on the page that rewards it.
+ // Not for scrolling - see the header. A labelled group a reader can reach, and
+ // stop on, before zooming into the one thing on the page that rewards it.
// A group, not a region: every region is a landmark, and a post with two
- // sequence diagrams listed two landmarks with the same name.
+ // sequence diagrams would list two landmarks with the same name.
tabindex: '0',
role: 'group',
'aria-label': label,
diff --git a/src/lib/satteri-figure.mjs b/src/lib/satteri-figure.mjs
@@ -2,18 +2,19 @@
* satteri-figure — promotes the corpus's own caption convention to real markup.
*
* The author already writes captions as an italic paragraph directly beneath a standalone
- * image; 11 of the 22 standalone images in the corpus carry one (arp-spoofing 8, doh 2,
+ * image; 11 of the 24 image paragraphs in the corpus carry one (arp-spoofing 8, doh 2,
* bind9 1). Without this they render as ordinary italic paragraphs and every image ships
* as a bare <img> with no <figure>.
*
- * It also repairs alt text: 20 of 26 images have an alt that is empty or filename-shaped
- * ("image", "image-1", "img_4305", "2026-08-13-165932"), which a screen reader spells out
- * letter by letter. Junk alt is cleared, and where a caption exists it becomes the alt.
+ * It also repairs alt text: all 26 images in the posts have an alt that is empty or
+ * filename-shaped ("image", "images", "img_4305", "2026-08-13-165932", "1"), which a screen
+ * reader spells out letter by letter. Junk alt is cleared, and an image with a caption gets
+ * an empty alt, because the figcaption beside it is already read out.
*
* The repair is not limited to the images this plugin lifts. The figure path only takes a
* paragraph containing nothing BUT images, so an image with prose beside it, a linked
* image [](b), or an image in a list or a table cell never reaches it -- and those
- * carry exactly the same "image-1" the corpus produces everywhere else. None of the 26
+ * carry exactly the same filename alts the corpus produces everywhere else. None of the 26
* corpus images are in that position today, which is the only reason this has not been
* audible; the `image` visitor below makes the repair total rather than positional.
*/
@@ -53,10 +54,11 @@ export default function satteriFigure() {
name: 'figure',
/*
* Runs after `before` has built the figures, so every image it sees is one the figure
- * path did not claim -- plus the plates' own images, whose alt is already either a
- * caption or empty, and clearing an empty alt again is a no-op. An alt is only ever
- * cleared, never invented: a filename is worse than silence to a screen reader, and
- * this plugin has no way to know what the picture shows.
+ * path did not claim -- plus the plates' own images, whose alt the figure path has
+ * already emptied or kept as the author's own words, so testing it again changes
+ * nothing. An alt is only ever cleared, never invented: a filename is worse than
+ * silence to a screen reader, and this plugin has no way to know what the picture
+ * shows.
*/
image(node, ctx) {
const alt = node.alt ?? '';
diff --git a/src/lib/satteri-korean.mjs b/src/lib/satteri-korean.mjs
@@ -10,8 +10,9 @@
* ── 1. A single `~` is a range, not a deletion ───────────────────────────────────────
* GFM strikethrough pairs single tildes as well as double ones, and Korean writes ranges
* with a tilde constantly: `그림 3~4`, `5~8`, `25~50바이트`, `1Gbps ~ 100Gbps`. Two on one
- * line are a matched pair, so everything between them is struck through. Measured in
- * dist/ before this plugin — content/writing/how-to-win-best-paper-ko.md:231 shipped as
+ * line are a matched pair, so everything between them is struck through. Without this
+ * plugin, the paragraph of content/writing/how-to-win-best-paper-ko.md that names figures
+ * 1 to 8 renders as
*
* 그림 3<del>4는 … 그림 5</del>8에는 결과와 분석이 담겨 있을 수 잇다.
*
@@ -33,17 +34,17 @@
* The closing run is preceded by `’`, followed by `고` — a letter, so not right-flanking —
* so it cannot close, and the asterisks ship as literal text in the middle of the prose.
*
- * Measured across the built site before this plugin: **12 runs in 3 posts**, and every one
- * of them had the identical shape — content ending in Unicode punctuation (Pe or Pf, a
- * closing bracket or a closing quote) with a CJK letter (Lo) immediately after the closing
- * run. That is not a coincidence, it is the flanking rule's blind spot, and it is what the
- * `cjk-friendly` CommonMark proposals exist to close upstream.
+ * Without this plugin the corpus has **12 such runs in 3 posts**, and every one of them has
+ * the identical shape — content ending in Unicode punctuation (Pe or Pf, a closing bracket
+ * or a closing quote) with a CJK letter (Lo) immediately after the closing run. That is not
+ * a coincidence, it is the flanking rule's blind spot, and it is what the `cjk-friendly`
+ * CommonMark proposals exist to close upstream.
*
* So the repair is written as that exact shape rather than as a general "re-emphasise
* stray asterisks" pass. It fires only where a CJK letter sits against the delimiter, i.e.
* only where the flanking rule was answering a question about a script it does not model.
- * On the same corpus the one other literal-asterisk pair — bind9's `* port *`, whitespace
- * on both sides of the content — is left alone, which is CommonMark being right.
+ * A literal pair with whitespace on both sides of the content, like `* port *`, is left
+ * alone, which is CommonMark being right.
*
* Known limits, stated rather than hidden. Both are cases where the pair never reaches this
* plugin intact, and both need a parser, not a visitor:
@@ -52,8 +53,9 @@
* - Two such runs on ONE line can be mispaired by the parser before anything gets here.
* Measured on `앞**‘강조’**뒤 그리고 앞**‘강조’** 뒤.`: the first `**` cannot open and the
* second can, so the parser bolds from the second to the third and leaves the outer two
- * asterisks stranded. One run on a line — which is all 12 of the live ones, and the
- * normal way anyone writes — leaves the whole pair literal, and that is repaired.
+ * asterisks stranded. One run on a line — the normal way anyone writes — leaves the
+ * whole pair literal, and that is repaired. All 12 in the corpus are repaired,
+ * including the one line that holds two closing-shape runs, a `**` and a `*`.
*/
/**
@@ -72,7 +74,7 @@ const PUNCT = /\p{P}/u;
* A `*` or `**` pair with no whitespace just inside either delimiter and no `*` in the
* content. Those two constraints are CommonMark's own (a run cannot open on trailing
* whitespace or close on leading whitespace); repeating them here is what keeps the pass
- * from touching `2 * 3 * 4` or bind9's `* port *`.
+ * from touching `2 * 3 * 4` or `* port *`.
*/
const PAIR = /(\*\*|\*)(?![\s*])([^*]*[^\s*])\1/g;
diff --git a/src/lib/satteri-lang.mjs b/src/lib/satteri-lang.mjs
@@ -2,13 +2,14 @@
* satteri-lang — `lang` on the passages of a post that are not in the post's language.
*
* The article element carries one lang, docLang() of the body. A screen reader picks its
- * voice from lang (WCAG 3.1.2), so two kinds of passage were read in the wrong one:
+ * voice from lang (WCAG 3.1.2), so without this two kinds of passage are read in the wrong
+ * one:
*
* sections The bilingual essays split themselves with a heading that reads exactly
* EN or KR. Everything after it, up to the next heading of the same level or
* higher, is in that language. AI Odyssey is an English document by its prose
- * ratio, so its KR half — 1,765 Hangul characters — was read by an English
- * voice.
+ * ratio, so its KR half — 1,754 Hangul characters — would be read by an
+ * English voice.
* headings A heading in the other script, like the English h3s of a Korean post.
*
* Only top-level blocks and headings are marked, and only when they differ from the
diff --git a/src/lib/satteri-prose.mjs b/src/lib/satteri-prose.mjs
@@ -11,12 +11,12 @@
* starts being a collision: h6 is the floor, so h5 would land on top of it and the
* document would lose a level it was using. --fs-h5 and --fs-h6 are different sizes, so
* that is visible, not just semantic. No corpus post has an h6 today, so this guard has
- * never fired; it is here because the premise of the contents rail is that every post
- * grows headings, and a post deep enough to reach h6 is exactly the one that would.
- * 2. Stamp intrinsic image size. The 26 images live in public/uploads and so bypass
- * Astro's image service: no width/height, and every one shifts layout while loading.
- * With the attributes present and no CSS `width`, the 13 images narrower than the 728px
- * column render at their own size instead of being upscaled.
+ * never fired; it is here for the first post deep enough to reach h6, which is exactly
+ * the post the shift would damage.
+ * 2. Stamp intrinsic image size. Post and About images live in public/uploads and so
+ * bypass Astro's image service: no width/height, and every one shifts layout while
+ * loading. With the attributes present and no CSS `width`, an image narrower than the
+ * 928px column renders at its own size instead of being upscaled.
* 3. Wrap tables. 4 posts contain tables; each gets a focusable scroll container so a wide
* table never makes the page itself scroll sideways.
*/
@@ -56,11 +56,10 @@ const sizes = new Map();
* title's way, and is there an h6 already at the floor that a shift would collide with —
* found by visiting only those two tags, which Sätteri filters natively.
*
- * This used to be a `before` hook walking the whole tree in JavaScript, which materialised
- * every node: every Shiki token span and every SVG element included. Measured, a 9 KB wide
- * table took 13.9 s that way, an 18 KB one ran out of Map space after 83 s, and a 12 KB
- * nested list overflowed the stack; either failure shipped the post empty. The output is
- * byte-identical.
+ * Not a `before` hook walking the whole tree in JavaScript: that materialises every node,
+ * every Shiki token span and every SVG element included. Measured, a 9 KB wide table took
+ * 13.9 s that way, an 18 KB one ran out of Map space after 83 s, and a 12 KB nested list
+ * overflowed the stack; either failure ships the post empty.
*
* It is a plugin of its own and must come BEFORE satteriProse in hastPlugins: one plugin's
* visitors all run before the next plugin starts, so the flags are set before the
@@ -112,7 +111,7 @@ export default function satteriProse({ publicDir = 'public' } = {}) {
ctx.wrapNode(node, {
type: 'element', tagName: 'div',
// A group, as the code plates are: a region is a landmark, and the Hitchhiker post
- // listed three landmarks all named "Table".
+ // would list three landmarks all named "Table".
properties: { className: ['scroller'], tabindex: '0', role: 'group', 'aria-label': 'Table' },
children: [],
});
|