src/lib/prose.ts (6947B)
1 /**
2 * prose.ts — derives typesetting decisions from the body of a post.
3 *
4 * Why read the body instead of the front matter: `type` is unset on all 30 posts and
5 * `deck` is empty on all 30, so any branch that keys off front matter is dead code today.
6 * The body already knows the shape of the document — how many subheadings it has and how
7 * long its paragraphs run.
8 */
9
10 /**
11 * Excluded from length and shape measurements: code fences, images, tables, link URLs.
12 *
13 * Every pattern here is linear on any input, which is a property to keep when editing
14 * them. `\s*` at the start of a line can run across blank lines and rescan them from each
15 * one (80,000 newlines took 4.9 s); `[^\]]*` and `[^)]*` let every unclosed `[` or `(` scan
16 * to the end of the post (1.3 s on 80,000 `[`). Here link text stops at the next bracket,
17 * and a destination may hold one level of parentheses, as Wikipedia addresses do.
18 */
19 const LINK = /\[([^\][]*)\]\((?:[^()]|\([^()]*\))*\)/g;
20 const IMAGE = /!\[[^\][]*\]\((?:[^()]|\([^()]*\))*\)/g;
21
22 function plain(md: string): string {
23 return md
24 .replace(/```[\s\S]*?```/g, '')
25 .replace(IMAGE, '')
26 .replace(/^[ \t]*\|.*$/gm, '')
27 .replace(LINK, '$1');
28 }
29
30 /** Keeps only lines outside code fences, so a `# comment` inside one is not read as a heading. */
31 function outsideFences(md: string): string {
32 let inFence = false;
33 const out: string[] = [];
34 for (const line of md.split('\n')) {
35 if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
36 if (!inFence) out.push(line);
37 }
38 return out.join('\n');
39 }
40
41 /*
42 * No reading-time estimate is derived here: the reference's article header is a title, a
43 * byline and a date, with no estimate of reading time, and this site's header follows it.
44 */
45
46 export type ProseShape = 'essay' | 'technical';
47 export type ProseRhythm = 'flow' | 'staccato';
48
49 /**
50 * The typographic shape of a document.
51 *
52 * shape:
53 * technical — three or more subheadings; the headings are the structure (11 posts).
54 * essay — no subheadings, or decorative ones; the paragraph flow is everything (19).
55 *
56 * rhythm:
57 * staccato — no subheadings, median paragraph <= 60 characters, 8+ paragraphs.
58 * The 928px column at 16px holds more than 60 characters of Korean prose
59 * per line, so a 60-character median is a paragraph of one line.
60 * One-line paragraphs stacked one gap apart stop reading as prose and
61 * start reading as verse.
62 * (i-have-to: 69 paragraphs, median 40 chars; will-the-next: 92 at 46.)
63 * These switch from blank-line breaks to indented paragraphs in CSS.
64 * flow — everything else; keeps blank-line paragraph breaks.
65 *
66 * Measured split: staccato 10, technical 11, flow-essay 9 = 30.
67 */
68 export function proseShape(md: string): {
69 shape: ProseShape;
70 rhythm: ProseRhythm;
71 paragraphs: number;
72 medianChars: number;
73 } {
74 const body = outsideFences(plain(md));
75 const headings = (body.match(/^#{1,6}\s/gm) ?? []).length;
76
77 const lengths: number[] = [];
78 for (const block of body.split(/\n\s*\n/)) {
79 const lines = block.split('\n').filter((l) => l.trim());
80 if (lines.length === 0) continue;
81 // Headings, tables, quotes, lists and images are not paragraphs.
82 if (/^[#|>\-*!]/.test(lines[0].trimStart())) continue;
83 lengths.push(lines.join(' ').length);
84 }
85 lengths.sort((a, b) => a - b);
86 const median = lengths.length ? lengths[Math.floor(lengths.length / 2)] : 0;
87
88 return {
89 shape: headings >= 3 ? 'technical' : 'essay',
90 rhythm: headings === 0 && median <= 60 && lengths.length >= 8 ? 'staccato' : 'flow',
91 paragraphs: lengths.length,
92 medianChars: median,
93 };
94 }
95
96 /**
97 * Fallback excerpt used where a deck is missing, which is every post while `deck` is empty
98 * on all 30: the meta description, the JSON-LD description and the feed item all come from
99 * here.
100 */
101 export function excerpt(md: string, max = 160): string {
102 const body = outsideFences(plain(md));
103 for (const block of body.split(/\n\s*\n/)) {
104 const t = block.split('\n').filter((l) => l.trim()).join(' ').trim();
105 if (!t || /^[#|>\-*!]/.test(t)) continue;
106 const clean = readable(t).replace(/\s+/g, ' ').trim();
107 return clean.length <= max ? clean : clean.slice(0, max - 1).trimEnd() + '…';
108 }
109 return '';
110 }
111
112 /**
113 * One paragraph of Markdown as the text a reader sees, because that is what a description
114 * is: search results, link previews and feed readers all show it as text. Code spans stay
115 * exactly as written. Outside them the inline tags satteriGuard allows are dropped (a <br>
116 * becomes a space), emphasis markers go, backslash escapes resolve, and entities decode, so
117 * `AT&T` reads AT&T and `<abbr title="…">ARP</abbr>` reads ARP. Every consumer escapes
118 * what it receives: Astro for the meta attribute, the JSON-LD replace for `<`, and
119 * index.xml.ts for the feed.
120 *
121 * Only the first 4,000 characters are read. The description keeps 160, and a first
122 * paragraph can be a pasted log.
123 */
124 function readable(md: string): string {
125 const s = md.slice(0, 4000);
126 // A run of n backticks opens a code span that the next run of exactly n closes: pair each
127 // run with the next of its length, walking right to left once.
128 const runs = [...s.matchAll(/`+/g)];
129 const next = new Array<number>(runs.length).fill(-1);
130 const last = new Map<number, number>();
131 for (let i = runs.length - 1; i >= 0; i--) {
132 next[i] = last.get(runs[i][0].length) ?? -1;
133 last.set(runs[i][0].length, i);
134 }
135 let out = '';
136 let from = 0;
137 for (let i = 0; i < runs.length; i++) {
138 if (next[i] < 0) continue;
139 const open = runs[i];
140 const close = runs[next[i]];
141 out += inline(s.slice(from, open.index)) + s.slice(open.index! + open[0].length, close.index);
142 from = close.index! + close[0].length;
143 i = next[i];
144 }
145 return out + inline(s.slice(from));
146 }
147
148 const NAMED: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0' };
149
150 /** Prose outside code spans. */
151 const inline = (t: string): string =>
152 t
153 .replace(/<br[\t\n\f\r ]*\/?>/gi, ' ')
154 .replace(/<\/?(?:kbd|mark|sub|sup|ins|abbr)\b[^>]*>/gi, '')
155 .replace(/(?<!\\)[*_`]/g, '')
156 .replace(/\\([!-/:-@[-`{-~])/g, '$1')
157 .replace(/&(#\d{1,7}|#[xX][0-9a-fA-F]{1,6}|[A-Za-z]+);/g, (m, e: string) => {
158 if (e[0] !== '#') return NAMED[e] ?? m;
159 const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
160 return n > 0 && n <= 0x10ffff && (n < 0xd800 || n > 0xdfff) ? String.fromCodePoint(n) : '\ufffd';
161 });
162
163 /** Date formatting, pinned to UTC: reading in local time shifts corpus dates by a day. */
164 const DATE_FMT = new Intl.DateTimeFormat('en-US', {
165 timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric',
166 });
167 export const longDate = (iso: string): string => DATE_FMT.format(new Date(`${iso}T00:00:00Z`));