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&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',
|