blog.git

blog

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

astro.config.mjs (15655B)

      1 // @ts-check
      2 import { createHash } from 'node:crypto';
      3 import { readdirSync, readFileSync } from 'node:fs';
      4 import { defineConfig } from 'astro/config';
      5 import sitemap from '@astrojs/sitemap';
      6 import { satteri } from '@astrojs/markdown-satteri';
      7 import satteriFigure from './src/lib/satteri-figure.mjs';
      8 import satteriGuard from './src/lib/satteri-guard.mjs';
      9 import satteriProse, { satteriHeadingScan } from './src/lib/satteri-prose.mjs';
     10 import satteriDiagram from './src/lib/satteri-diagram.mjs';
     11 import satteriKorean from './src/lib/satteri-korean.mjs';
     12 import satteriLang from './src/lib/satteri-lang.mjs';
     13 import { diagramCopy, diagramKey, findMermaidFences, readDiagram } from './src/lib/diagrams.mjs';
     14 
     15 /**
     16  * sha256 of the <style> inside every diagram copy a page will carry, for the
     17  * Content-Security-Policy below.
     18  *
     19  * Astro hashes the scripts and styles it emits itself, and once style-src carries a hash,
     20  * browsers ignore 'unsafe-inline' for <style> elements. The diagrams' own <style> arrives
     21  * inside Markdown HTML that Astro never looks at, so without these it would be blocked and
     22  * the diagram would lose its palette. A diagram drawn twice on one page is renamed for the
     23  * second copy, <style> included, so each copy is hashed as satteriDiagram will emit it:
     24  * through diagramCopy(), up to the most copies of that diagram any one document has. The
     25  * text is inlined verbatim with no entities, so it is hashed as it stands. prebuild bakes
     26  * any new diagram before this file is loaded, and check-dist fails the build if a page
     27  * still carries a <style> or <script> its policy does not list.
     28  */
     29 function diagramCopies(dir, copies = new Map()) {
     30   for (const entry of readdirSync(dir, { withFileTypes: true })) {
     31     const file = `${dir}/${entry.name}`;
     32     if (entry.isDirectory()) diagramCopies(file, copies);
     33     else if (entry.name.endsWith('.md')) {
     34       const onPage = new Map();
     35       for (const { source } of findMermaidFences(readFileSync(file, 'utf8'))) {
     36         const key = diagramKey(source);
     37         onPage.set(key, (onPage.get(key) ?? 0) + 1);
     38       }
     39       for (const [key, n] of onPage) copies.set(key, Math.max(copies.get(key) ?? 0, n));
     40     }
     41   }
     42   return copies;
     43 }
     44 const DIAGRAM_STYLE_HASHES = [...diagramCopies('content')].flatMap(([key, n]) => {
     45   const svg = readDiagram(key);
     46   if (!svg) return [];
     47   return Array.from({ length: n }, (_, i) => diagramCopy(svg, key, i))
     48     .flatMap((markup) => [...markup.matchAll(/<style>([\s\S]*?)<\/style>/g)])
     49     .map(([, css]) => `sha256-${createHash('sha256').update(css).digest('base64')}`);
     50 });
     51 
     52 /**
     53  * Grammars preloaded into the Shiki highlighter: the fence languages the corpus uses, plus
     54  * json beside jsonc.
     55  *
     56  * A warm-up list, NOT a gate — measured rather than assumed. A fence in a language outside
     57  * it still highlights (```rust emits data-language="rust" with real tokens), and a fence in
     58  * a language that exists nowhere logs `[Shiki] The language "notalanguage" doesn't exist,
     59  * falling back to "plaintext"`, emits data-language="plaintext" and leaves the build green.
     60  * Nothing in this pipeline fails the build over a fence language. What the list buys is
     61  * that the languages the corpus does use are resolved once at startup instead of on first
     62  * sight.
     63  * Aliases already ship in the Shiki bundle: bash/sh/zsh resolve to shellscript,
     64  * console resolves to shellsession.
     65  *
     66  * `mermaid` is deliberately absent. It is the one entry left in excludeLangs below, so the
     67  * highlight plugin returns before Shiki is called: the grammar would be loaded and never
     68  * used, because a mermaid fence becomes a picture rather than highlighted source.
     69  */
     70 const LANGS = [
     71   'plaintext', 'bash', 'shellscript', 'shellsession',
     72   'ini', 'jsonc', 'json', 'xml', 'python',
     73 ];
     74 
     75 /**
     76  * Fence words whose grammar is not what the word says. `langAlias` below hands the
     77  * grammar to Shiki, but Shiki writes `dataLanguage` from the *unresolved* word, so
     78  * without this same map the wrapper labels a journald.conf block "vi" — a label that
     79  * is both visible in the figcaption and the hook every per-language CSS rule matches on.
     80  * One map, consumed in both places, is the only way those two stay in agreement.
     81  */
     82 const ALIAS = { vi: 'ini' };
     83 
     84 /**
     85  * Lines of 2,000 characters or more are not tokenised. Shiki checks its 500 ms time limit
     86  * between scans, and a single scan over a long bash line is already superlinear, so the
     87  * limit does not bound it: measured, a 20 KB printf line took 3.0 s and a 50 KB one 19.8 s,
     88  * and a few KB of `<<` threw RangeError, which emptied the post. Such a line renders as one
     89  * uncoloured token. The corpus's longest fence line is 1,639 characters.
     90  */
     91 const lineCap = {
     92   name: 'sqzer:line-cap',
     93   preprocess(code, options) {
     94     options.tokenizeMaxLineLength = 2000;
     95   },
     96 };
     97 
     98 /**
     99  * Code block wrapper. Wraps `<pre>` in `<figure class="code">` and labels the language.
    100  *
    101  * Do not match on the class: it catches nothing. Astro's built-in transformer runs
    102  * before user transformers and rewrites `shiki` to `astro-code` in `class`
    103  * (the pre hook in @astrojs/internal-helpers/dist/shiki.js). The only stable contract
    104  * is `dataLanguage`, which that same hook sets directly.
    105  *
    106  * The root hook must leave **exactly one** child. Astro reads only the first child of
    107  * root, via `codeToHast(...).then((root) => root.children[0])`
    108  * (@astrojs/markdown-satteri/dist/satteri-processor.js).
    109  */
    110 const codeBlock = {
    111   name: 'sqzer:code-block',
    112   root(root) {
    113     const pre = root.children[0];
    114     if (!pre || pre.type !== 'element' || pre.tagName !== 'pre') return;
    115 
    116     const raw = String(pre.properties?.dataLanguage ?? 'plaintext');
    117     const lang = ALIAS[raw] ?? raw;
    118     // Re-stamp the pre so the attribute a reader is shown and the attribute CSS
    119     // matches on are the resolved grammar, not the author's word for it.
    120     pre.properties.dataLanguage = lang;
    121     // ```bash wrap — opt in per block, for things like log dumps whose lines cannot be kept.
    122     const wrap = /(^|\s)wrap(\s|$)/.test(String(this.options?.meta?.__raw ?? ''));
    123 
    124     // Shiki's tabindex="0" alone leaves a screen reader announcing this scroll region unnamed.
    125     pre.properties.role = 'group';
    126     pre.properties['aria-label'] = lang === 'plaintext' ? 'Code' : `${lang} code`;
    127 
    128     const children = [pre];
    129     // A fence with no language arrives as `plaintext`, which names nothing, so it gets no label.
    130     if (lang !== 'plaintext') {
    131       children.unshift({
    132         type: 'element',
    133         tagName: 'figcaption',
    134         properties: { className: ['code__lang'], 'aria-hidden': 'true' },
    135         children: [{ type: 'text', value: lang }],
    136       });
    137     }
    138 
    139     root.children = [{
    140       type: 'element',
    141       tagName: 'figure',
    142       properties: { className: ['code'], dataLanguage: lang, ...(wrap ? { dataWrap: '' } : {}) },
    143       children,
    144     }];
    145   },
    146 };
    147 
    148 export default defineConfig({
    149   site: 'https://sqzer.com',
    150   /* The Hugo site this replaced shipped a sitemap, and robots.txt (src/pages/robots.txt.ts)
    151      names one, so it has to exist: a robots line pointing at a 404 is a dead link handed
    152      to every crawler that reads the file. */
    153   integrations: [sitemap()],
    154   output: 'static',
    155   // Every canonical URL ends in a trailing slash; the directory format emits dist/<path>/index.html.
    156   trailingSlash: 'always',
    157   build: { format: 'directory' },
    158   /*
    159    * Content-Security-Policy, emitted by Astro as a <meta> in every page's <head>, since
    160    * GitHub Pages cannot send response headers. Astro computes the hashes of the scripts it
    161    * inlines, so editing the article's two scripts needs no change here.
    162    *
    163    * Nothing on this site loads from another origin, so everything is 'self' or 'none'.
    164    * Shiki colours every token with a style="" attribute, so 'unsafe-inline' is granted to
    165    * style-src-attr and nowhere else; a style attribute cannot run script. That grant is
    166    * the answer to the "Shiki ... not compatible with Content Security Policy" warning
    167    * Astro prints on every build. What a <meta> cannot carry: frame-ancestors, report-uri
    168    * and sandbox are ignored there.
    169    *
    170    * Two things in the emitted policy are Astro's and not the site's. script-src always lists
    171    * five hashes for Astro's client-directive runtimes (idle, load, media, only, visible;
    172    * astro/dist/runtime/client/*.prebuilt.js), added even on a site with no islands, and
    173    * style-src lists sha256 of the empty string. Neither matches anything a page carries.
    174    * Astro writes the <meta> where it renders the head, after the preload and icon links and
    175    * before every stylesheet and script; check-dist holds it to that.
    176    */
    177   security: {
    178     csp: {
    179       directives: [
    180         "default-src 'none'",
    181         "img-src 'self'",
    182         "font-src 'self'",
    183         "base-uri 'none'",
    184         "form-action 'none'",
    185       ],
    186       styleDirective: {
    187         resources: ["'self'", { resource: "'unsafe-inline'", kind: 'attribute' }],
    188         hashes: DIAGRAM_STYLE_HASHES,
    189       },
    190     },
    191   },
    192   markdown: {
    193     // Sätteri is the default Markdown processor in Astro 7, and `markdown.rehypePlugins`
    194     // now throws unless @astrojs/markdown-remark is installed alongside it. Plugins go
    195     // here instead, as Sätteri visitor objects.
    196     //   mdast: satteriKorean  — CommonMark inline rules that misfire on Korean prose
    197     //   mdast: satteriFigure  — <figure>/<figcaption> out of the corpus's own convention
    198     //   hast:  satteriGuard   — refuses raw HTML, heading attributes, link schemes and
    199     //                           image hosts a post must not carry; the build fails
    200     //   hast:  satteriHeadingScan — whether the post has an h1 or an h6; must precede satteriProse
    201     //   hast:  satteriProse   — heading demotion, intrinsic image size, table scrollers
    202     //   hast:  satteriDiagram — a mermaid fence -> the SVG baked from it before the build
    203     //   hast:  satteriLang    — lang on sections and headings not in the post's language
    204     // Ordering matters: hastPlugins run after highlighting and BEFORE heading-id
    205     // collection, so `headings` from render() reports the demoted depths. satteriGuard
    206     // goes first, before satteriDiagram adds the one raw node the site trusts.
    207     processor: satteri({
    208       mdastPlugins: [satteriKorean(), satteriFigure()],
    209       hastPlugins: [satteriGuard(), satteriHeadingScan(), satteriProse({ publicDir: 'public' }), satteriDiagram(), satteriLang()],
    210       /*
    211        * Parser flags. Astro hands Sätteri only `{ gfm, smartPunctuation }`. Every other
    212        * entry in `Features` defaults off except `frontmatter`, which Sätteri turns on unless
    213        * told otherwise (`features.frontmatter ?? true` in satteri/dist/compile.js). What is
    214        * listed here is the whole of the difference from stock, and each entry says what it
    215        * fixes or makes possible.
    216        */
    217       features: {
    218         /*
    219          * Astro has already split the front matter off: the loader hands over the body,
    220          * trimmed (astro/dist/vite-plugin-markdown/content-entry-type.js). Left on, Sätteri
    221          * reads that body's first line as a second front-matter fence, so a post opening
    222          * with a `---` rule loses everything up to the next `---`, `...` or `+++` pair. It
    223          * drops without a log, and the page still has a body for the render guard to find.
    224          * Nothing reads what Sätteri would parse there: Astro's metadata.frontmatter is the
    225          * loader's own copy.
    226          */
    227         frontmatter: false,
    228         /*
    229          * Smart punctuation, minus the dash rule. Quotes and ellipses are worth having in
    230          * prose; the dash rule is not, because it does not know what a CLI flag is. With it
    231          * on, journalctl's `#### 시간 범위 지정 (--since, --until)` renders as
    232          * `(–since, –until)`, an en dash a reader cannot paste into a shell. Only prose
    233          * outside backticks is affected — code spans and fences are untouched — but on a
    234          * sysadmin blog a flag named in a heading is normal writing, not markup.
    235          */
    236         smartPunctuation: { dashes: false },
    237         /*
    238          * `## Heading { #id }` sets the anchor. Without it the braces render as literal
    239          * text in the heading AND in the contents list, and the generated slug swallows
    240          * them (`…-custom-id-`). The contents list and rail link to headings by anchor, so
    241          * an author needs a way to keep an anchor stable across a retitling. No corpus
    242          * heading contains a brace, so nothing existing changes shape.
    243          */
    244         headingAttributes: true,
    245         /*
    246          * Not enabled, and each is a decision rather than an oversight:
    247          *   math          no post contains a formula, and `singleDollarTextMath` is on by
    248          *                 default — turning math on would read a `$…$` pair in prose, two
    249          *                 shell variables or two prices, as one.
    250          *   subscript     `~sub~` collides head-on with GFM strikethrough, which is the
    251          *                 very collision satteri-korean exists to undo.
    252          *   superscript   `^x^` is unused, and ^ appears in regexes and shell prose.
    253          *   directive     `:::note` blocks need styling per directive to mean anything.
    254          *   wikilinks     no wiki.
    255          *   definitionList  0 in the corpus; a table or a list carries the same content.
    256          *   rawHtml       off, raw HTML stays an opaque `raw` node; on, it is re-parsed into
    257          *                 elements. Either way it is emitted verbatim: this flag sanitises
    258          *                 nothing. What limits raw HTML is satteriGuard.
    259          */
    260       },
    261     }),
    262     /*
    263      * `mermaid` is the only exclusion this file asks for: the fence has to survive
    264      * highlighting intact so satteriDiagram can swap in the SVG that
    265      * scripts/render-diagrams.mjs baked from it.
    266      *
    267      * `math` is not listed because naming it does nothing. Astro ORs its own
    268      * `defaultExcludeLanguages = ["math"]` (@astrojs/internal-helpers/dist/markdown.js) into
    269      * this check unconditionally (the highlight plugin in
    270      * @astrojs/markdown-satteri/dist/satteri-processor.js), so math is excluded whether or
    271      * not it is named, and no value of this option — and no langAlias — can un-exclude it.
    272      *
    273      * What that costs, measured: an excluded language skips the highlight plugin, so a
    274      * ```math fence is the one construct in the language that arrives as a bare
    275      * `<pre><code>` with no plate, no scroll region and no copy control. Everything else
    276      * lands on the plate, including the two cases that look like they might not — an
    277      * indented code block and a fence with no language both come through as
    278      * figure.code[data-language=plaintext]. The corpus has 0 math fences and math parsing
    279      * is off, so this is left alone rather than fixed with a plugin;
    280      * `.prose pre:not(.code *)` in the article template keeps the bare case from scrolling
    281      * the page sideways.
    282      */
    283     syntaxHighlight: { type: 'shiki', excludeLangs: ['mermaid'] },
    284     shikiConfig: {
    285       // The css-variables theme emits token colors as `var(--astro-code-token-*)`, so the
    286       // OKLCH ladder in tokens.css drives syntax highlighting instead of a second palette.
    287       theme: 'css-variables',
    288       langs: LANGS,
    289       // ```vi in the corpus is journald.conf, not vim script. That is INI.
    290       langAlias: ALIAS,
    291       // Preserve lines by default. Folding is opt-in per block via ```<lang> wrap.
    292       wrap: false,
    293       transformers: [lineCap, codeBlock],
    294     },
    295   },
    296 });