From 5936b2f7774d412cbb1d1d7ef49b25ca04228b48 Mon Sep 17 00:00:00 2001 From: Claudio Wunder Date: Sat, 10 Oct 2026 13:25:05 +0200 Subject: [PATCH 01/14] perf(core): load Shiki grammars on demand The Shiki plugin registered every bundled language (~250 grammars) in each thread that highlighted code, which cost ~2s and ~100MB per thread and made every highlight several times slower, as each code block was matched against grammars it never uses. Importing it also imported all of them, through `@node-core/rehype-shiki`'s `LANGS` and its plugin, on every thread loading `jsx-ast`, the main thread included. The highlighter now registers a bundled language the first time code in it is highlighted, along with the bundled languages a configured one embeds, and lists the bundled ones from their metadata alone, without importing `LANGS`. The themes are given to Shiki by name, which it keeps parsed instead of parsing them for every highlight, named after the configured themes, as Shiki loads a theme given for both schemes once. A bundled language is registered with the bundled languages it embeds, lazily too (a fenced code block in Markdown): Shiki only highlights those once they are registered, so registering them later would make code highlight differently depending on what was highlighted before it. For the same reason, a bundled grammar injecting into others (`ts-tags`, into JavaScript's tagged templates) only injects into the language it was imported for. `@node-core/rehype-shiki` is updated to 1.5.1, whose plugin only imports every grammar when it isn't given a highlighter (nodejs/nodejs.org#9212). The grammars now come from doc-kit's own `shiki` dependency (4.4.3), which 1.5.1 shares, rather than the copy 1.5.0 pinned (4.3.1). Its C++ grammar highlights types and template arguments differently, which shows on the Node.js docs' C++ examples. Assisted-by: Claude Opus 5.5 Co-authored-by: Aviv Keller --- docs/creating-generators.md | 4 +- packages/core/package.json | 2 +- .../shiki/__tests__/highlighter.test.mjs | 67 ++++++ .../core/src/plugins/shiki/highlighter.mjs | 207 +++++++++++++++--- .../plugins/type-annotations/highlighter.mjs | 4 +- .../src/legacy-html/plugins/shiki.mjs | 2 +- packages/react/package.json | 2 +- .../src/html/utils/__tests__/config.test.mjs | 20 +- pnpm-lock.yaml | 97 +------- 9 files changed, 275 insertions(+), 130 deletions(-) diff --git a/docs/creating-generators.md b/docs/creating-generators.md index e8158558..c2912d33 100644 --- a/docs/creating-generators.md +++ b/docs/creating-generators.md @@ -524,7 +524,9 @@ A plugin needing asynchronous setup can export an async `load(options)` returning the plugin, instead of a default export. The Shiki plugin does, and `getHighlighter(name)` (from `@doc-kit/core/plugins/shiki/highlighter.mjs`) returns the highlighter of a generator's Shiki plugin, to highlight code as its -pipeline does. +pipeline does. It registers Shiki's bundled languages as code in them is +highlighted, so before using its `shiki` instance directly, pass the language +through its `resolveLanguage`. ## File Output diff --git a/packages/core/package.json b/packages/core/package.json index 2f203560..6516f69e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,7 +53,7 @@ ], "dependencies": { "@actions/core": "^3.0.0", - "@node-core/rehype-shiki": "^1.5.0", + "@node-core/rehype-shiki": "^1.5.1", "@swc/html-wasm": "^1.16.2", "@swc/wasm": "^1.15.46", "acorn": "^8.17.0", diff --git a/packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs b/packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs index 91208b8e..030f5d10 100644 --- a/packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs +++ b/packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs @@ -86,6 +86,73 @@ describe('createHighlighter', () => { ); }); + it('registers a bundled language once code in it is highlighted', async () => { + const highlighter = await createHighlighter({ + langAlias: { py: 'python' }, + }); + const loaded = () => highlighter.shiki.getLoadedLanguages(); + + assert.ok(!loaded().includes('python')); + assert.ok(!loaded().includes('javascript')); + + // By its name, an alias of its own, or one of the options + assert.equal(highlighter.resolveLanguage('py'), 'py'); + assert.equal(highlighter.resolveLanguage('mjs'), 'mjs'); + + assert.ok(loaded().includes('python')); + assert.ok(loaded().includes('javascript')); + assert.ok(loaded().includes('cjs')); + + assert.equal(highlighter.resolveLanguage(undefined), 'text'); + assert.equal(highlighter.resolveLanguage('plaintext'), 'plaintext'); + }); + + it('lists the bundled languages without registering them', async () => { + const highlighter = await createHighlighter({ langs: [grammar] }); + + assert.deepStrictEqual( + highlighter.langs.find(({ name }) => name === 'rust'), + { name: 'rust', displayName: 'Rust', aliases: ['rs'] } + ); + assert.equal(highlighter.langs.at(-1), grammar); + assert.ok(!highlighter.shiki.getLoadedLanguages().includes('rust')); + }); + + it('registers the bundled languages a language embeds', async () => { + const highlighter = await createHighlighter({ + langs: [ + { + ...grammar, + name: 'oxcscript', + scopeName: 'source.oxcscript', + embeddedLangs: ['javascript'], + patterns: [{ include: 'source.js' }], + }, + ], + }); + + assert.match( + highlighter.highlightToHtml('const on = 1', 'oxcscript'), + /--shiki-dark/ + ); + }); + + it('registers the bundled languages a language embeds lazily', async () => { + const highlighter = await createHighlighter({ + langs: [ + { + ...grammar, + name: 'oxcdoc', + scopeName: 'source.oxcdoc', + embeddedLangsLazy: ['rust'], + patterns: [{ include: 'source.rust' }], + }, + ], + }); + + assert.ok(highlighter.shiki.getLoadedLanguages().includes('rust')); + }); + it('gives the same highlighter for the same options', async () => { const highlighter = await createHighlighter({ langs: [grammar] }); diff --git a/packages/core/src/plugins/shiki/highlighter.mjs b/packages/core/src/plugins/shiki/highlighter.mjs index 6ecdef8d..06dc0a58 100644 --- a/packages/core/src/plugins/shiki/highlighter.mjs +++ b/packages/core/src/plugins/shiki/highlighter.mjs @@ -1,13 +1,24 @@ 'use strict'; +import { createRequire } from 'node:module'; import { endianness } from 'node:os'; -import { LANGS } from '@node-core/rehype-shiki'; import createSyntaxHighlighter from '@node-core/rehype-shiki/highlighter'; +import { isSpecialLang } from 'shiki/core'; +import { bundledLanguagesInfo } from 'shiki/langs'; import { bundledThemes } from 'shiki/themes'; import { importFromURL } from '#utils/loaders.mjs'; import { getMarkdownPlugins } from '#utils/markdown/plugins.mjs'; +import { lazy } from '#utils/misc.mjs'; + +const require = createRequire(import.meta.url); + +/** + * A language a highlighter highlights. + * + * @typedef {Pick} Language + */ /** * A syntax highlighter, creating its Shiki instance on first use. @@ -17,7 +28,7 @@ import { getMarkdownPlugins } from '#utils/markdown/plugins.mjs'; * @property {(languageId?: string) => string} resolveLanguage - Resolves a language, falling back to plain text for unknown ones * @property {(code: string, lang: string, meta?: Record) => string} highlightToHtml - Highlights code, returning the inner HTML of its `` element * @property {(code: string, lang: string, meta?: Record) => ReturnType} highlightToHast - Highlights code, returning a HAST tree - * @property {Array} langs - The languages it highlights + * @property {Array} langs - The languages it highlights */ /** @@ -53,6 +64,94 @@ let engine; // The highlighters of the options given, by their JSON const highlighters = new Map(); +// What code in an unknown language is highlighted as +const FALLBACK_LANGUAGE = 'text'; + +// The languages Shiki bundles. Registering a grammar takes time and memory, +// and slows down every highlight after it, so each one is only imported and +// registered once code in it is highlighted (see `resolveLanguage`). +const BUNDLED_LANGUAGES = bundledLanguagesInfo.map(({ id, name, aliases }) => ({ + name: id, + displayName: name, + aliases, +})); + +// The bundled language each of their names and aliases stands for, as code +// names its language by either +const BUNDLED_NAMES = new Map( + BUNDLED_LANGUAGES.flatMap(({ name, aliases = [] }) => + [name, ...aliases].map(alias => [alias, name]) + ) +); + +/** + * Imports a bundled language: its grammar, and those of the languages it + * embeds, lazily too. Shiki only highlights a language embedded lazily (a + * fenced code block in Markdown) once it is registered, so registering it + * later would make code highlight differently depending on what was + * highlighted before it. + * + * For the same reason, a grammar injecting into others' (`ts-tags`', into + * JavaScript's tagged templates) only injects into the language it was + * imported for: Shiki only applies an injection to the grammars it creates + * after it. A language given in `langs` keeps its injections. + * + * @param {string} name - A name or alias of the language + * @returns {Array} + */ +const importBundledLanguage = name => { + const id = BUNDLED_NAMES.get(name); + const langs = new Map(); + const pending = [id]; + + while (pending.length > 0) { + const grammars = require(`shiki/langs/${pending.pop()}.mjs`).default; + + for (const lang of grammars) { + if (langs.has(lang.name)) { + continue; + } + + langs.set(lang.name, lang); + + const { embeddedLangs = [], embeddedLangsLazy = [] } = lang; + + for (const embedded of [...embeddedLangs, ...embeddedLangsLazy]) { + if (BUNDLED_NAMES.has(embedded)) { + pending.push(BUNDLED_NAMES.get(embedded)); + } + } + } + } + + const { scopeName } = langs.get(id); + + return [...langs.values()].map(lang => + lang.injectTo ? { ...lang, injectTo: [scopeName] } : lang + ); +}; + +/** + * Adds the bundled languages that languages embed, which Shiki registers + * along with them. + * + * @param {Array} langs - The languages + * @returns {Array} + */ +const withEmbeddedLanguages = langs => { + const names = new Set(langs.map(({ name }) => name)); + + const embedded = langs + .flatMap(({ embeddedLangs = [], embeddedLangsLazy = [] }) => [ + ...embeddedLangs, + ...embeddedLangsLazy, + ]) + .filter(name => !names.has(name) && BUNDLED_NAMES.has(name)) + .flatMap(importBundledLanguage); + + return [...embedded, ...langs]; +}; + /** * Imports a list of options, each given as it is, or as a module (a path * relative to the working directory, or a URL) default-exporting it, or a list @@ -119,13 +218,12 @@ const importHighlighter = async ({ const coreOptions = { engine: regexEngine, - langs: [...LANGS, ...importedLangs], - // A copy, as Shiki adds the aliases of the languages it bundles to it + // The bundled languages are registered as code uses them + langs: withEmbeddedLanguages(importedLangs), + // A copy, as Shiki adds the aliases of the languages it registers to it langAlias: { ...langAlias }, }; - const highlighterOptions = { transformers: importedTransformers }; - // Without themes of its own, the highlighter has a default light and dark one if (themes) { const [light, dark] = await Promise.all([ @@ -134,58 +232,111 @@ const importHighlighter = async ({ ]); coreOptions.themes = [light, dark]; - highlighterOptions.themes = { light: light.name, dark: dark.name }; - highlighterOptions.defaultColor = 'light'; } - let highlighter; + /** + * Creates the Shiki instance, on first use, and the options it highlights + * with. + */ + const load = lazy(() => { + const { shiki } = createSyntaxHighlighter({ coreOptions }); + + // The themes are given by name: Shiki keeps the themes it loaded parsed, + // but parses a theme object it's given again for every highlight + const [light, dark] = + coreOptions.themes?.map(theme => theme.name) ?? shiki.getLoadedThemes(); + + const highlightOptions = { + themes: { light, dark }, + defaultColor: 'light', + transformers: importedTransformers, + }; + + return { shiki, highlightOptions }; + }); + + /** + * Resolves a language, falling back to plain text for unknown ones. A + * bundled language is registered the first time it's resolved. + * + * @param {string} [languageId] + * @returns {string} + */ + const resolveLanguage = languageId => { + if (!languageId) { + return FALLBACK_LANGUAGE; + } + + const instance = load().shiki; + const name = instance.resolveLangAlias(languageId.toLowerCase()); + + if (isSpecialLang(name) || instance.getLoadedLanguages().includes(name)) { + return languageId; + } + + if (!BUNDLED_NAMES.has(name)) { + return FALLBACK_LANGUAGE; + } + + instance.loadLanguageSync(importBundledLanguage(name)); + + return languageId; + }; /** - * Gives the Shiki highlighter, creating it on first use. + * The options Shiki highlights code with. + * + * @param {string} lang - The language of the code + * @param {Record} meta - Its metadata */ - const current = () => - (highlighter ??= createSyntaxHighlighter({ - coreOptions, - highlighterOptions, - })); + const optionsFor = (lang, meta) => ({ + lang: resolveLanguage(lang), + ...load().highlightOptions, + meta, + }); return { - langs: coreOptions.langs, + langs: [...BUNDLED_LANGUAGES, ...importedLangs], /** * The Shiki instance. */ get shiki() { - return current().shiki; + return load().shiki; }, - /** - * Resolves a language, falling back to plain text for unknown ones. - * - * @param {string} [languageId] - */ - resolveLanguage: languageId => current().resolveLanguage(languageId), + resolveLanguage, /** * Highlights code, returning the inner HTML of its `` element. * - * @param {...any} args - The code, its language, and its metadata + * @param {string} code - The code + * @param {string} lang - Its language + * @param {Record} [meta] - Its metadata */ - highlightToHtml: (...args) => current().highlightToHtml(...args), + highlightToHtml: (code, lang, meta = {}) => + load() + .shiki.codeToHtml(code, optionsFor(lang, meta)) + // Shiki wraps the highlighted code in a
 and a 
+        .match(/(.+?)<\/code>/s)[1],
 
     /**
      * Highlights code, returning a HAST tree.
      *
-     * @param {...any} args - The code, its language, and its metadata
+     * @param {string} code - The code
+     * @param {string} lang - Its language
+     * @param {Record} [meta] - Its metadata
      */
