src/lib/satteri-prose.mjs (5636B)
1 /**
2 * satteri-prose — three repairs to the rendered tree, in one plugin.
3 *
4 * 1. Demote headings. 8 of 30 posts use `#` as a section heading (VTZero 4,
5 * 인지부조화이론 5, how-to-win-best-paper-ko 4, AIOdyssey 2, We_need_to_talk 2,
6 * journalctl 1, sysmon 1, tso 1), each of which would emit an <h1> competing with the
7 * article title. When a document contains any h1, every heading drops one level.
8 * This runs before Sätteri's heading-id plugin, so `headings` from render() reports the
9 * shifted depths and the table of contents stays truthful.
10 * Unless the document also contains an h6, which is where +1 stops being a shift and
11 * starts being a collision: h6 is the floor, so h5 would land on top of it and the
12 * document would lose a level it was using. --fs-h5 and --fs-h6 are different sizes, so
13 * that is visible, not just semantic. No corpus post has an h6 today, so this guard has
14 * never fired; it is here for the first post deep enough to reach h6, which is exactly
15 * the post the shift would damage.
16 * 2. Stamp intrinsic image size. Post and About images live in public/uploads and so
17 * bypass Astro's image service: no width/height, and every one shifts layout while
18 * loading. With the attributes present and no CSS `width`, an image narrower than the
19 * 928px column renders at its own size instead of being upscaled.
20 * 3. Wrap tables. 4 posts contain tables; each gets a focusable scroll container so a wide
21 * table never makes the page itself scroll sideways.
22 */
23 import { readFileSync } from 'node:fs';
24 import path from 'node:path';
25
26 /** PNG / WebP / JPEG intrinsic size straight from the file header — no dependency. */
27 function intrinsicSize(file) {
28 let b;
29 try { b = readFileSync(file); } catch { return null; }
30 if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47)
31 return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
32 if (b.length > 30 && b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') {
33 const tag = b.toString('latin1', 12, 16);
34 if (tag === 'VP8X') return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
35 if (tag === 'VP8 ') return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff };
36 if (tag === 'VP8L') { const n = b.readUInt32LE(21);
37 return { width: (n & 0x3fff) + 1, height: ((n >> 14) & 0x3fff) + 1 }; }
38 }
39 if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) {
40 let o = 2;
41 while (o + 9 < b.length) {
42 if (b[o] !== 0xff) { o++; continue; }
43 const m = b[o + 1];
44 if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc)
45 return { height: b.readUInt16BE(o + 5), width: b.readUInt16BE(o + 7) };
46 o += 2 + b.readUInt16BE(o + 2);
47 }
48 }
49 return null;
50 }
51
52 const sizes = new Map();
53
54 /**
55 * The two answers the heading demotion below needs — is there an h1 to get out of the
56 * title's way, and is there an h6 already at the floor that a shift would collide with —
57 * found by visiting only those two tags, which Sätteri filters natively.
58 *
59 * Not a `before` hook walking the whole tree in JavaScript: that materialises every node,
60 * every Shiki token span and every SVG element included. Measured, a 9 KB wide table took
61 * 13.9 s that way, an 18 KB one ran out of Map space after 83 s, and a 12 KB nested list
62 * overflowed the stack; either failure ships the post empty.
63 *
64 * It is a plugin of its own and must come BEFORE satteriProse in hastPlugins: one plugin's
65 * visitors all run before the next plugin starts, so the flags are set before the
66 * demotion reads them. Placed after, the demotion would silently never happen.
67 */
68 export function satteriHeadingScan() {
69 return {
70 name: 'heading-scan',
71 element: {
72 filter: ['h1', 'h6'],
73 visit(node, ctx) {
74 if (node.tagName === 'h1') ctx.data.proseHasH1 = true;
75 else ctx.data.proseHasH6 = true;
76 },
77 },
78 };
79 }
80
81 export default function satteriProse({ publicDir = 'public' } = {}) {
82 return {
83 name: 'prose',
84 element: [
85 {
86 filter: ['h1', 'h2', 'h3', 'h4', 'h5'],
87 visit(node, ctx) {
88 if (!ctx.data.proseHasH1 || ctx.data.proseHasH6) return;
89 return { ...node, tagName: `h${Number(node.tagName[1]) + 1}` };
90 },
91 },
92 {
93 filter: ['img'],
94 visit(node, ctx) {
95 const src = String(node.properties?.src ?? '');
96 if (src.startsWith('/') && node.properties?.width == null) {
97 const file = path.join(publicDir, decodeURIComponent(src));
98 if (!sizes.has(file)) sizes.set(file, intrinsicSize(file));
99 const s = sizes.get(file);
100 if (s) { ctx.setProperty(node, 'width', s.width); ctx.setProperty(node, 'height', s.height); }
101 }
102 if (node.properties?.loading == null) ctx.setProperty(node, 'loading', 'lazy');
103 if (node.properties?.decoding == null) ctx.setProperty(node, 'decoding', 'async');
104 },
105 },
106 {
107 filter: ['table'],
108 visit(node, ctx) {
109 const parent = ctx.parent(node);
110 if (parent?.type === 'element' && parent.tagName === 'div') return;
111 ctx.wrapNode(node, {
112 type: 'element', tagName: 'div',
113 // A group, as the code plates are: a region is a landmark, and the Hitchhiker post
114 // would list three landmarks all named "Table".
115 properties: { className: ['scroller'], tabindex: '0', role: 'group', 'aria-label': 'Table' },
116 children: [],
117 });
118 },
119 },
120 ],
121 };
122 }