diff --git a/.changeset/build-memory.md b/.changeset/build-memory.md new file mode 100644 index 000000000..f19cebc64 --- /dev/null +++ b/.changeset/build-memory.md @@ -0,0 +1,7 @@ +--- +'@doc-kit/core': minor +'@doc-kit/generator-react': minor +'@node-core/doc-kit-legacy': minor +--- + +Builds take about half the memory and a third of the time (measured on the Node.js API docs): Shiki registers each language once code in it is highlighted, highlighted code reaches pages as static markup rather than a tree per token, `all.html` is rendered by the worker pool, output is no longer minified by default (serve it compressed instead), idle workers end after 250ms, and each worker's heap is limited to 512MB, which Node.js's `--max-old-space-size` raises. diff --git a/docs/configuration.md b/docs/configuration.md index 1865a918c..5901d3255 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,7 +109,7 @@ 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:** `false`. - `repository` {string} GitHub repository in `owner/repo` format; without one, repository UI is omitted. - `ref` {string} Git reference (branch, tag, or commit SHA). **Default:** @@ -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/docs/creating-generators.md b/docs/creating-generators.md index e81585586..c2912d335 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/docs/publishing.md b/docs/publishing.md index 5ab4f3233..359b95358 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -56,6 +56,22 @@ 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 its output by default: compressed (gzip or brotli), as +hosts serve it, it is far smaller than minifying would make it. + +Cloudflare, Vercel, Netlify, and GitHub Pages compress on their own, 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/core/package.json b/packages/core/package.json index 2f2035605..f5f1254ea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,8 +52,7 @@ "README.md" ], "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/generators/json-all/README.md b/packages/core/src/generators/json-all/README.md index dfacb652a..bb2aec09a 100644 --- a/packages/core/src/generators/json-all/README.md +++ b/packages/core/src/generators/json-all/README.md @@ -23,7 +23,7 @@ generator's schema for the documents. - `output` {string} The directory where `all.json` will be written. - `minify` {boolean} Whether to minify the output. Inherited from `global`. - **Default:** `true`. + **Default:** `false`. - `index` {Array} The `{ api }` objects defining the document order. Inherited from `global`. - `schemaURL` {string} Where the bundle's schema is published. diff --git a/packages/core/src/generators/json-simple/README.md b/packages/core/src/generators/json-simple/README.md index f052adcb9..4d1694dcc 100644 --- a/packages/core/src/generators/json-simple/README.md +++ b/packages/core/src/generators/json-simple/README.md @@ -6,4 +6,4 @@ The `json-simple` generator creates a simplified JSON version of the API documen - `output` {string} The directory where `api-docs.json` will be written. - `minify` {boolean} Whether to minify the output JSON. Inherited from - `global`. **Default:** `true`. + `global`. **Default:** `false`. diff --git a/packages/core/src/generators/json/README.md b/packages/core/src/generators/json/README.md index 0b09bc989..d42d872f3 100644 --- a/packages/core/src/generators/json/README.md +++ b/packages/core/src/generators/json/README.md @@ -18,7 +18,7 @@ Files keep the input's directory layout: `doc/api/some/fs.md` becomes `out/some/ - `output` {string} The directory to write the documents to. - `minify` {boolean} Whether to minify the output. Inherited from `global`. - **Default:** `true`. + **Default:** `false`. - `repository` {string} The `owner/name` repository source links resolve against. Inherited from `global`; without one, `sourceLink.url` is `null`. - `schemaURL` {string} Where the schema is published. `{schemaVersion}` is diff --git a/packages/core/src/generators/metadata/generate.mjs b/packages/core/src/generators/metadata/generate.mjs index b35fa134f..7e7619c60 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/core/src/logger/__tests__/transports/github.test.mjs b/packages/core/src/logger/__tests__/transports/github.test.mjs index ed7d7dfa8..2f881500e 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 07683124b..8631f212b 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 673146ccb..11b190dd2 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/packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs b/packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs index 91208b8e4..030f5d108 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 6ecdef8de..06dc0a582 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 f26f9b25c..9bdd2931c 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/core/src/threading/constants.mjs b/packages/core/src/threading/constants.mjs
new file mode 100644
index 000000000..82d465212
--- /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 2cd4c9a19..d8dcc9377 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');
@@ -22,7 +24,10 @@ 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() },
   });
 }
diff --git a/packages/core/src/utils/configuration/constants.mjs b/packages/core/src/utils/configuration/constants.mjs
index 1af80ccd0..393cd9d73 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;
 
diff --git a/packages/core/src/utils/configuration/index.mjs b/packages/core/src/utils/configuration/index.mjs
index 5ab508039..c36183180 100644
--- a/packages/core/src/utils/configuration/index.mjs
+++ b/packages/core/src/utils/configuration/index.mjs
@@ -62,7 +62,7 @@ export const getDefaultConfig = (generators, config) =>
       global: {
         project: detectProject() ?? 'API Docs',
         version: process.version,
-        minify: true,
+        minify: false,
         ref: 'HEAD',
         // Without release history there is nothing to build a version picker
         // from, so generators render single-version output.
diff --git a/packages/node-legacy/src/legacy-html-all/README.md b/packages/node-legacy/src/legacy-html-all/README.md
index d854ea1e3..f69c2e076 100644
--- a/packages/node-legacy/src/legacy-html-all/README.md
+++ b/packages/node-legacy/src/legacy-html-all/README.md
@@ -8,6 +8,6 @@ The `legacy-html-all` generator creates a single `all.html` file containing all
 - `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`.
+  `global`. **Default:** `false`.
 - `version` {Object} Version object containing version information. Inherited
   from `global`. **Default:** `process.version`.
diff --git a/packages/node-legacy/src/legacy-html/README.md b/packages/node-legacy/src/legacy-html/README.md
index da7a95526..3df8dc581 100644
--- a/packages/node-legacy/src/legacy-html/README.md
+++ b/packages/node-legacy/src/legacy-html/README.md
@@ -18,4 +18,4 @@ The `legacy-html` generator creates legacy HTML documentation pages for Node.js
 - `index` {Array} Array of `{ api, section }` objects defining the
   documentation structure.
 - `minify` {boolean} Whether to minify the output HTML. Inherited from
-  `global`. **Default:** `true`.
+  `global`. **Default:** `false`.
diff --git a/packages/node-legacy/src/legacy-html/plugins/shiki.mjs b/packages/node-legacy/src/legacy-html/plugins/shiki.mjs
index 188915fdd..90b559ccb 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() {
   /**
@@ -90,7 +92,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/node/src/api-links/README.md b/packages/node/src/api-links/README.md
index fed3884e6..4f0178d12 100644
--- a/packages/node/src/api-links/README.md
+++ b/packages/node/src/api-links/README.md
@@ -8,4 +8,4 @@ The `api-links` generator creates a mapping of publicly accessible functions to
 - `sourceURL` {string} URL template for linking to source files.
   **Default:** `'${GITHUB_BLOB_URL}lib/{fileName}'`.
 - `minify` {boolean} Whether to minify the output JSON. Inherited from
-  `global`. **Default:** `true`.
+  `global`. **Default:** `false`.
diff --git a/packages/react/package.json b/packages/react/package.json
index e9199f728..adb3946ed 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -35,12 +35,13 @@
     "@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",
     "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/html/__tests__/generate.test.mjs b/packages/react/src/html/__tests__/generate.test.mjs
index 4b453124e..a2198925b 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, - /