-    highlightToHast: (...args) => current().highlightToHast(...args),
+    highlightToHast: (code, lang, meta = {}) =>
+      load().shiki.codeToHast(code, optionsFor(lang, meta)),
   };
 };
 
 /**
  * Creates a highlighter of every language Shiki bundles, with the given
  * options (see `./rehype.mjs`). Its Shiki instance is created on first use,
- * and the same options give the same highlighter.
+ * each bundled language is registered once code in it is highlighted, and the
+ * same options give the same highlighter.
  *
  * @param {import('./rehype.mjs').ShikiOptions} [options] - The options
  * @returns {Promise}
diff --git a/packages/core/src/plugins/type-annotations/highlighter.mjs b/packages/core/src/plugins/type-annotations/highlighter.mjs
index f26f9b25..9bdd2931 100644
--- a/packages/core/src/plugins/type-annotations/highlighter.mjs
+++ b/packages/core/src/plugins/type-annotations/highlighter.mjs
@@ -23,11 +23,11 @@ export const createTypeAnnotationHandler = getHighlighter => (state, node) => {
     return typeAnnotationToHast(state, node);
   }
 
-  const { shiki } = getHighlighter();
+  const { shiki, resolveLanguage } = getHighlighter();
   const [lightTheme, darkTheme] = shiki.getLoadedThemes();
 
   const root = shiki.codeToHast(node.value, {
-    lang: node.data?.typescript ? 'typescript' : 'text',
+    lang: resolveLanguage(node.data?.typescript ? 'typescript' : 'text'),
     themes: { light: lightTheme, dark: darkTheme },
     decorations: links.map(({ start, end, href }) => ({
       start,
diff --git a/packages/node-legacy/src/legacy-html/plugins/shiki.mjs b/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
index 188915fd..39b628f7 100644
--- a/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
+++ b/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
@@ -90,7 +90,7 @@ export default function rehypeShikiji() {
       const { children } = highlighter.shiki.codeToHast(
         preElement.children[0].value,
         {
-          lang: languageId,
+          lang: highlighter.resolveLanguage(languageId),
           // Allows support for dual themes (light, dark) for Shiki
           themes: { light: shikiConfig.themes[0], dark: shikiConfig.themes[1] },
         }
diff --git a/packages/react/package.json b/packages/react/package.json
index e9199f72..0bf8f002 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -35,7 +35,7 @@
     "@fontsource-variable/open-sans": "^5.3.0",
     "@fontsource/ibm-plex-mono": "^5.3.0",
     "@heroicons/react": "^2.2.0",
-    "@node-core/rehype-shiki": "^1.5.0",
+    "@node-core/rehype-shiki": "^1.5.1",
     "@node-core/ui-components": "^1.8.0",
     "@orama/orama": "^3.1.18",
     "@orama/ui": "^1.5.4",
diff --git a/packages/react/src/html/utils/__tests__/config.test.mjs b/packages/react/src/html/utils/__tests__/config.test.mjs
index 94e2e0c3..a4515e63 100644
--- a/packages/react/src/html/utils/__tests__/config.test.mjs
+++ b/packages/react/src/html/utils/__tests__/config.test.mjs
@@ -1,18 +1,24 @@
 import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
 import { describe, it, mock } from 'node:test';
+import { fileURLToPath } from 'node:url';
 
 import { setConfig } from '@doc-kit/core/utils/configuration/index.mjs';
 import { loadMarkdownPlugins } from '@doc-kit/core/utils/markdown/plugins.mjs';
 import { SemVer } from 'semver';
 
-mock.module('@node-core/rehype-shiki', {
+// The languages Shiki bundles, as `@doc-kit/core` imports them
+const core = createRequire(
+  fileURLToPath(import.meta.resolve('@doc-kit/core/package.json'))
+);
+
+mock.module(core.resolve('shiki/langs'), {
   exports: {
-    LANGS: [
-      { name: 'javascript', aliases: ['js'], displayName: 'JavaScript' },
-      { name: 'typescript', aliases: ['ts'], displayName: 'TypeScript' },
-      { name: 'python', displayName: 'Python' },
+    bundledLanguagesInfo: [
+      { id: 'javascript', name: 'JavaScript', aliases: ['js'] },
+      { id: 'typescript', name: 'TypeScript', aliases: ['ts'] },
+      { id: 'python', name: 'Python' },
     ],
-    default: async () => ({}),
   },
 });
 
@@ -44,7 +50,7 @@ const config = await setConfig({
 });
 
 // Loading the real `html` generator would pull in the full rendering stack
-// (which the `rehype-shiki` mock above cannot satisfy), so its resolved
+// (which the `shiki/langs` mock above cannot satisfy), so its resolved
 // configuration is stubbed in directly.
 config.html = {
   ...config.global,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e4274c3d..79b835c8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -84,8 +84,8 @@ importers:
         specifier: workspace:>=1.0.0
         version: link:../node-legacy
       '@node-core/rehype-shiki':
-        specifier: ^1.5.0
-        version: 1.5.0(supports-color@10.2.2)
+        specifier: ^1.5.1
+        version: 1.5.1(supports-color@10.2.2)
       '@swc/html-wasm':
         specifier: ^1.16.2
         version: 1.16.2
@@ -242,8 +242,8 @@ importers:
         specifier: ^2.2.0
         version: 2.2.0(react@19.3.0)
       '@node-core/rehype-shiki':
-        specifier: ^1.5.0
-        version: 1.5.0(supports-color@10.2.2)
+        specifier: ^1.5.1
+        version: 1.5.1(supports-color@10.2.2)
       '@node-core/ui-components':
         specifier: ^1.8.0
         version: 1.8.0(@orama/core@1.2.19)(@types/react@19.2.18)(react-dom@19.2.8(react@19.3.0))(supports-color@10.2.2)
@@ -1090,8 +1090,8 @@ packages:
     resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
     engines: {node: ^14.21.3 || >=16}
 
-  '@node-core/rehype-shiki@1.5.0':
-    resolution: {integrity: sha512-G/NtRi2NPveDc6fXgNvSbqq92lL4GKZmilmwouQL+fIHcOzuicwJQPP1tedYmUnhexO7PykYA0ckxHD3BOl5IQ==}
+  '@node-core/rehype-shiki@1.5.1':
+    resolution: {integrity: sha512-yEEVQBPcVDeQ+n1hLPscX8MXA0Q/suww9zOqnH4j5lX+4xPlrTB5grZOm4jFWPj2Vr6AGuudgc600+UdFN25rA==}
     engines: {node: '>=20'}
 
   '@node-core/ui-components@1.8.0':
@@ -1869,18 +1869,10 @@ packages:
   '@rolldown/pluginutils@1.0.1':
     resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
 
-  '@shikijs/core@4.3.1':
-    resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
-    engines: {node: '>=20'}
-
   '@shikijs/core@4.4.3':
     resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==}
     engines: {node: '>=20'}
 
-  '@shikijs/engine-javascript@4.3.1':
-    resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
-    engines: {node: '>=20'}
-
   '@shikijs/engine-javascript@4.4.3':
     resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==}
     engines: {node: '>=20'}
@@ -1888,10 +1880,6 @@ packages:
   '@shikijs/engine-oniguruma@3.23.0':
     resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==}
 
-  '@shikijs/engine-oniguruma@4.3.1':
-    resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
-    engines: {node: '>=20'}
-
   '@shikijs/engine-oniguruma@4.4.3':
     resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==}
     engines: {node: '>=20'}
@@ -1899,18 +1887,10 @@ packages:
   '@shikijs/langs@3.23.0':
     resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
 
-  '@shikijs/langs@4.3.1':
-    resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
-    engines: {node: '>=20'}
-
   '@shikijs/langs@4.4.3':
     resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==}
     engines: {node: '>=20'}
 
-  '@shikijs/primitive@4.3.1':
-    resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
-    engines: {node: '>=20'}
-
   '@shikijs/primitive@4.4.3':
     resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==}
     engines: {node: '>=20'}
@@ -1918,10 +1898,6 @@ packages:
   '@shikijs/themes@3.23.0':
     resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
 
-  '@shikijs/themes@4.3.1':
-    resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
-    engines: {node: '>=20'}
-
   '@shikijs/themes@4.4.3':
     resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==}
     engines: {node: '>=20'}
@@ -1935,10 +1911,6 @@ packages:
   '@shikijs/types@3.23.0':
     resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
 
-  '@shikijs/types@4.3.1':
-    resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
-    engines: {node: '>=20'}
-
   '@shikijs/types@4.4.3':
     resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==}
     engines: {node: '>=20'}
@@ -3207,10 +3179,6 @@ packages:
     resolution: {integrity: sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==}
     engines: {node: '>= 0.4'}
 
-  shiki@4.3.1:
-    resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
-    engines: {node: '>=20'}
-
   shiki@4.4.3:
     resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==}
     engines: {node: '>=20'}
@@ -4223,7 +4191,7 @@ snapshots:
 
   '@noble/hashes@1.8.0': {}
 
-  '@node-core/rehype-shiki@1.5.0(supports-color@10.2.2)':
+  '@node-core/rehype-shiki@1.5.1(supports-color@10.2.2)':
     dependencies:
       '@shikijs/core': 4.4.3
       '@shikijs/engine-javascript': 4.4.3
@@ -4231,7 +4199,7 @@ snapshots:
       '@shikijs/twoslash': 4.4.3(supports-color@10.2.2)(typescript@5.9.3)
       classnames: 2.5.1
       hast-util-to-string: 3.0.1
-      shiki: 4.3.1
+      shiki: 4.4.3
       typescript: 5.9.3
       unist-util-visit: 5.1.0
     transitivePeerDependencies:
@@ -4835,14 +4803,6 @@ snapshots:
 
   '@rolldown/pluginutils@1.0.1': {}
 
-  '@shikijs/core@4.3.1':
-    dependencies:
-      '@shikijs/primitive': 4.3.1
-      '@shikijs/types': 4.3.1
-      '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.5
-      hast-util-to-html: 9.0.5
-
   '@shikijs/core@4.4.3':
     dependencies:
       '@shikijs/primitive': 4.4.3
@@ -4851,12 +4811,6 @@ snapshots:
       '@types/hast': 3.0.5
       hast-util-to-html: 9.0.5
 
-  '@shikijs/engine-javascript@4.3.1':
-    dependencies:
-      '@shikijs/types': 4.3.1
-      '@shikijs/vscode-textmate': 10.0.2
-      oniguruma-to-es: 4.3.6
-
   '@shikijs/engine-javascript@4.4.3':
     dependencies:
       '@shikijs/types': 4.4.3
@@ -4868,11 +4822,6 @@ snapshots:
       '@shikijs/types': 3.23.0
       '@shikijs/vscode-textmate': 10.0.2
 
-  '@shikijs/engine-oniguruma@4.3.1':
-    dependencies:
-      '@shikijs/types': 4.3.1
-      '@shikijs/vscode-textmate': 10.0.2
-
   '@shikijs/engine-oniguruma@4.4.3':
     dependencies:
       '@shikijs/types': 4.4.3
@@ -4882,20 +4831,10 @@ snapshots:
     dependencies:
       '@shikijs/types': 3.23.0
 
-  '@shikijs/langs@4.3.1':
-    dependencies:
-      '@shikijs/types': 4.3.1
-
   '@shikijs/langs@4.4.3':
     dependencies:
       '@shikijs/types': 4.4.3
 
-  '@shikijs/primitive@4.3.1':
-    dependencies:
-      '@shikijs/types': 4.3.1
-      '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.5
-
   '@shikijs/primitive@4.4.3':
     dependencies:
       '@shikijs/types': 4.4.3
@@ -4906,10 +4845,6 @@ snapshots:
     dependencies:
       '@shikijs/types': 3.23.0
 
-  '@shikijs/themes@4.3.1':
-    dependencies:
-      '@shikijs/types': 4.3.1
-
   '@shikijs/themes@4.4.3':
     dependencies:
       '@shikijs/types': 4.4.3
@@ -4928,11 +4863,6 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
-  '@shikijs/types@4.3.1':
-    dependencies:
-      '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.5
-
   '@shikijs/types@4.4.3':
     dependencies:
       '@shikijs/vscode-textmate': 10.0.2
@@ -6641,17 +6571,6 @@ snapshots:
 
   shell-quote@1.12.0: {}
 
-  shiki@4.3.1:
-    dependencies:
-      '@shikijs/core': 4.3.1
-      '@shikijs/engine-javascript': 4.3.1
-      '@shikijs/engine-oniguruma': 4.3.1
-      '@shikijs/langs': 4.3.1
-      '@shikijs/themes': 4.3.1
-      '@shikijs/types': 4.3.1
-      '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.5
-
   shiki@4.4.3:
     dependencies:
       '@shikijs/core': 4.4.3

From c1681b4db45e305c71c492731351a941a699ce22 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 13:32:07 +0200
Subject: [PATCH 02/14] perf(react): embed highlighted code as static markup

Every highlighted token of every code block, signature and type became
a hast element, then a JSX element, then generated code, only to be
rendered back into the same markup: most of what `jsx-ast` allocated,
and much of what the pages' code took to compile and render. Highlighted
code is static, as islands adopt it without rendering it again, so it
now reaches the pages as HTML, written by `hast-util-to-html` and held
by a `` through `dangerouslySetInnerHTML`.

Code blocks are embedded by a plugin running after Shiki. Types and
signatures are embedded as they are highlighted, before `rehype-raw`,
which would otherwise parse each of their tokens again.

It is the HTML Preact rendered, spelled differently in places:
`hast-util-to-html` writes `<` as `<`, leaves `"` unescaped in
text, and keeps Shiki's token styles without a trailing `;`.

Assisted-by: Claude Opus 5.5 
---
 packages/react/package.json                   |   1 +
 packages/react/src/jsx-ast/index.mjs          |  10 +-
 .../plugins/__tests__/static-markup.test.mjs  | 142 ++++++++++++++++++
 .../src/jsx-ast/plugins/static-markup.mjs     |  96 ++++++++++++
 .../utils/__tests__/signature.test.mjs        |  38 ++++-
 packages/react/src/jsx-ast/utils/hast.mjs     |  19 +++
 .../react/src/jsx-ast/utils/signature.mjs     |   5 +-
 pnpm-lock.yaml                                |   3 +
 8 files changed, 309 insertions(+), 5 deletions(-)
 create mode 100644 packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs
 create mode 100644 packages/react/src/jsx-ast/plugins/static-markup.mjs
 create mode 100644 packages/react/src/jsx-ast/utils/hast.mjs

diff --git a/packages/react/package.json b/packages/react/package.json
index 0bf8f002..adb3946e 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -41,6 +41,7 @@
     "@orama/ui": "^1.5.4",
     "estree-util-to-js": "^2.0.0",
     "github-slugger": "^2.0.0",
+    "hast-util-to-html": "^9.0.5",
     "hast-util-to-string": "^3.0.1",
     "hastscript": "^9.0.1",
     "mdast-util-slice-markdown": "^2.0.1",
diff --git a/packages/react/src/jsx-ast/index.mjs b/packages/react/src/jsx-ast/index.mjs
index b1dd1aeb..23362b95 100644
--- a/packages/react/src/jsx-ast/index.mjs
+++ b/packages/react/src/jsx-ast/index.mjs
@@ -5,6 +5,7 @@ import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotati
 
 import { AST_NODE_TYPES } from './constants.mjs';
 import { generate, processChunk } from './generate.mjs';
+import { embedHighlightedTypes } from './plugins/static-markup.mjs';
 
 /**
  * Generator for converting MDAST to JSX AST.
@@ -40,10 +41,11 @@ export default {
           // within our Markdown and we trust the sources of the Markdown files
           allowDangerousHtml: true,
           passThrough: ['element', ...Object.values(AST_NODE_TYPES.MDX)],
-          // Types are highlighted, with their links embedded
+          // Types are highlighted, with their links embedded, as static
+          // markup (see `./plugins/static-markup.mjs`)
           handlers: {
-            typeAnnotation: createTypeAnnotationHandler(() =>
-              getHighlighter('jsx-ast')
+            typeAnnotation: embedHighlightedTypes(
+              createTypeAnnotationHandler(() => getHighlighter('jsx-ast'))
             ),
           },
         },
@@ -52,6 +54,8 @@ export default {
       // The configured rehype plugins run before code blocks are highlighted
       '...',
       '@doc-kit/core/plugins/shiki/rehype.mjs',
+      // Highlighted code reaches the pages as the markup it renders to
+      './plugins/static-markup.mjs',
       './plugins/transformer.mjs',
     ],
     recmaPlugins: ['rehype-recma', 'recma-jsx', '...', 'recma-stringify'],
diff --git a/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs b/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs
new file mode 100644
index 00000000..2a927e26
--- /dev/null
+++ b/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs
@@ -0,0 +1,142 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { createHighlighter } from '@doc-kit/core/plugins/shiki/highlighter.mjs';
+import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
+import { toString } from 'hast-util-to-string';
+import rehypeRaw from 'rehype-raw';
+import { unified } from 'unified';
+
+import rehypeStaticMarkup, {
+  embedHighlightedTypes,
+} from '../static-markup.mjs';
+
+const highlighter = await createHighlighter();
+
+const embedTypes = embedHighlightedTypes(
+  createTypeAnnotationHandler(() => highlighter)
+);
+
+// A minimal mdast-util-to-hast state: the handlers only use patch/applyData
+const state = { patch: () => {}, applyData: (_, result) => result };
+
+/**
+ * The text an HTML fragment reads as, parsed as a browser would parse it.
+ *
+ * @param {string} html
+ */
+const textContent = html =>
+  toString(
+    unified()
+      .use(rehypeRaw)
+      .runSync({ type: 'root', children: [{ type: 'raw', value: html }] })
+  );
+
+/**
+ * Reads an attribute of a JSX element: its value, or the value of the object
+ * it's given (as `dangerouslySetInnerHTML` is).
+ *
+ * @param {{ attributes: Array<{ name: string, value: unknown }> }} element
+ * @param {string} name
+ */
+const attribute = (element, name) => {
+  const { value } = element.attributes.find(entry => entry.name === name);
+
+  if (typeof value === 'string') {
+    return value;
+  }
+
+  const [{ expression }] = value.data.estree.body;
+
+  return Object.fromEntries(
+    expression.properties.map(({ key, value: entry }) => [
+      key.name,
+      entry.value,
+    ])
+  );
+};
+
+/**
+ * The markup a JSX `` holds through `dangerouslySetInnerHTML`.
+ *
+ * @param {{ attributes: Array<{ name: string, value: unknown }> }} element
+ */
+const innerHTML = element =>
+  attribute(element, 'dangerouslySetInnerHTML').__html;
+
+describe('rehypeStaticMarkup', () => {
+  const embed = rehypeStaticMarkup();
+
+  it("embeds a highlighted block's code as the markup it renders to", () => {
+    const tree = highlighter.highlightToHast('const a = 1;', 'js');
+    const [pre] = tree.children;
+
+    embed(tree);
+
+    // The 
 stays as it is, for the code box to render
+    assert.equal(pre.tagName, 'pre');
+    assert.match(pre.properties.class, /^shiki /);
+
+    const [code] = pre.children;
+
+    assert.equal(code.type, 'mdxJsxFlowElement');
+    assert.equal(code.name, 'code');
+    assert.equal(code.children.length, 0);
+    assert.match(
+      innerHTML(code),
+      /^const<\/span>/i
+    );
+    assert.equal(textContent(innerHTML(code)), 'const a = 1;');
+  });
+
+  it('leaves code that was not highlighted as it is', () => {
+    const code = {
+      type: 'element',
+      tagName: 'code',
+      properties: { className: ['language-js'] },
+      children: [{ type: 'text', value: 'a();' }],
+    };
+
+    const tree = {
+      type: 'root',
+      children: [
+        { type: 'element', tagName: 'pre', properties: {}, children: [code] },
+      ],
+    };
+
+    embed(tree);
+
+    assert.equal(tree.children[0].children[0], code);
+  });
+});
+
+describe('embedHighlightedTypes', () => {
+  const makeNode = (value, data) => ({ type: 'typeAnnotation', value, data });
+
+  it('embeds a highlighted type as one inline , its links included', () => {
+    const element = embedTypes(
+      state,
+      makeNode('Promise', {
+        typescript: true,
+        links: [{ start: 0, end: 7, text: 'Promise', href: 'mdn.io/promise' }],
+      })
+    );
+
+    assert.equal(element.type, 'mdxJsxTextElement');
+    assert.equal(element.name, 'code');
+    assert.match(attribute(element, 'className'), /^shiki .* type$/);
+
+    const html = innerHTML(element);
+
+    assert.match(html, //);
+    assert.doesNotMatch(html, /');
+  });
+
+  it('leaves a type that was not highlighted as it is', () => {
+    const element = embedTypes(state, makeNode('Whatever', { links: [] }));
+
+    assert.equal(element.type, 'element');
+    assert.deepStrictEqual(element.properties, { className: ['type'] });
+  });
+});
diff --git a/packages/react/src/jsx-ast/plugins/static-markup.mjs b/packages/react/src/jsx-ast/plugins/static-markup.mjs
new file mode 100644
index 00000000..4ec935ff
--- /dev/null
+++ b/packages/react/src/jsx-ast/plugins/static-markup.mjs
@@ -0,0 +1,96 @@
+'use strict';
+
+import { toHtml } from 'hast-util-to-html';
+import { SKIP, visit } from 'unist-util-visit';
+
+import { createJSXElement } from '../utils/ast.mjs';
+import { getClassNames } from '../utils/hast.mjs';
+
+// Highlighted code is static: an island adopts it without rendering it again.
+// So it reaches the page as the markup it renders to, rather than each of its
+// tokens becoming a hast element, then a JSX element, then generated code,
+// only to be rendered back into that same markup.
+
+/**
+ * Whether an element is code Shiki highlighted: the `
` of a block, or
+ * the `` of a type.
+ *
+ * @param {import('hast').Element} element
+ */
+const isHighlighted = element => getClassNames(element).includes('shiki');
+
+/**
+ * Turns the `` of highlighted code into a JSX `` holding its
+ * markup as it is.
+ *
+ * @param {import('hast').Element} code - The ``
+ * @param {boolean} [inline] - Whether it's a type's, rather than a block's
+ */
+const embedCode = (code, inline = false) => {
+  const classNames = getClassNames(code);
+
+  // JSX takes the class names as one `className`, which comes first, as
+  // `class` did
+  const attributes = {
+    inline,
+    className: classNames.length > 0 ? classNames.join(' ') : undefined,
+    ...code.properties,
+    dangerouslySetInnerHTML: { __html: toHtml(code.children) },
+  };
+
+  delete attributes.class;
+
+  return createJSXElement('code', attributes);
+};
+
+/**
+ * Embeds the code blocks of a tree that Shiki highlighted.
+ *
+ * @template {import('hast').Root} T
+ * @param {T} tree
+ * @returns {T}
+ */
+export const embedHighlightedBlocks = tree => {
+  visit(tree, 'element', node => {
+    const [code] = node.children;
+
+    if (node.tagName === 'pre' && code?.tagName === 'code') {
+      if (isHighlighted(node)) {
+        node.children[0] = embedCode(code);
+      }
+
+      return SKIP;
+    }
+  });
+
+  return tree;
+};
+
+/**
+ * Wraps a `typeAnnotation` handler of `remark-rehype`, embedding the types it
+ * highlights.
+ *
+ * @param {(state: import('mdast-util-to-hast').State, node: import('mdast').Node) => import('hast').Element} handler
+ * @returns {typeof handler}
+ */
+export const embedHighlightedTypes = handler => (state, node) => {
+  const result = handler(state, node);
+
+  // A type that didn't parse, or links nowhere, isn't highlighted
+  if (!isHighlighted(result)) {
+    return result;
+  }
+
+  const code = embedCode(result, true);
+
+  state.patch(node, code);
+
+  return code;
+};
+
+/**
+ * Embeds the code blocks that Shiki highlighted.
+ *
+ * @type {import('unified').Plugin<[], import('hast').Root>}
+ */
+export default () => embedHighlightedBlocks;
diff --git a/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs
index 0ae5670e..bb397b29 100644
--- a/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs
+++ b/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs
@@ -1,7 +1,13 @@
 import assert from 'node:assert/strict';
 import { describe, it } from 'node:test';
 
-import { generateSignature, getFullName } from '../signature.mjs';
+import { loadMarkdownPlugins } from '@doc-kit/core/utils/markdown/plugins.mjs';
+
+import {
+  createSignatureCodeBlock,
+  generateSignature,
+  getFullName,
+} from '../signature.mjs';
 
 describe('generateSignature', () => {
   describe('function signatures', () => {
@@ -246,6 +252,36 @@ describe('generateSignature', () => {
   });
 });
 
+describe('createSignatureCodeBlock', () => {
+  it('highlights the signature, its code embedded as markup', async () => {
+    // Signatures are highlighted by the Shiki plugin of `jsx-ast`
+    await loadMarkdownPlugins({
+      name: 'jsx-ast',
+      markdown: {
+        rehypePlugins: [
+          import.meta.resolve('@doc-kit/core/plugins/shiki/rehype.mjs'),
+        ],
+      },
+    });
+
+    const block = createSignatureCodeBlock('fn', {
+      params: [{ name: 'a' }],
+      return: { type: 'string' },
+    });
+
+    assert.deepStrictEqual(block.properties, { className: ['signature'] });
+
+    const [pre] = block.children;
+    const [code] = pre.children;
+
+    assert.match(pre.properties.class, /^shiki /);
+    assert.equal(code.type, 'mdxJsxFlowElement');
+    assert.ok(
+      code.attributes.some(({ name }) => name === 'dangerouslySetInnerHTML')
+    );
+  });
+});
+
 describe('getFullName', () => {
   it('returns fallback when name equals text', () => {
     const result = getFullName({ name: 'test', text: 'test' }, 'fallback');
diff --git a/packages/react/src/jsx-ast/utils/hast.mjs b/packages/react/src/jsx-ast/utils/hast.mjs
new file mode 100644
index 00000000..16135b3f
--- /dev/null
+++ b/packages/react/src/jsx-ast/utils/hast.mjs
@@ -0,0 +1,19 @@
+'use strict';
+
+/**
+ * The class names of an element. Shiki gives them in `class`, as a string or
+ * as a list, and hast in `className`, as a list.
+ *
+ * @param {import('hast').Element} element
+ * @returns {Array}
+ */
+export const getClassNames = element => {
+  const classNames =
+    element.properties?.class ?? element.properties?.className ?? [];
+
+  if (Array.isArray(classNames)) {
+    return classNames;
+  }
+
+  return classNames.split(' ');
+};
diff --git a/packages/react/src/jsx-ast/utils/signature.mjs b/packages/react/src/jsx-ast/utils/signature.mjs
index 3edf9015..bc37d84f 100644
--- a/packages/react/src/jsx-ast/utils/signature.mjs
+++ b/packages/react/src/jsx-ast/utils/signature.mjs
@@ -5,6 +5,7 @@ import parseSignature from '@doc-kit/core/utils/signature/parseSignature.mjs';
 import { h as createElement } from 'hastscript';
 
 import { JSX_IMPORTS } from '../../html/constants.mjs';
+import { embedHighlightedBlocks } from '../plugins/static-markup.mjs';
 import { createJSXElement } from './ast.mjs';
 import { parseListIntoProperties } from './types.mjs';
 
@@ -68,7 +69,9 @@ export const createSignatureCodeBlock = (functionName, signature, heading) => {
   const highlighter = getHighlighter('jsx-ast');
   const highlighted = highlighter.highlightToHast(sig, 'typescript');
 
-  return createElement('div', { class: 'signature' }, [highlighted]);
+  return createElement('div', { class: 'signature' }, [
+    embedHighlightedBlocks(highlighted),
+  ]);
 };
 
 /**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 79b835c8..749b77f9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -259,6 +259,9 @@ importers:
       github-slugger:
         specifier: ^2.0.0
         version: 2.0.0
+      hast-util-to-html:
+        specifier: ^9.0.5
+        version: 9.0.5
       hast-util-to-string:
         specifier: ^3.0.1
         version: 3.0.1

From b68f529d4a95dc607586ed0e367f0f4666934d89 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 13:35:16 +0200
Subject: [PATCH 03/14] perf(react): render `all.html` in the worker pool

The pool renders a single task on the calling thread, so `all.html`,
rendered on its own after the other pages, was rendered on the main
thread. That held the whole site in its heap, and kept the pool from
shutting its idle workers down until it was done. It is now rendered
alongside the other pages, first, as it takes by far the longest.

Assisted-by: Claude Opus 5.5 
---
 packages/react/src/html/generate.mjs | 27 +++++++++++++++------------
 1 file changed, 15 insertions(+), 12 deletions(-)

diff --git a/packages/react/src/html/generate.mjs b/packages/react/src/html/generate.mjs
index d48f6a59..1dbb67ed 100644
--- a/packages/react/src/html/generate.mjs
+++ b/packages/react/src/html/generate.mjs
@@ -28,10 +28,10 @@ const htmlLogger = logger.child('html');
  * 2. The client assets are bundled once; every page loads the same ones.
  * 3. Each page's program is compiled (JSX to a plain module) and written to a
  * temporary directory, one at a time, so no page is held longer than that.
- * 4. The worker pool imports, renders, templates, minifies and writes the
+ * 4. `all.html`, when enabled, is a program that imports the module pages'
+ * content, so it is compiled from what was already compiled.
+ * 5. The worker pool imports, renders, templates, minifies and writes the
  * pages, one page in memory per worker.
- * 5. `all.html`, when enabled, is a program that imports the module pages'
- * content, so it is written last from what was already compiled.
  *
  * @type {import('./types').Generator['generate']}
  */
@@ -109,18 +109,21 @@ export async function generate(input, worker) {
       tasks.push(await compile(page));
     }
 
-    htmlLogger.debug(`Compiled ${tasks.length} page programs`);
+    // The composed page imports the other pages' compiled programs, which
+    // exist from here on, so it is rendered by the worker pool alongside them.
+    // Rendering it on this thread instead would hold the whole site in it and
+    // block the pool from shutting down its idle workers until it is done.
+    // It goes first: it takes by far the longest, so the other pages are
+    // rendered while it is.
+    if (all) {
+      const allTask = await compile(all);
 
-    const writePages = createPageWriter(worker);
-    const extra = { template, assets };
+      tasks.unshift(allTask);
+    }
 
-    await writePages(tasks, extra);
+    htmlLogger.debug(`Compiled ${tasks.length} page programs`);
 
-    // The composed page imports the others' compiled programs, so it can only
-    // be rendered once those exist — which they now do.
-    if (all) {
-      await writePages([await compile(all)], extra);
-    }
+    await createPageWriter(worker)(tasks, { template, assets });
   } finally {
     await rm(outDir, { recursive: true, force: true });
   }

From bd177ddf53a7cb792a50799ccdc7f5a94fd69fd9 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 13:37:56 +0200
Subject: [PATCH 04/14] perf: keep worker-only code off the main thread

The main thread loads every generator of a run, but only ever calls
`generate`; building the pages is the workers' job. Still, the
generators imported what only their workers use, so the main thread
loaded the TypeScript parser (WASM) and what building a page takes.
Those are now imported where they are used.

`getFullName` also moves, next to `getClassNames` in `utils/hast.mjs`:
`buildBarProps`, which `section-pages` imports, and `buildContent` only
need a page's full name, not the code that builds and highlights
signatures.

Assisted-by: Claude Opus 5.5 
---
 .../core/src/generators/metadata/generate.mjs |   6 +-
 packages/react/src/jsx-ast/generate.mjs       |   5 +-
 .../src/jsx-ast/utils/__tests__/hast.test.mjs | 199 +++++++++++++++++
 .../utils/__tests__/signature.test.mjs        | 201 +-----------------
 .../react/src/jsx-ast/utils/buildBarProps.mjs |   2 +-
 .../react/src/jsx-ast/utils/buildContent.mjs  |   2 +-
 packages/react/src/jsx-ast/utils/hast.mjs     |  51 +++++
 .../react/src/jsx-ast/utils/signature.mjs     |  52 +----
 8 files changed, 262 insertions(+), 256 deletions(-)
 create mode 100644 packages/react/src/jsx-ast/utils/__tests__/hast.test.mjs

diff --git a/packages/core/src/generators/metadata/generate.mjs b/packages/core/src/generators/metadata/generate.mjs
index b35fa134..7e7619c6 100644
--- a/packages/core/src/generators/metadata/generate.mjs
+++ b/packages/core/src/generators/metadata/generate.mjs
@@ -3,8 +3,6 @@
 import getConfig from '#utils/configuration/index.mjs';
 import { loadFromURL } from '#utils/loaders.mjs';
 
-import { parseApiDoc } from './utils/parse.mjs';
-
 /**
  * Process a chunk of API doc files in a worker thread.
  * Called by chunk-worker.mjs for parallel processing.
@@ -12,6 +10,10 @@ import { parseApiDoc } from './utils/parse.mjs';
  * @type {import('./types').Generator['processChunk']}
  */
 export async function processChunk(fullInput, itemIndices, typeMap) {
+  // Loaded on first use rather than with the generator, so the main thread,
+  // which never parses, does not load the TypeScript parser (~40MB of WASM)
+  const { parseApiDoc } = await import('./utils/parse.mjs');
+
   const results = [];
 
   for (const idx of itemIndices) {
diff --git a/packages/react/src/jsx-ast/generate.mjs b/packages/react/src/jsx-ast/generate.mjs
index e0447412..05972ed1 100644
--- a/packages/react/src/jsx-ast/generate.mjs
+++ b/packages/react/src/jsx-ast/generate.mjs
@@ -2,7 +2,6 @@ import getConfig from '@doc-kit/core/utils/configuration/index.mjs';
 import { groupNodesByModule } from '@doc-kit/core/utils/generators.mjs';
 import { jsx, toJs } from 'estree-util-to-js';
 
-import buildContent from './utils/buildContent.mjs';
 import { getSortedHeadNodes } from './utils/getSortedHeadNodes.mjs';
 import { buildNotFoundPage } from './utils/synthetic/404.mjs';
 
@@ -18,6 +17,10 @@ import { buildNotFoundPage } from './utils/synthetic/404.mjs';
  * @type {import('./types').Generator['processChunk']}
  */
 export async function processChunk(slicedInput, itemIndices) {
+  // Loaded on first use rather than with the generator, so the main thread,
+  // which never builds a page, does not load what building one takes
+  const { default: buildContent } = await import('./utils/buildContent.mjs');
+
   const results = [];
 
   for (const idx of itemIndices) {
diff --git a/packages/react/src/jsx-ast/utils/__tests__/hast.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/hast.test.mjs
new file mode 100644
index 00000000..cad86da1
--- /dev/null
+++ b/packages/react/src/jsx-ast/utils/__tests__/hast.test.mjs
@@ -0,0 +1,199 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { getFullName } from '../hast.mjs';
+
+describe('getFullName', () => {
+  it('returns fallback when name equals text', () => {
+    const result = getFullName({ name: 'test', text: 'test' }, 'fallback');
+    assert.strictEqual(result, 'fallback');
+  });
+
+  it('returns name as fallback when name equals text and no fallback provided', () => {
+    const result = getFullName({ name: 'test', text: 'test' });
+    assert.strictEqual(result, 'test');
+  });
+
+  it('extracts inline code that includes the name', () => {
+    const result = getFullName({
+      name: 'myFunc',
+      text: 'This is `myFunc(param1, param2)` function',
+    });
+    assert.strictEqual(result, 'myFunc');
+  });
+
+  it('handles inline code with extra content after name', () => {
+    const result = getFullName({
+      name: 'authenticate',
+      text: 'The `authenticate(user, password)` method',
+    });
+    assert.strictEqual(result, 'authenticate');
+  });
+
+  it('strips quotes from the beginning', () => {
+    const result = getFullName({
+      name: 'func',
+      text: 'The `"func"()` method',
+    });
+    assert.strictEqual(result, 'func');
+  });
+
+  it('strips single quotes from the beginning', () => {
+    const result = getFullName({
+      name: 'func',
+      text: "The `'func'()` method",
+    });
+    assert.strictEqual(result, 'func');
+  });
+
+  it('strips "new" keyword from the beginning', () => {
+    const result = getFullName({
+      name: 'Constructor',
+      text: 'The `new Constructor()` call',
+    });
+    assert.strictEqual(result, 'Constructor');
+  });
+
+  it('strips "new " with space from the beginning', () => {
+    const result = getFullName({
+      name: 'MyClass',
+      text: 'The `new MyClass(param)` constructor',
+    });
+    assert.strictEqual(result, 'MyClass');
+  });
+
+  it('returns fallback when no inline code found', () => {
+    const result = getFullName(
+      {
+        name: 'func',
+        text: 'This is a function without code blocks',
+      },
+      'fallback'
+    );
+    assert.strictEqual(result, 'fallback');
+  });
+
+  it('returns fallback when inline code does not include name', () => {
+    const result = getFullName(
+      {
+        name: 'myFunc',
+        text: 'This is `otherFunc()` function',
+      },
+      'fallback'
+    );
+    assert.strictEqual(result, 'fallback');
+  });
+
+  it('handles empty inline code', () => {
+    const result = getFullName(
+      {
+        name: 'func',
+        text: 'This has `` empty code',
+      },
+      'fallback'
+    );
+    assert.strictEqual(result, 'fallback');
+  });
+
+  it('handles multiple inline code blocks, uses first match', () => {
+    const result = getFullName({
+      name: 'func',
+      text: 'This has `func()` and `other()` code',
+    });
+    assert.strictEqual(result, 'func');
+  });
+
+  it('handles complex inline code with parameters', () => {
+    const result = getFullName({
+      name: 'processData',
+      text: 'The `processData(input, options = {})` method processes data',
+    });
+    assert.strictEqual(result, 'processData');
+  });
+
+  it('strips both quotes and new keyword', () => {
+    const result = getFullName({
+      name: 'MyClass',
+      text: '`"new MyClass"()`',
+    });
+    assert.strictEqual(result, 'MyClass');
+  });
+
+  it('handles text with no backticks', () => {
+    const result = getFullName(
+      {
+        name: 'func',
+        text: 'This function does something',
+      },
+      'fallbackValue'
+    );
+    assert.strictEqual(result, 'fallbackValue');
+  });
+
+  it('skips occurrences of the name within the receiver', () => {
+    const result = getFullName({
+      name: 'channel',
+      text: '`diagnostics_channel.channel(name)`',
+    });
+    assert.strictEqual(result, 'diagnostics_channel.channel');
+  });
+
+  it('skips occurrences of the name that are a prefix of the receiver', () => {
+    const result = getFullName({
+      name: 'read',
+      text: '`readable.read([size])`',
+    });
+    assert.strictEqual(result, 'readable.read');
+  });
+
+  it('ignores parameters repeating the name', () => {
+    const result = getFullName({
+      name: 'percentile',
+      text: '`histogram.percentile(percentile)`',
+    });
+    assert.strictEqual(result, 'histogram.percentile');
+  });
+
+  it('handles symbol-keyed methods', () => {
+    const result = getFullName({
+      name: "[Symbol.for('nodejs.rejection')]",
+      text: "`emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args])`",
+    });
+    assert.strictEqual(result, "emitter[Symbol.for('nodejs.rejection')]");
+  });
+
+  it('keeps quoted names intact', () => {
+    const result = getFullName({
+      name: 'console.log',
+      text: "Event: `'console.log'`",
+    });
+    assert.strictEqual(result, 'console.log');
+  });
+
+  it('does not strip "new" from within a name', () => {
+    const result = getFullName({
+      name: 'newListener',
+      text: "Event: `'newListener'`",
+    });
+    assert.strictEqual(result, 'newListener');
+  });
+
+  it('does not strip "new" from within a dotted name', () => {
+    const result = getFullName({
+      name: 'onnewtoken',
+      text: '`session.onnewtoken`',
+    });
+    assert.strictEqual(result, 'session.onnewtoken');
+  });
+
+  it('returns fallback when no occurrence terminates the name', () => {
+    const result = getFullName(
+      {
+        name: 'read',
+        text: '`readable`',
+      },
+      'fallback'
+    );
+    assert.strictEqual(result, 'fallback');
+  });
+});
diff --git a/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs
index bb397b29..9469ebfc 100644
--- a/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs
+++ b/packages/react/src/jsx-ast/utils/__tests__/signature.test.mjs
@@ -3,11 +3,7 @@ import { describe, it } from 'node:test';
 
 import { loadMarkdownPlugins } from '@doc-kit/core/utils/markdown/plugins.mjs';
 
-import {
-  createSignatureCodeBlock,
-  generateSignature,
-  getFullName,
-} from '../signature.mjs';
+import { createSignatureCodeBlock, generateSignature } from '../signature.mjs';
 
 describe('generateSignature', () => {
   describe('function signatures', () => {
@@ -281,198 +277,3 @@ describe('createSignatureCodeBlock', () => {
     );
   });
 });
-
-describe('getFullName', () => {
-  it('returns fallback when name equals text', () => {
-    const result = getFullName({ name: 'test', text: 'test' }, 'fallback');
-    assert.strictEqual(result, 'fallback');
-  });
-
-  it('returns name as fallback when name equals text and no fallback provided', () => {
-    const result = getFullName({ name: 'test', text: 'test' });
-    assert.strictEqual(result, 'test');
-  });
-
-  it('extracts inline code that includes the name', () => {
-    const result = getFullName({
-      name: 'myFunc',
-      text: 'This is `myFunc(param1, param2)` function',
-    });
-    assert.strictEqual(result, 'myFunc');
-  });
-
-  it('handles inline code with extra content after name', () => {
-    const result = getFullName({
-      name: 'authenticate',
-      text: 'The `authenticate(user, password)` method',
-    });
-    assert.strictEqual(result, 'authenticate');
-  });
-
-  it('strips quotes from the beginning', () => {
-    const result = getFullName({
-      name: 'func',
-      text: 'The `"func"()` method',
-    });
-    assert.strictEqual(result, 'func');
-  });
-
-  it('strips single quotes from the beginning', () => {
-    const result = getFullName({
-      name: 'func',
-      text: "The `'func'()` method",
-    });
-    assert.strictEqual(result, 'func');
-  });
-
-  it('strips "new" keyword from the beginning', () => {
-    const result = getFullName({
-      name: 'Constructor',
-      text: 'The `new Constructor()` call',
-    });
-    assert.strictEqual(result, 'Constructor');
-  });
-
-  it('strips "new " with space from the beginning', () => {
-    const result = getFullName({
-      name: 'MyClass',
-      text: 'The `new MyClass(param)` constructor',
-    });
-    assert.strictEqual(result, 'MyClass');
-  });
-
-  it('returns fallback when no inline code found', () => {
-    const result = getFullName(
-      {
-        name: 'func',
-        text: 'This is a function without code blocks',
-      },
-      'fallback'
-    );
-    assert.strictEqual(result, 'fallback');
-  });
-
-  it('returns fallback when inline code does not include name', () => {
-    const result = getFullName(
-      {
-        name: 'myFunc',
-        text: 'This is `otherFunc()` function',
-      },
-      'fallback'
-    );
-    assert.strictEqual(result, 'fallback');
-  });
-
-  it('handles empty inline code', () => {
-    const result = getFullName(
-      {
-        name: 'func',
-        text: 'This has `` empty code',
-      },
-      'fallback'
-    );
-    assert.strictEqual(result, 'fallback');
-  });
-
-  it('handles multiple inline code blocks, uses first match', () => {
-    const result = getFullName({
-      name: 'func',
-      text: 'This has `func()` and `other()` code',
-    });
-    assert.strictEqual(result, 'func');
-  });
-
-  it('handles complex inline code with parameters', () => {
-    const result = getFullName({
-      name: 'processData',
-      text: 'The `processData(input, options = {})` method processes data',
-    });
-    assert.strictEqual(result, 'processData');
-  });
-
-  it('strips both quotes and new keyword', () => {
-    const result = getFullName({
-      name: 'MyClass',
-      text: '`"new MyClass"()`',
-    });
-    assert.strictEqual(result, 'MyClass');
-  });
-
-  it('handles text with no backticks', () => {
-    const result = getFullName(
-      {
-        name: 'func',
-        text: 'This function does something',
-      },
-      'fallbackValue'
-    );
-    assert.strictEqual(result, 'fallbackValue');
-  });
-
-  it('skips occurrences of the name within the receiver', () => {
-    const result = getFullName({
-      name: 'channel',
-      text: '`diagnostics_channel.channel(name)`',
-    });
-    assert.strictEqual(result, 'diagnostics_channel.channel');
-  });
-
-  it('skips occurrences of the name that are a prefix of the receiver', () => {
-    const result = getFullName({
-      name: 'read',
-      text: '`readable.read([size])`',
-    });
-    assert.strictEqual(result, 'readable.read');
-  });
-
-  it('ignores parameters repeating the name', () => {
-    const result = getFullName({
-      name: 'percentile',
-      text: '`histogram.percentile(percentile)`',
-    });
-    assert.strictEqual(result, 'histogram.percentile');
-  });
-
-  it('handles symbol-keyed methods', () => {
-    const result = getFullName({
-      name: "[Symbol.for('nodejs.rejection')]",
-      text: "`emitter[Symbol.for('nodejs.rejection')](err, eventName[, ...args])`",
-    });
-    assert.strictEqual(result, "emitter[Symbol.for('nodejs.rejection')]");
-  });
-
-  it('keeps quoted names intact', () => {
-    const result = getFullName({
-      name: 'console.log',
-      text: "Event: `'console.log'`",
-    });
-    assert.strictEqual(result, 'console.log');
-  });
-
-  it('does not strip "new" from within a name', () => {
-    const result = getFullName({
-      name: 'newListener',
-      text: "Event: `'newListener'`",
-    });
-    assert.strictEqual(result, 'newListener');
-  });
-
-  it('does not strip "new" from within a dotted name', () => {
-    const result = getFullName({
-      name: 'onnewtoken',
-      text: '`session.onnewtoken`',
-    });
-    assert.strictEqual(result, 'session.onnewtoken');
-  });
-
-  it('returns fallback when no occurrence terminates the name', () => {
-    const result = getFullName(
-      {
-        name: 'read',
-        text: '`readable`',
-      },
-      'fallback'
-    );
-    assert.strictEqual(result, 'fallback');
-  });
-});
diff --git a/packages/react/src/jsx-ast/utils/buildBarProps.mjs b/packages/react/src/jsx-ast/utils/buildBarProps.mjs
index 5bb3823e..24b5194f 100644
--- a/packages/react/src/jsx-ast/utils/buildBarProps.mjs
+++ b/packages/react/src/jsx-ast/utils/buildBarProps.mjs
@@ -3,7 +3,7 @@
 import { visit } from 'unist-util-visit';
 
 import { TOC_MAX_HEADING_DEPTH } from '../constants.mjs';
-import { getFullName } from './signature.mjs';
+import { getFullName } from './hast.mjs';
 
 // Callable heading types whose ToC label should be the bare function name
 // rather than the full signature.
diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs
index 82688fa2..96b36a05 100644
--- a/packages/react/src/jsx-ast/utils/buildContent.mjs
+++ b/packages/react/src/jsx-ast/utils/buildContent.mjs
@@ -29,11 +29,11 @@ import {
 } from '../constants.mjs';
 import { createJSXElement } from './ast.mjs';
 import { extractHeadings, extractTextContent } from './buildBarProps.mjs';
+import { getFullName } from './hast.mjs';
 import { renderAsJSX } from './render.mjs';
 import {
   insertSignatureCodeBlock,
   createSignatureTable,
-  getFullName,
 } from './signature.mjs';
 
 /**
diff --git a/packages/react/src/jsx-ast/utils/hast.mjs b/packages/react/src/jsx-ast/utils/hast.mjs
index 16135b3f..591fcd22 100644
--- a/packages/react/src/jsx-ast/utils/hast.mjs
+++ b/packages/react/src/jsx-ast/utils/hast.mjs
@@ -17,3 +17,54 @@ export const getClassNames = element => {
 
   return classNames.split(' ');
 };
+
+/**
+ * Infers the "real" function name from a heading node.
+ * Useful when auto-generated headings differ from code tokens.
+ *
+ * @param {import('@doc-kit/core/generators/metadata/types').HeadingData} heading - Metadata with name and text fields.
+ * @param {any} fallback - Fallback value if inference fails.
+ */
+export const getFullName = ({ name, text }, fallback = name) => {
+  // If the name and text are identical, just use fallback
+  if (name === text) {
+    return fallback;
+  }
+
+  // Attempt to extract inline code from heading text
+  const code = text.trim().match(/`([^`]+)`/)?.[1];
+
+  if (!code?.includes(name)) {
+    return fallback;
+  }
+
+  // Find the occurrence of `name` that denotes the documented entry: the one
+  // immediately followed by its parameter list, a closing quote, or the end
+  // of the code. Earlier occurrences are mere substrings of the receiver
+  // (e.g. `channel` within `diagnostics_channel.channel`, `read` within
+  // `readable.read`), and later ones can be parameters repeating the name.
+  let end = -1;
+  let index = code.indexOf(name);
+
+  while (index !== -1) {
+    const next = code[index + name.length];
+
+    if (next === undefined || next === '(' || next === "'" || next === '"') {
+      end = index + name.length;
+      break;
+    }
+
+    index = code.indexOf(name, index + 1);
+  }
+
+  // If inline code includes the name, return a clean version of it
+  return end === -1
+    ? fallback
+    : code
+        .slice(0, end) // Truncate everything after the name.
+        // Strip a leading quote and/or the "new" keyword. The latter requires
+        // following whitespace so names containing "new" (e.g. `newListener`)
+        // stay intact.
+        .replace(/^["']/, '')
+        .replace(/^new\s+/, '');
+};
diff --git a/packages/react/src/jsx-ast/utils/signature.mjs b/packages/react/src/jsx-ast/utils/signature.mjs
index bc37d84f..ab8e0a77 100644
--- a/packages/react/src/jsx-ast/utils/signature.mjs
+++ b/packages/react/src/jsx-ast/utils/signature.mjs
@@ -7,6 +7,7 @@ import { h as createElement } from 'hastscript';
 import { JSX_IMPORTS } from '../../html/constants.mjs';
 import { embedHighlightedBlocks } from '../plugins/static-markup.mjs';
 import { createJSXElement } from './ast.mjs';
+import { getFullName } from './hast.mjs';
 import { parseListIntoProperties } from './types.mjs';
 
 /**
@@ -74,57 +75,6 @@ export const createSignatureCodeBlock = (functionName, signature, heading) => {
   ]);
 };
 
-/**
- * Infers the "real" function name from a heading node.
- * Useful when auto-generated headings differ from code tokens.
- *
- * @param {import('@doc-kit/core/generators/metadata/types').HeadingData} heading - Metadata with name and text fields.
- * @param {any} fallback - Fallback value if inference fails.
- */
-export const getFullName = ({ name, text }, fallback = name) => {
-  // If the name and text are identical, just use fallback
-  if (name === text) {
-    return fallback;
-  }
-
-  // Attempt to extract inline code from heading text
-  const code = text.trim().match(/`([^`]+)`/)?.[1];
-
-  if (!code?.includes(name)) {
-    return fallback;
-  }
-
-  // Find the occurrence of `name` that denotes the documented entry: the one
-  // immediately followed by its parameter list, a closing quote, or the end
-  // of the code. Earlier occurrences are mere substrings of the receiver
-  // (e.g. `channel` within `diagnostics_channel.channel`, `read` within
-  // `readable.read`), and later ones can be parameters repeating the name.
-  let end = -1;
-  let index = code.indexOf(name);
-
-  while (index !== -1) {
-    const next = code[index + name.length];
-
-    if (next === undefined || next === '(' || next === "'" || next === '"') {
-      end = index + name.length;
-      break;
-    }
-
-    index = code.indexOf(name, index + 1);
-  }
-
-  // If inline code includes the name, return a clean version of it
-  return end === -1
-    ? fallback
-    : code
-        .slice(0, end) // Truncate everything after the name.
-        // Strip a leading quote and/or the "new" keyword. The latter requires
-        // following whitespace so names containing "new" (e.g. `newListener`)
-        // stay intact.
-        .replace(/^["']/, '')
-        .replace(/^new\s+/, '');
-};
-
 /**
  * Transforms a heading + list structure into a function/class signature block.
  * Mutates the `children` array by injecting the signature HAST node.

From 17d4be8bb1126f1049296f016889a273cecef771 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 13:38:35 +0200
Subject: [PATCH 05/14] perf(react): keep the `#theme/config` source off the
 rendering threads

The threads rendering pages load the `html` generator's module, and so
everything `generate` imports, along with `processing.mjs`. That
included `config.mjs`, which loads Shiki for the languages' display
names and a Markdown processor of its own: ~14MB and ~75ms per worker,
for what only the main thread uses, while the workers render pages,
which is when the build peaks.

`createVirtualImports` moves into `config.mjs`, which `generate` imports
when it bundles the site, so the main thread alone loads it.

Assisted-by: Claude Opus 5.5 
---
 packages/react/src/html/generate.mjs         |  5 ++++-
 packages/react/src/html/utils/config.mjs     | 17 +++++++++++++++++
 packages/react/src/html/utils/processing.mjs | 14 --------------
 3 files changed, 21 insertions(+), 15 deletions(-)

diff --git a/packages/react/src/html/generate.mjs b/packages/react/src/html/generate.mjs
index 1dbb67ed..796c0616 100644
--- a/packages/react/src/html/generate.mjs
+++ b/packages/react/src/html/generate.mjs
@@ -12,7 +12,6 @@ import { resolveBundler } from './bundlers/index.mjs';
 import { buildAllPage } from './utils/all.mjs';
 import { copyStaticAssets } from './utils/copying.mjs';
 import createProgramBuilder, { moduleFileName } from './utils/generate.mjs';
-import { createVirtualImports } from './utils/processing.mjs';
 import { createPageWriter } from './utils/render.mjs';
 
 const htmlLogger = logger.child('html');
@@ -47,6 +46,10 @@ export async function generate(input, worker) {
   // cross links need the whole set.
   const datas = [...pages, ...(all ? [all] : [])].map(({ data }) => data);
 
+  // Loaded here rather than with the generator, so the threads rendering
+  // pages, which load this module too, never load what only this needs
+  const { createVirtualImports } = await import('./utils/config.mjs');
+
   const bundler = await resolveBundler(config.bundler);
   const { buildLibraryProgram, buildPageProgram, clientProgram } =
     createProgramBuilder();
diff --git a/packages/react/src/html/utils/config.mjs b/packages/react/src/html/utils/config.mjs
index 512cbdbf..594b73e7 100644
--- a/packages/react/src/html/utils/config.mjs
+++ b/packages/react/src/html/utils/config.mjs
@@ -223,3 +223,20 @@ export default function createConfigSource(input, server = false) {
 
   return lines.join('\n');
 }
+
+/**
+ * Creates the virtual imports for one bundle target.
+ *
+ * Only the thread bundling the site needs this module: it loads Shiki (see
+ * `buildLanguageDisplayNameMap`) and a Markdown processor of its own, which
+ * the threads rendering pages have no use for.
+ *
+ * @param {Array} datas - Per-page metadata
+ * @param {Record} virtualImports
+ * @param {boolean} server
+ * @returns {Record}
+ */
+export const createVirtualImports = (datas, virtualImports, server) => ({
+  ...virtualImports,
+  '#theme/config': createConfigSource(datas, server),
+});
diff --git a/packages/react/src/html/utils/processing.mjs b/packages/react/src/html/utils/processing.mjs
index b18a1d61..4dc60378 100644
--- a/packages/react/src/html/utils/processing.mjs
+++ b/packages/react/src/html/utils/processing.mjs
@@ -3,22 +3,8 @@ import { populate } from '@doc-kit/core/utils/configuration/templates.mjs';
 
 import { ROUTER_DATA_ATTRIBUTE } from '../ui/constants.mjs';
 import { THEME_SCRIPT } from '../ui/theme-script.mjs';
-import createConfigSource from './config.mjs';
 import { relativeOrAbsolute } from './relativeOrAbsolute.mjs';
 
-/**
- * Creates the virtual imports for one bundle target.
- *
- * @param {Array} datas - Per-page metadata
- * @param {Record} virtualImports
- * @param {boolean} server
- * @returns {Record}
- */
-export const createVirtualImports = (datas, virtualImports, server) => ({
-  ...virtualImports,
-  '#theme/config': createConfigSource(datas, server),
-});
-
 /**
  * Populates a template string by evaluating it as a JavaScript template literal,
  * allowing full JS expression syntax (e.g., ${if ...}, ${JSON.stringify(...)}).

From 832ac6519afd439206c83321a8cb5dd15b2809e3 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Thu, 8 Oct 2026 12:56:31 +0200
Subject: [PATCH 06/14] docs(core): describe the thread ceiling without Shiki's
 grammars

A worker that highlights code no longer registers every grammar Shiki
bundles, only those of the code it highlighted. The comment now says
what holds for every generator: each worker has a heap of its own, with
the libraries and the pages it is working on.

Assisted-by: Claude Opus 5.5 
---
 packages/core/src/utils/configuration/constants.mjs | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/packages/core/src/utils/configuration/constants.mjs b/packages/core/src/utils/configuration/constants.mjs
index 1af80ccd..393cd9d7 100644
--- a/packages/core/src/utils/configuration/constants.mjs
+++ b/packages/core/src/utils/configuration/constants.mjs
@@ -1,9 +1,9 @@
 'use strict';
 
 /**
- * The default `threads` ceiling. Each worker that highlights code holds Shiki's
- * grammars and regex engine (~300MB) on top of the pages it is building, so
- * past a few threads memory, not CPU, is what runs out. `--threads` raises it.
+ * The default `threads` ceiling. Each worker holds a heap of its own, with the
+ * libraries and the pages it is working on, so past a few threads memory, not
+ * CPU, is what runs out. `--threads` raises it.
  */
 export const DEFAULT_MAX_THREADS = 4;
 

From d1786fdd48161e75b3a86816e77f459ee74ad4ad Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 13:41:03 +0200
Subject: [PATCH 07/14] perf(core): limit each worker's heap to 512MB

V8 lets a heap grow to several times its live data before collecting
it, the more the higher its limit: four times from 2GB up, and on a
machine with plenty of memory every worker's limit is 4GB, so each held
several times what it was using.

Each worker's old space is now limited to `WORKER_HEAP_SIZE` (512MB).
The biggest page of the Node.js docs, `all.html`, takes ~300MB. Node's
`--max-old-space-size` replaces the limit, to raise it, and it's what
limits the main thread, which runs the generators with one thread.

On Node core's build with 4 threads, the peak goes from 2.02GB to
1.67GB on Node 26, and from 2.44GB to 2.19GB on Node 24, for 3-5% more
time spent collecting garbage. The build passes with workers limited to
as little as ~350MB.

Assisted-by: Claude Opus 5.5 
Co-authored-by: Aviv Keller 
---
 docs/configuration.md                     |  3 ++-
 packages/core/src/threading/constants.mjs | 10 ++++++++++
 packages/core/src/threading/index.mjs     |  3 +++
 3 files changed, 15 insertions(+), 1 deletion(-)
 create mode 100644 packages/core/src/threading/constants.mjs

diff --git a/docs/configuration.md b/docs/configuration.md
index 1865a918..a34a2ddd 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -134,7 +134,8 @@ these for that generator alone, except `markdown`, which it adds to.
 Top-level, alongside `target` and `global`:
 
 - `threads` {number} Worker threads used for generation. Defaults to your
-  CPU count.
+  CPU count. Each worker's heap is limited to 512MB; to raise the limit, run
+  Node.js with `--max-old-space-size` (directly or through `NODE_OPTIONS`).
 - `chunkSize` {number} Items processed per worker thread. **Default:** `10`.
 
 ## Generator options
diff --git a/packages/core/src/threading/constants.mjs b/packages/core/src/threading/constants.mjs
new file mode 100644
index 00000000..82d46521
--- /dev/null
+++ b/packages/core/src/threading/constants.mjs
@@ -0,0 +1,10 @@
+'use strict';
+
+/**
+ * The heap size limit of each worker thread (V8's old space), in MB. V8 lets a
+ * heap grow to several times its live data before collecting it, the more the
+ * higher its limit, so every worker held several times what it was using. The
+ * biggest page of the Node.js docs, `all.html`, takes ~300MB. Node.js's
+ * `--max-old-space-size` replaces it.
+ */
+export const WORKER_HEAP_SIZE = 512;
diff --git a/packages/core/src/threading/index.mjs b/packages/core/src/threading/index.mjs
index 2cd4c9a1..503ae6b9 100644
--- a/packages/core/src/threading/index.mjs
+++ b/packages/core/src/threading/index.mjs
@@ -2,6 +2,8 @@ import Piscina from 'piscina';
 
 import logger from '#logger/index.mjs';
 
+import { WORKER_HEAP_SIZE } from './constants.mjs';
+
 const poolLogger = logger.child('WorkerPool');
 
 const workerScript = import.meta.resolve('./chunk-worker.mjs');
@@ -23,6 +25,7 @@ export default function createWorkerPool(threads) {
     minThreads: 0,
     maxThreads: threads,
     idleTimeout: 1_000,
+    resourceLimits: { maxOldGenerationSizeMb: WORKER_HEAP_SIZE },
     workerData: { logLevel: logger.getLogLevel() },
   });
 }

From c375ebad720434841c50a6c5c4122e654619b8c1 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 15:18:07 +0200
Subject: [PATCH 08/14] perf(core): end idle workers after 250ms instead of a
 second

A worker only ended a second after its last task, so the `jsx-ast`
workers and their heaps (~800MB on the Node.js docs) were still there
while the main thread bundled the site, which is when the build peaked.
They now end a quarter of a second after running out of work, soon
enough to be gone while the site is bundled.

On Node core's build with 4 threads on Node 26, the peak goes from
~2.31GB to ~1.91GB. Workers also end in some of the gaps between
generators, so the build starts 10 or 11 workers rather than 8, which
costs ~0.3s.

Assisted-by: Claude Opus 5.5 
---
 packages/core/src/threading/index.mjs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/packages/core/src/threading/index.mjs b/packages/core/src/threading/index.mjs
index 503ae6b9..d8dcc937 100644
--- a/packages/core/src/threading/index.mjs
+++ b/packages/core/src/threading/index.mjs
@@ -24,7 +24,9 @@ export default function createWorkerPool(threads) {
     filename: workerScript,
     minThreads: 0,
     maxThreads: threads,
-    idleTimeout: 1_000,
+    // A worker idle for 250ms ends, so its heap is gone during a long stretch
+    // of work on the main thread, such as bundling the site
+    idleTimeout: 250,
     resourceLimits: { maxOldGenerationSizeMb: WORKER_HEAP_SIZE },
     workerData: { logLevel: logger.getLogLevel() },
   });

From e4b6d2605f5387e9f6f978afc522a6984b3eb290 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sat, 10 Oct 2026 15:43:07 +0200
Subject: [PATCH 09/14] perf(react): highlight each type once per thread

Of the ~20,000 types the Node.js docs highlight, 741 are different:
`{string}` alone is on most pages. Each thread now keeps the markup of
the types it highlighted, by highlighter, so a type it has seen is only
embedded again, saving ~4% of the build's time and ~5% of its CPU.

Assisted-by: Claude Opus 5.5 
---
 packages/react/src/jsx-ast/index.mjs          |  5 +-
 .../plugins/__tests__/static-markup.test.mjs  | 52 ++++++++++++-
 .../src/jsx-ast/plugins/static-markup.mjs     | 74 +++++++++++++------
 3 files changed, 103 insertions(+), 28 deletions(-)

diff --git a/packages/react/src/jsx-ast/index.mjs b/packages/react/src/jsx-ast/index.mjs
index 23362b95..59a9ff29 100644
--- a/packages/react/src/jsx-ast/index.mjs
+++ b/packages/react/src/jsx-ast/index.mjs
@@ -1,7 +1,6 @@
 'use strict';
 
 import { getHighlighter } from '@doc-kit/core/plugins/shiki/highlighter.mjs';
-import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
 
 import { AST_NODE_TYPES } from './constants.mjs';
 import { generate, processChunk } from './generate.mjs';
@@ -44,8 +43,8 @@ export default {
           // Types are highlighted, with their links embedded, as static
           // markup (see `./plugins/static-markup.mjs`)
           handlers: {
-            typeAnnotation: embedHighlightedTypes(
-              createTypeAnnotationHandler(() => getHighlighter('jsx-ast'))
+            typeAnnotation: embedHighlightedTypes(() =>
+              getHighlighter('jsx-ast')
             ),
           },
         },
diff --git a/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs b/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs
index 2a927e26..efec1dc0 100644
--- a/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs
+++ b/packages/react/src/jsx-ast/plugins/__tests__/static-markup.test.mjs
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
 import { describe, it } from 'node:test';
 
 import { createHighlighter } from '@doc-kit/core/plugins/shiki/highlighter.mjs';
-import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
 import { toString } from 'hast-util-to-string';
 import rehypeRaw from 'rehype-raw';
 import { unified } from 'unified';
@@ -13,9 +12,7 @@ import rehypeStaticMarkup, {
 
 const highlighter = await createHighlighter();
 
-const embedTypes = embedHighlightedTypes(
-  createTypeAnnotationHandler(() => highlighter)
-);
+const embedTypes = embedHighlightedTypes(() => highlighter);
 
 // A minimal mdast-util-to-hast state: the handlers only use patch/applyData
 const state = { patch: () => {}, applyData: (_, result) => result };
@@ -133,6 +130,53 @@ describe('embedHighlightedTypes', () => {
     assert.equal(textContent(html), 'Promise');
   });
 
+  it('highlights the same type once for each highlighter', async () => {
+    let calls = 0;
+
+    // The highlighter, counting what its Shiki instance highlights
+    const counting = {
+      resolveLanguage: highlighter.resolveLanguage,
+      get shiki() {
+        const { shiki } = highlighter;
+
+        return {
+          ...shiki,
+          codeToHast: (...args) => {
+            calls++;
+
+            return shiki.codeToHast(...args);
+          },
+        };
+      },
+    };
+
+    const themed = await createHighlighter({
+      themes: { light: 'github-light', dark: 'github-dark' },
+    });
+
+    let current = counting;
+
+    const embed = embedHighlightedTypes(() => current);
+
+    const promise = () =>
+      makeNode('Promise', {
+        typescript: true,
+        links: [{ start: 0, end: 7, text: 'Promise', href: 'mdn.io/promise' }],
+      });
+
+    const first = embed(state, promise());
+    const second = embed(state, promise());
+
+    assert.equal(calls, 1);
+    // Each type is a node of its own, with the same markup
+    assert.notEqual(second, first);
+    assert.equal(innerHTML(second), innerHTML(first));
+
+    current = themed;
+
+    assert.notEqual(innerHTML(embed(state, promise())), innerHTML(first));
+  });
+
   it('leaves a type that was not highlighted as it is', () => {
     const element = embedTypes(state, makeNode('Whatever', { links: [] }));
 
diff --git a/packages/react/src/jsx-ast/plugins/static-markup.mjs b/packages/react/src/jsx-ast/plugins/static-markup.mjs
index 4ec935ff..131428c3 100644
--- a/packages/react/src/jsx-ast/plugins/static-markup.mjs
+++ b/packages/react/src/jsx-ast/plugins/static-markup.mjs
@@ -1,5 +1,6 @@
 'use strict';
 
+import { createTypeAnnotationHandler } from '@doc-kit/core/plugins/type-annotations/highlighter.mjs';
 import { toHtml } from 'hast-util-to-html';
 import { SKIP, visit } from 'unist-util-visit';
 
@@ -20,29 +21,39 @@ import { getClassNames } from '../utils/hast.mjs';
 const isHighlighted = element => getClassNames(element).includes('shiki');
 
 /**
- * Turns the `` of highlighted code into a JSX `` holding its
- * markup as it is.
+ * The attributes and the markup of the `` of highlighted code.
  *
  * @param {import('hast').Element} code - The ``
- * @param {boolean} [inline] - Whether it's a type's, rather than a block's
+ * @returns {{ attributes: Record, html: string }}
  */
-const embedCode = (code, inline = false) => {
+const toMarkup = code => {
   const classNames = getClassNames(code);
 
   // JSX takes the class names as one `className`, which comes first, as
   // `class` did
   const attributes = {
-    inline,
     className: classNames.length > 0 ? classNames.join(' ') : undefined,
     ...code.properties,
-    dangerouslySetInnerHTML: { __html: toHtml(code.children) },
   };
 
   delete attributes.class;
 
-  return createJSXElement('code', attributes);
+  return { attributes, html: toHtml(code.children) };
 };
 
+/**
+ * A JSX `` holding highlighted markup as it is.
+ *
+ * @param {ReturnType} markup
+ * @param {boolean} [inline] - Whether it's a type's, rather than a block's
+ */
+const createCodeElement = ({ attributes, html }, inline = false) =>
+  createJSXElement('code', {
+    inline,
+    ...attributes,
+    dangerouslySetInnerHTML: { __html: html },
+  });
+
 /**
  * Embeds the code blocks of a tree that Shiki highlighted.
  *
@@ -56,7 +67,7 @@ export const embedHighlightedBlocks = tree => {
 
     if (node.tagName === 'pre' && code?.tagName === 'code') {
       if (isHighlighted(node)) {
-        node.children[0] = embedCode(code);
+        node.children[0] = createCodeElement(toMarkup(code));
       }
 
       return SKIP;
@@ -67,25 +78,46 @@ export const embedHighlightedBlocks = tree => {
 };
 
 /**
- * Wraps a `typeAnnotation` handler of `remark-rehype`, embedding the types it
- * highlights.
+ * Creates the `typeAnnotation` handler of `remark-rehype` highlighting types
+ * and embedding them.
+ *
+ * Every highlighted type is kept, by highlighter: the same few hundred types
+ * are highlighted thousands of times, `{string}` alone on most pages.
  *
- * @param {(state: import('mdast-util-to-hast').State, node: import('mdast').Node) => import('hast').Element} handler
- * @returns {typeof handler}
+ * @param {() => import('@doc-kit/core/plugins/shiki/highlighter.mjs').SyntaxHighlighter} getHighlighter - Gives the highlighter, once a type is highlighted
+ * @returns {(state: import('mdast-util-to-hast').State, node: import('mdast').Node) => import('hast').ElementContent}
  */
-export const embedHighlightedTypes = handler => (state, node) => {
-  const result = handler(state, node);
+export const embedHighlightedTypes = getHighlighter => {
+  const highlight = createTypeAnnotationHandler(getHighlighter);
+  const highlighted = new WeakMap();
 
-  // A type that didn't parse, or links nowhere, isn't highlighted
-  if (!isHighlighted(result)) {
-    return result;
-  }
+  return (state, node) => {
+    const highlighter = getHighlighter();
 
-  const code = embedCode(result, true);
+    if (!highlighted.has(highlighter)) {
+      highlighted.set(highlighter, new Map());
+    }
+
+    const types = highlighted.get(highlighter);
+    const key = JSON.stringify([node.value, node.data]);
 
-  state.patch(node, code);
+    if (!types.has(key)) {
+      const result = highlight(state, node);
 
-  return code;
+      // A type that didn't parse, or links nowhere, isn't highlighted
+      if (!isHighlighted(result)) {
+        return result;
+      }
+
+      types.set(key, toMarkup(result));
+    }
+
+    const code = createCodeElement(types.get(key), true);
+
+    state.patch(node, code);
+
+    return code;
+  };
 };
 
 /**

From 2d97707f1bbd367cd1721077788b120d670bbc6a Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Sun, 11 Oct 2026 21:42:47 +0200
Subject: [PATCH 10/14] perf(core): write GitHub Actions commands without
 `@actions/core`

Every thread loads the logger, and with it each transport: the GitHub
one imported `@actions/core`, and through it `undici`, for the four
functions printing workflow commands. That was 237 modules, 19MB and
~80ms on every thread, whichever transport is used. The transport now
writes the commands itself, escaped as `@actions/core` does, for the
same output: 10 modules, 3MB and 7ms.

Assisted-by: Claude Opus 5.5 
---
 packages/core/package.json                    |  1 -
 .../__tests__/transports/github.test.mjs      | 37 +++++++++++
 packages/core/src/logger/constants.mjs        | 35 +++++++++++
 .../core/src/logger/transports/github.mjs     | 63 ++++++++++++++-----
 pnpm-lock.yaml                                |  3 -
 5 files changed, 120 insertions(+), 19 deletions(-)

diff --git a/packages/core/package.json b/packages/core/package.json
index 6516f69e..f5f1254e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -52,7 +52,6 @@
     "README.md"
   ],
   "dependencies": {
-    "@actions/core": "^3.0.0",
     "@node-core/rehype-shiki": "^1.5.1",
     "@swc/html-wasm": "^1.16.2",
     "@swc/wasm": "^1.15.46",
diff --git a/packages/core/src/logger/__tests__/transports/github.test.mjs b/packages/core/src/logger/__tests__/transports/github.test.mjs
index ed7d7dfa..2f881500 100644
--- a/packages/core/src/logger/__tests__/transports/github.test.mjs
+++ b/packages/core/src/logger/__tests__/transports/github.test.mjs
@@ -148,6 +148,43 @@ describe('github', () => {
     );
   });
 
+  it('should escape messages and files', t => {
+    process.env.FORCE_COLOR = '1';
+
+    t.mock.timers.enable({ apis: ['Date'] });
+
+    const fn = t.mock.method(process.stdout, 'write');
+
+    // noop
+    fn.mock.mockImplementation(() => {});
+
+    github({
+      level: LogLevel.info,
+      message: '100%: a, b\r\nc',
+      metadata: {
+        file: {
+          path: 'a,b:c%.md',
+          position: {
+            start: { line: 1 },
+            end: { line: 2 },
+          },
+        },
+      },
+      timestamp: Date.now(),
+    });
+
+    const callsArgs = process.stdout.write.mock.calls.map(
+      call => call.arguments[0]
+    );
+
+    strictEqual(process.stdout.write.mock.callCount(), 1);
+    // The message keeps its `,` and `:`, which only separate properties
+    strictEqual(
+      callsArgs[0].trim(),
+      '::notice file=a%2Cb%3Ac%25.md,line=1,endLine=2::[00:00:00.000] \x1B[32mINFO\x1B[39m: 100%25: a, b%0D%0Ac'
+    );
+  });
+
   it('should print child logger name', t => {
     process.env.FORCE_COLOR = '1';
 
diff --git a/packages/core/src/logger/constants.mjs b/packages/core/src/logger/constants.mjs
index 07683124..8631f212 100644
--- a/packages/core/src/logger/constants.mjs
+++ b/packages/core/src/logger/constants.mjs
@@ -32,3 +32,38 @@ export const levelToColorMap = {
   [LogLevel.error]: 'magenta',
   [LogLevel.fatal]: 'red',
 };
+
+/**
+ * Maps log level numbers to the GitHub Actions workflow commands reporting
+ * them.
+ */
+export const levelToGitHubCommand = {
+  [LogLevel.debug]: 'debug',
+  [LogLevel.info]: 'notice',
+  [LogLevel.warn]: 'warning',
+  [LogLevel.error]: 'error',
+  [LogLevel.fatal]: 'error',
+};
+
+/**
+ * Maps the characters a workflow command's message can't hold to their
+ * escapes. A command is a line, `::command key=value,key=value::message`,
+ * so its message, which runs to the end of the line, can't break it, nor hold
+ * the `%` an escape starts with.
+ */
+export const commandDataEscapes = {
+  '%': '%25',
+  '\r': '%0D',
+  '\n': '%0A',
+};
+
+/**
+ * Maps the characters a workflow command's property can't hold to their
+ * escapes: those its message can't, and the `,` separating properties and
+ * the `:` of the `::` ending them.
+ */
+export const commandPropertyEscapes = {
+  ...commandDataEscapes,
+  ':': '%3A',
+  ',': '%2C',
+};
diff --git a/packages/core/src/logger/transports/github.mjs b/packages/core/src/logger/transports/github.mjs
index 673146cc..11b190dd 100644
--- a/packages/core/src/logger/transports/github.mjs
+++ b/packages/core/src/logger/transports/github.mjs
@@ -1,23 +1,35 @@
 'use strict';
 
-import { debug, notice, warning, error } from '@actions/core';
+import { EOL } from 'node:os';
 
-import { LogLevel } from '#logger/constants.mjs';
+import {
+  commandDataEscapes,
+  commandPropertyEscapes,
+  levelToGitHubCommand,
+} from '#logger/constants.mjs';
 import { prettifyLevel } from '#logger/utils/colors.mjs';
 import { prettifyTimestamp } from '#logger/utils/time.mjs';
 
-const actions = {
-  [LogLevel.debug]: debug,
-  [LogLevel.info]: notice,
-  [LogLevel.warn]: warning,
-  [LogLevel.error]: error,
-  [LogLevel.fatal]: error,
-};
+/**
+ * Escapes a value of a workflow command: its message, with
+ * `commandDataEscapes`, or one of its properties, with
+ * `commandPropertyEscapes`.
+ *
+ * @param {string | number} value - The value
+ * @param {Record} escapes - The escapes of the characters it can't hold
+ * @returns {string}
+ */
+const escapeCommandValue = (value, escapes) =>
+  String(value).replace(
+    /[%\r\n:,]/g,
+    character => escapes[character] ?? character
+  );
 
 /**
- * Logs messages to GitHub Actions with formatted output and file info with
- * appropriate gh actions based on level.
+ * Logs messages to GitHub Actions as workflow commands, with the file and
+ * lines they're about, for them to annotate.
  *
+ * @see https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands
  * @param {import('../types').TransportContext} context
  * @returns {void}
  */
@@ -30,13 +42,34 @@ const github = ({ level, message, timestamp, metadata = {}, module }) => {
 
   const logMessage = `[${time}] ${prettyLevel}${module ? ` (${module})` : ''}: ${message}`;
 
-  const logFn = actions[level] ?? notice;
+  const command = levelToGitHubCommand[level] ?? 'notice';
+
+  const data = escapeCommandValue(logMessage, commandDataEscapes);
 
-  logFn(logMessage, {
+  // Debug messages take no properties
+  if (command === 'debug') {
+    process.stdout.write(`::debug::${data}${EOL}`);
+
+    return;
+  }
+
+  const properties = {
     file: file?.path,
-    startLine: file?.position?.start.line,
+    line: file?.position?.start.line,
     endLine: file?.position?.end.line,
-  });
+  };
+
+  const annotations = [];
+
+  for (const [key, value] of Object.entries(properties)) {
+    if (value) {
+      annotations.push(
+        `${key}=${escapeCommandValue(value, commandPropertyEscapes)}`
+      );
+    }
+  }
+
+  process.stdout.write(`::${command} ${annotations.join(',')}::${data}${EOL}`);
 };
 
 export default github;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 749b77f9..15bb6e8b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -71,9 +71,6 @@ importers:
 
   packages/core:
     dependencies:
-      '@actions/core':
-        specifier: ^3.0.0
-        version: 3.0.1
       '@doc-kit/generator-react':
         specifier: workspace:>=0.1.0
         version: link:../react

From 26ad7b7a9635113559f775b7f16422a279666c45 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Mon, 12 Oct 2026 02:28:34 +0200
Subject: [PATCH 11/14] perf(react): let go of the bundler before the pages
 render

`generate` bundled the site and compiled the pages' programs in its own
scope, so everything it set up for that, the bundler, the program
builder and the compile function, stayed reachable while the pages
rendered. With one thread, the main thread renders them all, and held
it all the while.

The bundling and the compiling move to `buildPrograms`, which returns
the pages' tasks for the pool, so all of it is let go once it returns.

On Node core's build with one thread on Node 26, the peak goes from
~2.66GB to ~2.45GB (medians of 8 runs). With four threads, where the
workers render the pages, it doesn't change.

Assisted-by: Claude Opus 5.5 
---
 packages/react/src/html/generate.mjs | 169 +++++++++++++++------------
 1 file changed, 97 insertions(+), 72 deletions(-)

diff --git a/packages/react/src/html/generate.mjs b/packages/react/src/html/generate.mjs
index 796c0616..4f01f80e 100644
--- a/packages/react/src/html/generate.mjs
+++ b/packages/react/src/html/generate.mjs
@@ -42,94 +42,119 @@ export async function generate(input, worker) {
   const pages = [...input];
   const all = config.generateAllPage ? buildAllPage(pages) : undefined;
 
-  // Every page's metadata, in render order — the sidebar, the index and the
-  // cross links need the whole set.
-  const datas = [...pages, ...(all ? [all] : [])].map(({ data }) => data);
-
-  // Loaded here rather than with the generator, so the threads rendering
-  // pages, which load this module too, never load what only this needs
-  const { createVirtualImports } = await import('./utils/config.mjs');
-
-  const bundler = await resolveBundler(config.bundler);
-  const { buildLibraryProgram, buildPageProgram, clientProgram } =
-    createProgramBuilder();
-
   // The built library and the compiled page programs live here until every
   // page is written; the directory is removed afterwards
   const outDir = await mkdtemp(join(tmpdir(), 'doc-kit-html-'));
 
   try {
-    const libraryURL = await bundler.buildServer({
-      entry: buildLibraryProgram(),
-      virtualImports: createVirtualImports(datas, config.virtualImports, true),
+    const { assets, tasks } = await buildPrograms({
+      pages,
+      all,
       outDir,
       config,
     });
 
-    htmlLogger.debug('Built the component library');
+    await createPageWriter(worker)(tasks, { template, assets });
+  } finally {
+    await rm(outDir, { recursive: true, force: true });
+  }
 
-    const assets = await bundler.buildClient({
-      entry: clientProgram,
-      virtualImports: createVirtualImports(datas, config.virtualImports, false),
-      config,
-    });
+  await copyStaticAssets(config);
+}
 
-    htmlLogger.debug('Built the client assets', assets);
-
-    const modulesDir = join(outDir, 'pages');
-    await mkdir(modulesDir);
-
-    /**
-     * Compiles a page's program to disk and describes it for the workers.
-     *
-     * @param {import('./types').Page} page
-     * @returns {Promise}
-     */
-    const compile = async page => {
-      const file = join(modulesDir, moduleFileName(page.data.api));
-
-      await writeFile(
-        file,
-        await bundler.compile(
-          buildPageProgram(page, libraryURL),
-          `${page.data.api}.jsx`
-        )
-      );
-
-      const { data, headings, readingTime } = page;
-
-      return {
-        moduleURL: pathToFileURL(file).href,
-        data,
-        headings,
-        readingTime,
-      };
-    };
+/**
+ * Bundles the component library and the client assets, and compiles every
+ * page's program into `outDir`, for the worker pool to render.
+ *
+ * Everything it sets up for that, the bundler included, is let go once it
+ * returns: with one thread, the pages are rendered on this thread next.
+ *
+ * @param {object} options
+ * @param {Array} options.pages - Every page, in render order
+ * @param {import('./types').ComposedPage} [options.all] - `all.html`, when enabled
+ * @param {string} options.outDir - Where the library and the programs are written
+ * @param {import('./types').ResolvedWebConfiguration} options.config
+ * @returns {Promise<{ assets: import('./types').ClientAssets, tasks: Array }>}
+ */
+const buildPrograms = async ({ pages, all, outDir, config }) => {
+  // Every page's metadata, in render order — the sidebar, the index and the
+  // cross links need the whole set.
+  const datas = [...pages, ...(all ? [all] : [])].map(({ data }) => data);
 
-    const tasks = [];
+  // Loaded here rather than with the generator, so the threads rendering
+  // pages, which load this module too, never load what only this needs
+  const { createVirtualImports } = await import('./utils/config.mjs');
 
-    for (const page of pages) {
-      tasks.push(await compile(page));
-    }
+  const bundler = await resolveBundler(config.bundler);
+  const { buildLibraryProgram, buildPageProgram, clientProgram } =
+    createProgramBuilder();
 
-    // The composed page imports the other pages' compiled programs, which
-    // exist from here on, so it is rendered by the worker pool alongside them.
-    // Rendering it on this thread instead would hold the whole site in it and
-    // block the pool from shutting down its idle workers until it is done.
-    // It goes first: it takes by far the longest, so the other pages are
-    // rendered while it is.
-    if (all) {
-      const allTask = await compile(all);
+  const libraryURL = await bundler.buildServer({
+    entry: buildLibraryProgram(),
+    virtualImports: createVirtualImports(datas, config.virtualImports, true),
+    outDir,
+    config,
+  });
+
+  htmlLogger.debug('Built the component library');
+
+  const assets = await bundler.buildClient({
+    entry: clientProgram,
+    virtualImports: createVirtualImports(datas, config.virtualImports, false),
+    config,
+  });
+
+  htmlLogger.debug('Built the client assets', assets);
+
+  const modulesDir = join(outDir, 'pages');
+  await mkdir(modulesDir);
+
+  /**
+   * Compiles a page's program to disk and describes it for the workers.
+   *
+   * @param {import('./types').Page} page
+   * @returns {Promise}
+   */
+  const compile = async page => {
+    const file = join(modulesDir, moduleFileName(page.data.api));
+
+    await writeFile(
+      file,
+      await bundler.compile(
+        buildPageProgram(page, libraryURL),
+        `${page.data.api}.jsx`
+      )
+    );
+
+    const { data, headings, readingTime } = page;
+
+    return {
+      moduleURL: pathToFileURL(file).href,
+      data,
+      headings,
+      readingTime,
+    };
+  };
 
-      tasks.unshift(allTask);
-    }
+  const tasks = [];
 
-    htmlLogger.debug(`Compiled ${tasks.length} page programs`);
+  for (const page of pages) {
+    tasks.push(await compile(page));
+  }
 
-    await createPageWriter(worker)(tasks, { template, assets });
-  } finally {
-    await rm(outDir, { recursive: true, force: true });
+  // The composed page imports the other pages' compiled programs, which
+  // exist from here on, so it is rendered by the worker pool alongside them.
+  // Rendering it on this thread instead would hold the whole site in it and
+  // block the pool from shutting down its idle workers until it is done.
+  // It goes first: it takes by far the longest, so the other pages are
+  // rendered while it is.
+  if (all) {
+    const allTask = await compile(all);
+
+    tasks.unshift(allTask);
   }
 
-  await copyStaticAssets(config);
-}
+  htmlLogger.debug(`Compiled ${tasks.length} page programs`);
+
+  return { assets, tasks };
+};

From 5d9cbb01c96fd7441c46c8d24b30fa1c20598bc9 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Mon, 12 Oct 2026 02:29:04 +0200
Subject: [PATCH 12/14] chore(legacy-html): mark its Shiki plugin as deprecated

It will be removed soon.

Assisted-by: Claude Opus 5.5 
---
 packages/node-legacy/src/legacy-html/plugins/shiki.mjs | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/packages/node-legacy/src/legacy-html/plugins/shiki.mjs b/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
index 39b628f7..90b559cc 100644
--- a/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
+++ b/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
@@ -39,6 +39,8 @@ function isCodeBlock(node) {
 
 /**
  * Creates a HAST transformer for Shiki which is used for transforming our codeboxes
+ *
+ * @deprecated This plugin is deprecated and will be removed soon.
  */
 export default function rehypeShikiji() {
   /**

From 8a0cc35586d49f84c3210f607e872b4418b5eab3 Mon Sep 17 00:00:00 2001
From: Claudio Wunder 
Date: Mon, 12 Oct 2026 03:43:33 +0200
Subject: [PATCH 13/14] perf: don't minify HTML by default

The HTML generators minified every page with SWC's HTML minifier,
compiled to WASM. Its memory grows to about twelve times the biggest
page it is given, and is never returned: each thread rendering pages
kept it until it ended, and on Node.js 24, the worker that minified the
Node.js docs' 33MB `all.html` collected garbage on nearly every
allocation from then on, as V8 found that memory too large for its
heap limit. Minifying also took a fifth of the build's CPU.

For that, the Node.js docs' pages were 5.9% smaller, but only 3.2%
once gzipped and 2.1% with brotli, which is how hosts serve them:
Cloudflare, Vercel, Netlify and GitHub Pages compress HTML by default.
So `html`, `legacy-html` and `legacy-html-all` now default to
`minify: false`, loading the minifier only when it is enabled, and the
publishing docs explain how to serve the pages compressed. The `html`
generator's scripts and styles stay minified, unless
`vite.build.minify` says otherwise.

On Node core's build with 4 threads on Node.js 26, the build takes 7.7s
instead of 10.5s and peaks at ~1.68GB instead of ~2.12GB; with one
thread, 15.2s instead of 22.5s. On Node.js 24, 7.9s instead of 51.7s.

Assisted-by: Claude Opus 5.5 
---
 docs/configuration.md                         |  5 +-
 docs/publishing.md                            | 19 +++++++
 .../node-legacy/src/legacy-html-all/README.md |  3 +-
 .../node-legacy/src/legacy-html-all/index.mjs |  1 +
 .../node-legacy/src/legacy-html/README.md     |  3 +-
 .../node-legacy/src/legacy-html/index.mjs     |  1 +
 packages/react/src/html/README.md             |  2 +
 .../src/html/__tests__/generate.test.mjs      | 54 +++++++++----------
 packages/react/src/html/bundlers/vite.mjs     |  7 +--
 packages/react/src/html/index.mjs             |  1 +
 packages/react/src/html/utils/render.mjs      |  5 +-
 11 files changed, 65 insertions(+), 36 deletions(-)

diff --git a/docs/configuration.md b/docs/configuration.md
index a34a2ddd..95fac12c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -109,7 +109,10 @@ Everything under the `global` key applies to every generator:
   logos, and templated text. Defaults to the `name` in your `package.json`.
 - `version` {string|SemVer} Documentation version. **Default:**
   `process.version`.
-- `minify` {boolean} Whether to minify output. **Default:** `true`.
+- `minify` {boolean} Whether to minify output. **Default:** `true`, except
+  for the generators writing HTML (`html`, `legacy-html`, and
+  `legacy-html-all`), which only minify it when their own `minify` is set (see
+  [Compress the pages](./publishing.md#compress-the-pages)).
 - `repository` {string} GitHub repository in `owner/repo` format; without
   one, repository UI is omitted.
 - `ref` {string} Git reference (branch, tag, or commit SHA). **Default:**
diff --git a/docs/publishing.md b/docs/publishing.md
index 5ab4f323..fdc344b5 100644
--- a/docs/publishing.md
+++ b/docs/publishing.md
@@ -56,6 +56,25 @@ costs a request per asset each time a new tab opens the site. On Vercel:
 Within a visit, moving between pages loads no assets at all: the site
 navigates client-side, swapping in the next page's content.
 
+## Compress the pages
+
+doc-kit doesn't minify HTML by default: served compressed (gzip or brotli), as
+hosts do, pages shrink to a fraction of their size, which minifying them would
+barely improve on. To minify it anyway, set `minify: true` on the generator
+writing it, such as `html: { minify: true }`.
+
+Cloudflare, Vercel, Netlify, and GitHub Pages compress HTML on their own, with
+nothing to configure, and so does `npx serve out`. If you serve the output
+yourself, turn compression on:
+
+- **nginx:** `gzip on;` compresses HTML; add the other types with
+  `gzip_types text/css application/javascript application/json;`.
+- **Caddy:** `encode zstd gzip`.
+- **Apache:** with `mod_deflate`,
+  `AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json`.
+- **Amazon S3:** serve the bucket through CloudFront, with **Compress objects
+  automatically** on.
+
 ## Tell doc-kit its public URL
 
 Set `baseURL` to where the site will live. Generators that emit absolute
diff --git a/packages/node-legacy/src/legacy-html-all/README.md b/packages/node-legacy/src/legacy-html-all/README.md
index d854ea1e..264620d2 100644
--- a/packages/node-legacy/src/legacy-html-all/README.md
+++ b/packages/node-legacy/src/legacy-html-all/README.md
@@ -7,7 +7,6 @@ The `legacy-html-all` generator creates a single `all.html` file containing all
 - `output` {string} The directory where `all.html` will be written.
 - `templatePath` {string} Path to the HTML template file. Inherited from
   `legacy-html`. **Default:** `'template.html'`.
-- `minify` {boolean} Whether to minify the output HTML. Inherited from
-  `global`. **Default:** `true`.
+- `minify` {boolean} Whether to minify the output HTML. **Default:** `false`.
 - `version` {Object} Version object containing version information. Inherited
   from `global`. **Default:** `process.version`.
diff --git a/packages/node-legacy/src/legacy-html-all/index.mjs b/packages/node-legacy/src/legacy-html-all/index.mjs
index b14b5e16..28e0296e 100644
--- a/packages/node-legacy/src/legacy-html-all/index.mjs
+++ b/packages/node-legacy/src/legacy-html-all/index.mjs
@@ -22,6 +22,7 @@ export default {
 
   defaultConfiguration: {
     templatePath: legacyHtml.defaultConfiguration.templatePath,
+    minify: false,
   },
 
   generate,
diff --git a/packages/node-legacy/src/legacy-html/README.md b/packages/node-legacy/src/legacy-html/README.md
index da7a9552..05587d18 100644
--- a/packages/node-legacy/src/legacy-html/README.md
+++ b/packages/node-legacy/src/legacy-html/README.md
@@ -17,5 +17,4 @@ The `legacy-html` generator creates legacy HTML documentation pages for Node.js
   **Default:** `'${GITHUB_EDIT_URL}/doc/api{path}.md'`.
 - `index` {Array} Array of `{ api, section }` objects defining the
   documentation structure.
-- `minify` {boolean} Whether to minify the output HTML. Inherited from
-  `global`. **Default:** `true`.
+- `minify` {boolean} Whether to minify the output HTML. **Default:** `false`.
diff --git a/packages/node-legacy/src/legacy-html/index.mjs b/packages/node-legacy/src/legacy-html/index.mjs
index 1bcb28f6..a6b9b65c 100644
--- a/packages/node-legacy/src/legacy-html/index.mjs
+++ b/packages/node-legacy/src/legacy-html/index.mjs
@@ -31,6 +31,7 @@ export default {
     ref: 'main',
     pageURL: '{baseURL}/latest-{version}/api{path}.html',
     editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`,
+    minify: false,
   },
 
   hasParallelProcessor: true,
diff --git a/packages/react/src/html/README.md b/packages/react/src/html/README.md
index caa8610f..13e3890c 100644
--- a/packages/react/src/html/README.md
+++ b/packages/react/src/html/README.md
@@ -51,6 +51,8 @@ from the module pages' compiled content rather than built again from scratch.
   page's content on one page, in sidebar order, assembled from the module pages
   rather than built again. Chunk pages and the index are left out.
   **Default:** `true`.
+- `minify` {boolean} Whether to minify the pages' HTML. Scripts and styles are
+  minified either way, unless `vite.build.minify` is set. **Default:** `false`.
 - `bundler` {WebBundler} Adapter that bundles the component library and the
   client assets, and compiles page programs. See
   [Bundler adapters](#bundler-adapters). **Default:** `createViteBundler()`.
diff --git a/packages/react/src/html/__tests__/generate.test.mjs b/packages/react/src/html/__tests__/generate.test.mjs
index 4b453124..a2198925 100644
--- a/packages/react/src/html/__tests__/generate.test.mjs
+++ b/packages/react/src/html/__tests__/generate.test.mjs
@@ -102,21 +102,21 @@ describe('web generate', () => {
     ]);
 
     assert.match(fsHTML, /View As/);
-    assert.match(fsHTML, /href=fs\.json/);
-    assert.match(fsHTML, /href=fs\.md/);
+    assert.match(fsHTML, /href="fs\.json"/);
+    assert.match(fsHTML, /href="fs\.md"/);
     assert.doesNotMatch(notFoundHTML, /View As/);
     // Assets resolve from the page: relative for real pages, from the root
     // for synthetic ones, which are served at any path
-    assert.match(fsHTML, /src=\.\.\/assets\/client-[^ ]+\.js/);
-    assert.match(fsHTML, /href=\.\.\/assets\/[^ ]+\.css/);
-    assert.match(notFoundHTML, /src=\/assets\/client-[^ ]+\.js/);
-    assert.match(fsHTML, /on:idle[^>]*data-island-name=SearchBox/);
+    assert.match(fsHTML, /src="\.\.\/assets\/client-[^"]+\.js"/);
+    assert.match(fsHTML, /href="\.\.\/assets\/[^"]+\.css"/);
+    assert.match(notFoundHTML, /src="\/assets\/client-[^"]+\.js"/);
+    assert.match(fsHTML, /on:idle[^>]*data-island-name="SearchBox"/);
     // The manifest the asset tags were read from does not ship
     assert.equal((await readdir(output)).includes('.vite'), false);
 
     // Fonts are hashed like every other asset, and preloaded from where the
     // bundler actually wrote them
-    const fonts = [...fsHTML.matchAll(/href=\.\.\/(assets\/[^ ]+\.woff2)/g)];
+    const fonts = [...fsHTML.matchAll(/href="\.\.\/(assets\/[^"]+\.woff2)"/g)];
     const written = await readdir(join(output, 'assets'));
 
     assert.strictEqual(fonts.length, FONTS.length);
@@ -150,7 +150,7 @@ describe('web generate', () => {
     assert.match(html, /File system body[\s\S]*Zlib body/);
     assert.doesNotMatch(html, /Index body/);
     // Their tables of contents, concatenated
-    assert.match(html, /href=#fs[\s\S]*href=#zlib/);
+    assert.match(html, /href="#fs"[\s\S]*href="#zlib"/);
     assert.doesNotMatch(html, /View As/);
   });
 
@@ -209,30 +209,30 @@ describe('web generate', () => {
     ]);
 
     // Assets and module files resolve from the nested directory
-    assert.match(readFileHTML, /src=\.\.\/assets\//);
-    assert.match(readFileHTML, /href=\.\.\/fs\.json/);
-    assert.match(readFileHTML, /href=\.\.\/fs\.html#fsreadfile/);
+    assert.match(readFileHTML, /src="\.\.\/assets\//);
+    assert.match(readFileHTML, /href="\.\.\/fs\.json"/);
+    assert.match(readFileHTML, /href="\.\.\/fs\.html#fsreadfile"/);
 
     // The sidebar nests the module's sections, repeating the module itself
     assert.match(readFileHTML, /]*open/);
     assert.match(
       readFileHTML,
-      /href=\.\.\/fs\.html[^>]*>(<[^>]*>)*File system/
+      /href="\.\.\/fs\.html"[^>]*>(<[^>]*>)*File system/
     );
-    assert.match(readFileHTML, /href=callback-api\.html/);
+    assert.match(readFileHTML, /href="callback-api\.html"/);
 
     // Previous/next step through the module's sections
     assert.match(readFileHTML, /Callback API/);
-    assert.match(readFileHTML, /href=watch\.html/);
+    assert.match(readFileHTML, /href="watch\.html"/);
     assert.match(fsHTML, /Next/);
 
     // Links were re-targeted for the chunk page
-    assert.match(readFileHTML, /href=watch\.html#fswatch/);
-    assert.match(readFileHTML, /href=\.\.\/net\.html/);
+    assert.match(readFileHTML, /href="watch\.html#fswatch"/);
+    assert.match(readFileHTML, /href="\.\.\/net\.html"/);
 
     // The full page is untouched, and lists sections in its own sidebar
-    assert.match(fsHTML, /href=fs\.json/);
-    assert.match(fsHTML, /href=fs\/readFile\.html/);
+    assert.match(fsHTML, /href="fs\.json"/);
+    assert.match(fsHTML, /href="fs\/readFile\.html"/);
   });
 
   it('renders the configurable head without hardcoded defaults', async context => {
@@ -252,10 +252,10 @@ describe('web generate', () => {
 
     assert.match(html, /Custom project docs/);
     assert.match(html, /https:\/\/example\.com\/og\.png/);
-    assert.match(html, /href=https:\/\/example\.com\/favicon\.ico/);
-    assert.match(html, /content=#abcdef/);
+    assert.match(html, /href="https:\/\/example\.com\/favicon\.ico"/);
+    assert.match(html, /content="#abcdef"/);
     assert.doesNotMatch(html, /nodejs\.org/);
-    assert.match(html, /property=og:type content=website/);
+    assert.match(html, /property="og:type" content="website"/);
   });
 
   it('uses the base URL for absolute client assets', async context => {
@@ -268,8 +268,8 @@ describe('web generate', () => {
     await generate([toPage(content)]);
     const html = await readFile(join(output, '404.html'), 'utf8');
 
-    assert.match(html, /src=https:\/\/example\.com\/docs\/assets\//);
-    assert.match(html, /href=https:\/\/example\.com\/docs\/assets\//);
+    assert.match(html, /src="https:\/\/example\.com\/docs\/assets\//);
+    assert.match(html, /href="https:\/\/example\.com\/docs\/assets\//);
   });
 
   it('applies configured Vite plugins to the client build', async context => {
@@ -367,18 +367,18 @@ describe('web generate', () => {
     assert.match(html, /
fs:1<\/article>/); assert.match( html, - /