blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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&amp;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`));