blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit f333bb27ffe4aa6729cc44c19c5a3772ae5387f9
parent 6d017c9ef7bd5200b241b9327186a6e5b312f48f
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 19:49:23 +0900

perf: keep pathological input from crashing or stalling the renderer

- satteriProse found out whether a post had an h1 or an h6 by walking
the whole tree in JavaScript, materialising every token span and SVG
node. A 6,000-deep list overflowed the stack and the post rendered
empty. A new satteriHeadingScan visits only h1 and h6, natively
filtered, and runs before satteriProse.
- Shiki's time limit is checked between scans, not within one, so a long
bash line was superlinear and a few KB of << threw RangeError. Lines
of 2,000 characters or more now render as one uncoloured token; the
corpus's longest is 1,639.
- Four body regexes were quadratic on crafted input: an unclosed [ or (
scanned to the end from each one (1.3 s on 80,000), ^\s*\| rescanned
blank runs (4.9 s), and \n+$ rescanned newline runs (3.7 s). Each is
now linear; link destinations may still hold one level of parentheses.

The 30 posts build byte-identical. The list and << fixtures that crashed
on the previous commit now build with their bodies.

Diffstat:
Mastro.config.mjs | 21++++++++++++++++++---
Msrc/lib/diagrams.mjs | 4+++-
Msrc/lib/prose.ts | 19+++++++++++++++----
Msrc/lib/satteri-prose.mjs | 45+++++++++++++++++++++++++++++----------------
Msrc/pages/writing/[year]/[slug].astro | 3++-
5 files changed, 67 insertion(+), 25 deletion(-)

diff --git a/astro.config.mjs b/astro.config.mjs
@@ -6,7 +6,7 @@ import sitemap from '@astrojs/sitemap';
 import { satteri } from '@astrojs/markdown-satteri';
 import satteriFigure from './src/lib/satteri-figure.mjs';
 import satteriGuard from './src/lib/satteri-guard.mjs';
-import satteriProse from './src/lib/satteri-prose.mjs';
+import satteriProse, { satteriHeadingScan } from './src/lib/satteri-prose.mjs';
 import satteriDiagram from './src/lib/satteri-diagram.mjs';
 import satteriKorean from './src/lib/satteri-korean.mjs';
 import { diagramCopy, diagramKey, findMermaidFences, readDiagram } from './src/lib/diagrams.mjs';
@@ -91,6 +91,20 @@ const LANGS = [
  */
 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.
+ */
+const lineCap = {
+  name: 'sqzer:line-cap',
+  preprocess(code, options) {
+    options.tokenizeMaxLineLength = 2000;
+  },
+};
+
 const codeBlock = {
   name: 'sqzer:code-block',
   root(root) {
@@ -181,6 +195,7 @@ export default defineConfig({
     //   mdast: satteriFigure  — <figure>/<figcaption> out of the corpus's own convention
     //   hast:  satteriGuard   — refuses raw HTML, heading attributes, link schemes and
     //                           image hosts a post must not carry; the build fails
+    //   hast:  satteriHeadingScan — whether the post has an h1 or an h6; must precede satteriProse
     //   hast:  satteriProse   — heading demotion, intrinsic image size, table scrollers
     //   hast:  satteriDiagram — a mermaid fence -> the SVG baked from it before the build
     // Ordering matters: hastPlugins run after highlighting and BEFORE heading-id
@@ -188,7 +203,7 @@ export default defineConfig({
     // goes first, before satteriDiagram adds the one raw node the site trusts.
     processor: satteri({
       mdastPlugins: [satteriKorean(), satteriFigure()],
-      hastPlugins: [satteriGuard(), satteriProse({ publicDir: 'public' }), satteriDiagram()],
+      hastPlugins: [satteriGuard(), satteriHeadingScan(), satteriProse({ publicDir: 'public' }), satteriDiagram()],
       /*
        * Parser flags. Astro hands Sätteri only `{ gfm, smartPunctuation }`. Every other
        * entry in `Features` defaults off except `frontmatter`, which Sätteri turns on unless
@@ -271,7 +286,7 @@ export default defineConfig({
       langAlias: ALIAS,
       // Preserve lines by default. Folding is opt-in per block via ```<lang> wrap.
       wrap: false,
-      transformers: [codeBlock],
+      transformers: [lineCap, codeBlock],
     },
   },
 });
diff --git a/src/lib/diagrams.mjs b/src/lib/diagrams.mjs
@@ -210,7 +210,9 @@ export function finishDiagram(svg, id) {
  * strips one trailing newline from a fence's text; this strips every trailing newline and
  * normalises CRLF, so a checkout with other line endings still finds its cached diagram.
  */
-export const normalizeDiagram = (source) => String(source).replace(/\r\n?/g, '\n').replace(/\n+$/, '');
+// `(?<!\n)` anchors the match at the start of a newline run, so a long run in the middle of
+// a fence is not rescanned from every position (80,000 newlines took 3.7 s without it).
+export const normalizeDiagram = (source) => String(source).replace(/\r\n?/g, '\n').replace(/(?<!\n)\n+$/, '');
 
 /** 16 hex characters = 64 bits. A collision needs billions of diagrams; the corpus has 3. */
 export function diagramKey(source) {
diff --git a/src/lib/prose.ts b/src/lib/prose.ts
@@ -7,13 +7,24 @@
  * long its paragraphs run.
  */
 
-/** Excluded from length and shape measurements: code fences, images, tables, link URLs. */
+/**
+ * Excluded from length and shape measurements: code fences, images, tables, link URLs.
+ *
+ * Every pattern here is linear on any input, which is a property to keep when editing
+ * them. `\s*` at the start of a line can run across blank lines and rescan them from each
+ * one (80,000 newlines took 4.9 s); `[^\]]*` and `[^)]*` let every unclosed `[` or `(` scan
+ * to the end of the post (1.3 s on 80,000 `[`). Here link text stops at the next bracket,
+ * and a destination may hold one level of parentheses, as Wikipedia addresses do.
+ */
+const LINK = /\[([^\][]*)\]\((?:[^()]|\([^()]*\))*\)/g;
+const IMAGE = /!\[[^\][]*\]\((?:[^()]|\([^()]*\))*\)/g;
+
 function plain(md: string): string {
   return md
     .replace(/```[\s\S]*?```/g, '')
-    .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
-    .replace(/^\s*\|.*$/gm, '')
-    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
+    .replace(IMAGE, '')
+    .replace(/^[ \t]*\|.*$/gm, '')
+    .replace(LINK, '$1');
 }
 
 /** Keeps only lines outside code fences, so a `# comment` inside one is not read as a heading. */
diff --git a/src/lib/satteri-prose.mjs b/src/lib/satteri-prose.mjs
@@ -51,29 +51,42 @@ function intrinsicSize(file) {
 
 const sizes = new Map();
 
+/**
+ * The two answers the heading demotion below needs — is there an h1 to get out of the
+ * 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.
+ *
+ * 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
+ * demotion reads them. Placed after, the demotion would silently never happen.
+ */
+export function satteriHeadingScan() {
+  return {
+    name: 'heading-scan',
+    element: {
+      filter: ['h1', 'h6'],
+      visit(node, ctx) {
+        if (node.tagName === 'h1') ctx.data.proseHasH1 = true;
+        else ctx.data.proseHasH6 = true;
+      },
+    },
+  };
+}
+
 export default function satteriProse({ publicDir = 'public' } = {}) {
   return {
     name: 'prose',
-    before(root, ctx) {
-      // One pre-scan so every heading visit shares the same answer. Both questions are
-      // answered in the same walk: is there an h1 to get out of the title's way, and is
-      // there an h6 already at the floor that a shift would collide with.
-      let hasH1 = false;
-      let hasH6 = false;
-      (function walk(n) {
-        if (n.type === 'element') {
-          if (n.tagName === 'h1') hasH1 = true;
-          else if (n.tagName === 'h6') hasH6 = true;
-        }
-        for (const child of n.children ?? []) walk(child);
-      })(root);
-      ctx.data.proseDemote = hasH1 && !hasH6;
-    },
     element: [
       {
         filter: ['h1', 'h2', 'h3', 'h4', 'h5'],
         visit(node, ctx) {
-          if (!ctx.data.proseDemote) return;
+          if (!ctx.data.proseHasH1 || ctx.data.proseHasH6) return;
           return { ...node, tagName: `h${Number(node.tagName[1]) + 1}` };
         },
       },
diff --git a/src/pages/writing/[year]/[slug].astro b/src/pages/writing/[year]/[slug].astro
@@ -59,7 +59,8 @@ const year = post.data.date.slice(0, 4);
  * essays sit at 0.25 and 0.28, and the other 28 posts all sit at 0.50 or above.
  */
 const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/g;
-const prose = md.replace(/```[\s\S]*?```/g, '').replace(/\[[^\]]*\]\([^)]*\)/g, '');
+// Linear on any input; see the note on LINK in src/lib/prose.ts.
+const prose = md.replace(/```[\s\S]*?```/g, '').replace(/\[[^\][]*\]\((?:[^()]|\([^()]*\))*\)/g, '');
 const kor = (prose.match(HANGUL) ?? []).length;
 const lat = (prose.match(/[A-Za-z]/g) ?? []).length;
 const lang = kor / Math.max(1, kor + lat) > 0.4 ? 'ko' : 'en';