blog.git

blog

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

fix: stop oversize paragraphs and tables at the gate, and escape YAML keys

Satteri's smart punctuation costs bytes x marks within one inline run.
Measured: 85 KB took 1.4 s and 2 GB, 88 KB of pasted JSON or 169 KB of
unfenced log made the render throw, and about 41,000 apostrophes in one
run killed Node without naming a file. Satteri also has no table cell
limit; an 18 KB table 3,072 columns wide became a 94 MB page.

The gate now holds each paragraph, list item or heading outside code to
16 KB and 400 quote, & and ... marks, 28 to 80 times the largest
paragraph in the corpus, and each table to 20,000 cells, reporting the
line and what to do. Posts, code entries and about.md are all checked.

A front-matter key went into a RegExp unescaped: `c++:` threw a
SyntaxError that named no file. Keys are now escaped.

Diffstat:
Mscripts/check-content.mjs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 69 insertion(+), 3 deletion(-)

diff --git a/scripts/check-content.mjs b/scripts/check-content.mjs
@@ -137,6 +137,61 @@ function outsideCode(md) {
   }).join('\n\n');
 }
 
+/* ── 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
+   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. */
+const RUN_BYTES = 16 * 1024;
+const RUN_MARKS = 400;
+const TABLE_CELLS = 20_000;
+const DELIMITER_ROW = /^\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)+\|?[ \t]*$/;
+const STARTS_RUN = /^(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)|^#{1,6}(?:[ \t]|$)/;
+
+function checkSizes(rel, body, firstLine) {
+  const blocks = [];
+  let block = null;
+  let fence = null;
+  body.replace(/\r\n?/g, '\n').split('\n').forEach((line, i) => {
+    const run = /^[\s>]*(`{3,}|~{3,})/.exec(line);
+    const rest = run ? line.slice(run.index + run[0].length) : '';
+    if (fence) {
+      if (run && run[1][0] === fence[0] && run[1].length >= fence.length && !rest.trim()) fence = null;
+      return;
+    }
+    const text = line.replace(/^[ \t>]*/, '');
+    if (run && !(run[1][0] === '`' && rest.includes('`'))) { fence = run[1]; block = null; return; }
+    if (!text.trim()) { block = null; return; }
+    if (!block) blocks.push((block = { line: firstLine + i, lines: [] }));
+    block.lines.push({ line: firstLine + i, text });
+  });
+  for (const b of blocks) {
+    if (b.lines.some((l) => DELIMITER_ROW.test(l.text))) {
+      const columns = b.lines[0].text.replace(/^\||\|$/g, '').split('|').length;
+      const cells = columns * (b.lines.length - 1);
+      if (cells > TABLE_CELLS)
+        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`);
+      continue;
+    }
+    const runs = [];
+    for (const l of b.lines) {
+      if (!runs.length || STARTS_RUN.test(l.text)) runs.push({ line: l.line, text: '' });
+      runs[runs.length - 1].text += `${l.text}\n`;
+    }
+    for (const r of runs) {
+      const bytes = Buffer.byteLength(r.text, 'utf8');
+      const marks = (r.text.match(/["'&]|\.\.\./g) ?? []).length;
+      if (bytes > RUN_BYTES || marks > RUN_MARKS)
+        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`);
+    }
+  }
+}
+
 /* Two spellings, tried in this order at each position. `(</uploads/a b.png>)` is how
    CommonMark writes a destination with a space in it (screenshots are named that way by
    default), and it runs to the `>`; the bare form ends at the first space or delimiter. */
@@ -203,7 +258,10 @@ for (const abs of files) {
      place it can be noticed is here, by comparing what was typed against what parsed. */
   for (const [k, v] of Object.entries(parsed || {})) {
     if (typeof v !== 'string') continue;
-    const typed = (new RegExp('^' + k + ':[ \\t]*(.*)$', 'm').exec(fm) || [])[1] || '';
+    // 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
+    // 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`);
@@ -254,6 +312,7 @@ for (const abs of files) {
   if (!tagsBlock && !(tagsInline && /[^\s[\],]/.test(tagsInline[1]))) req('tags', rel, 'tags is empty');
 
   checkUploads(rel, raw);
+  checkSizes(rel, raw.slice(m[0].length), m[0].split('\n').length);
 }
 
 /* ────────────────────────────────────────────────────────────────────────────
@@ -387,6 +446,7 @@ for (const abs of codeFiles) {
      renders as a bare link with nothing under it. */
   if (!body.trim()) err(rel, 'description is empty — the body of the file is the sentence the page prints');
   checkUploads(rel, raw);
+  checkSizes(rel, body, raw.slice(0, raw.length - body.length).split('\n').length);
 
   /* A duplicate `order` is a hard error rather than a warning. The page does break the
      tie — by name, then by id, so the build is deterministic either way — but on a list
@@ -402,10 +462,16 @@ for (const abs of codeFiles) {
   }
 }
 
-// about.md is the home page, and its portrait lives in /uploads/ like any post image.
+// about.md is the home page: its portrait lives in /uploads/ like any post image, and its
+// prose goes through the same renderer.
 {
   const abs = path.join(SRC, 'content', 'about.md');
-  if (existsSync(abs)) checkUploads('content/about.md', await read(abs));
+  if (existsSync(abs)) {
+    const raw = await read(abs);
+    const fm = /^---\r?\n[\s\S]*?\r?\n---/.exec(raw);
+    checkUploads('content/about.md', raw);
+    checkSizes('content/about.md', fm ? raw.slice(fm[0].length) : raw, fm ? fm[0].split('\n').length : 1);
+  }
 }
 
 console.log(`check-content: ${files.length} documents, ${seen.size} URLs, ${codeFiles.length} code entries, STRICT=${[...STRICT].join(',')}`);