blog.git

blog

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

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 [![](a)](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 }