src/lib/satteri-figure.mjs (4315B)
1 /**
2 * satteri-figure — promotes the corpus's own caption convention to real markup.
3 *
4 * The author already writes captions as an italic paragraph directly beneath a standalone
5 * image; 11 of the 24 image paragraphs in the corpus carry one (arp-spoofing 8, doh 2,
6 * bind9 1). Without this they render as ordinary italic paragraphs and every image ships
7 * as a bare <img> with no <figure>.
8 *
9 * It also repairs alt text: all 26 images in the posts have an alt that is empty or
10 * filename-shaped ("image", "images", "img_4305", "2026-08-13-165932", "1"), which a screen
11 * reader spells out letter by letter. Junk alt is cleared, and an image with a caption gets
12 * an empty alt, because the figcaption beside it is already read out.
13 *
14 * The repair is not limited to the images this plugin lifts. The figure path only takes a
15 * paragraph containing nothing BUT images, so an image with prose beside it, a linked
16 * image [](b), or an image in a list or a table cell never reaches it -- and those
17 * carry exactly the same filename alts the corpus produces everywhere else. None of the 26
18 * corpus images are in that position today, which is the only reason this has not been
19 * audible; the `image` visitor below makes the repair total rather than positional.
20 */
21
22 const JUNK_ALT =
23 /^(?:images?|imgs?|photos?|screenshots?|figures?)?[-_ ]?\d*$|^\d{4}-\d{2}-\d{2}[-_\d]*$|^[a-z]+_\d+$/i;
24
25 const meaningful = (kids = []) =>
26 kids.filter((k) => !(k.type === 'text' && k.value.trim() === ''));
27
28 /** The single meaningful child of a paragraph, or null. */
29 function sole(node) {
30 if (!node || node.type !== 'paragraph') return null;
31 const kids = meaningful(node.children);
32 return kids.length === 1 ? kids[0] : null;
33 }
34
35 /**
36 * Every image in a paragraph that holds nothing but images, or null.
37 * 인지부조화이론.md places three portrait scans on three consecutive lines, which
38 * CommonMark folds into one paragraph — they are a group, not three loose images.
39 */
40 function imageOnly(node) {
41 if (!node || node.type !== 'paragraph') return null;
42 const kids = meaningful(node.children);
43 return kids.length > 0 && kids.every((k) => k.type === 'image') ? kids : null;
44 }
45
46 function textOf(nodes) {
47 let out = '';
48 for (const n of nodes ?? []) out += n.value ?? textOf(n.children);
49 return out.trim();
50 }
51
52 export default function satteriFigure() {
53 return {
54 name: 'figure',
55 /*
56 * Runs after `before` has built the figures, so every image it sees is one the figure
57 * path did not claim -- plus the plates' own images, whose alt the figure path has
58 * already emptied or kept as the author's own words, so testing it again changes
59 * nothing. An alt is only ever cleared, never invented: a filename is worse than
60 * silence to a screen reader, and this plugin has no way to know what the picture
61 * shows.
62 */
63 image(node, ctx) {
64 const alt = node.alt ?? '';
65 if (alt !== '' && JUNK_ALT.test(alt)) ctx.setProperty(node, 'alt', '');
66 },
67 before(root, ctx) {
68 const kids = root.children ?? [];
69 for (let i = 0; i < kids.length; i++) {
70 const images = imageOnly(kids[i]);
71 if (!images) continue;
72
73 const next = sole(kids[i + 1]);
74 const caption = next && next.type === 'emphasis' ? next.children : null;
75
76 const plate = images.map((image, n) => {
77 const alt = JUNK_ALT.test(image.alt ?? '') ? '' : (image.alt ?? '');
78 // With a caption present the alt stays empty: the figcaption is real text in the
79 // DOM and is announced, so repeating it in alt only makes it read twice.
80 return { type: 'image', url: image.url, title: image.title ?? null, alt: caption ? '' : alt };
81 });
82
83 const body = [{
84 type: 'paragraph',
85 data: { hName: 'span', hProperties: { className: ['fig__plate'] } },
86 children: plate,
87 }];
88 if (caption) body.push({ type: 'paragraph', data: { hName: 'figcaption' }, children: [...caption] });
89
90 ctx.replaceNode(kids[i], {
91 type: 'figure',
92 data: { hName: 'figure', hProperties: { className: ['fig'], 'data-count': String(plate.length) } },
93 children: body,
94 });
95 if (caption) { ctx.removeNode(kids[i + 1]); i++; }
96 }
97 },
98 };
99 }