blog.git

blog

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

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 }