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 });