blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git
commit 28b28decddf08e37d3b72a7d8793d31d47e1cb7b
parent 9ac8801e9a85907fe9b52482c7fb296af16cad71
Author: sqzer <110329244+sqzer-x@users.noreply.github.com>
Date: Fri, 25 Sep 2026 05:01:59 +0900

fix: build descriptions from the text a reader sees

excerpt() returned Markdown source, so once the feed escaped it as HTML
text, an entity or an allowed inline tag in a first paragraph showed up
in feed readers as written: AT&amp;T, <abbr title="...">. It now turns
the paragraph into reader-visible text: code spans stay as written,
allowed inline tags go, <br> becomes a space, emphasis markers go,
backslash escapes resolve and entities decode. The meta description and
JSON-LD get the same text; every sink still escapes what it receives.
Only the first 4,000 characters are read. The current posts' output is
unchanged.

Diffstat:
Msrc/lib/prose.ts | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 52 insertion(+), 1 deletion(-)

diff --git a/src/lib/prose.ts b/src/lib/prose.ts
@@ -94,12 +94,63 @@ export function excerpt(md: string, max = 160): string {
   for (const block of body.split(/\n\s*\n/)) {
     const t = block.split('\n').filter((l) => l.trim()).join(' ').trim();
     if (!t || /^[#|>\-*!]/.test(t)) continue;
-    const clean = t.replace(/[*_`]/g, '').replace(/\s+/g, ' ');
+    const clean = readable(t).replace(/\s+/g, ' ').trim();
     return clean.length <= max ? clean : clean.slice(0, max - 1).trimEnd() + '…';
   }
   return '';
 }
 
+/**
+ * One paragraph of Markdown as the text a reader sees, because that is what a description
+ * is: search results, link previews and feed readers all show it as text. Code spans stay
+ * exactly as written. Outside them the inline tags satteriGuard allows are dropped (a <br>
+ * becomes a space), emphasis markers go, backslash escapes resolve, and entities decode, so
+ * `AT&amp;T` reads AT&T and `<abbr title="…">ARP</abbr>` reads ARP. Every consumer escapes
+ * what it receives: Astro for the meta attribute, the JSON-LD replace for `<`, and
+ * index.xml.ts for the feed.
+ *
+ * Only the first 4,000 characters are read. The description keeps 160, and a first
+ * paragraph can be a pasted log.
+ */
+function readable(md: string): string {
+  const s = md.slice(0, 4000);
+  // A run of n backticks opens a code span that the next run of exactly n closes: pair each
+  // run with the next of its length, walking right to left once.
+  const runs = [...s.matchAll(/`+/g)];
+  const next = new Array<number>(runs.length).fill(-1);
+  const last = new Map<number, number>();
+  for (let i = runs.length - 1; i >= 0; i--) {
+    next[i] = last.get(runs[i][0].length) ?? -1;
+    last.set(runs[i][0].length, i);
+  }
+  let out = '';
+  let from = 0;
+  for (let i = 0; i < runs.length; i++) {
+    if (next[i] < 0) continue;
+    const open = runs[i];
+    const close = runs[next[i]];
+    out += inline(s.slice(from, open.index)) + s.slice(open.index! + open[0].length, close.index);
+    from = close.index! + close[0].length;
+    i = next[i];
+  }
+  return out + inline(s.slice(from));
+}
+
+const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
+
+/** Prose outside code spans. */
+const inline = (t: string): string =>
+  t
+    .replace(/<br[\t\n\f\r ]*\/?>/gi, ' ')
+    .replace(/<\/?(?:kbd|mark|sub|sup|ins|abbr)\b[^>]*>/gi, '')
+    .replace(/(?<!\\)[*_`]/g, '')
+    .replace(/\\([!-/:-@[-`{-~])/g, '$1')
+    .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) : '�';
+    });
+
 /** Date formatting, pinned to UTC: reading in local time shifts corpus dates by a day. */
 const DATE_FMT = new Intl.DateTimeFormat('en-US', {
   timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric',