Skip to content

Commit 00fe2c2

Browse files
committed
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 <noreply@anthropic.com>
1 parent 7238bb1 commit 00fe2c2

11 files changed

Lines changed: 272 additions & 131 deletions

File tree

‎docs/configuration.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ bundles. To configure it, list it with options in
218218
dependencies, so your configuration file can resolve it):
219219

220220
- `langs` {Array} More languages: grammars, or modules default-exporting them.
221+
The languages Shiki bundles are registered once code in them is highlighted,
222+
so one injecting into others (such as `ts-tags`, into JavaScript's tagged
223+
templates) only does so in its own code blocks; list it here (e.g.
224+
`import.meta.resolve('shiki/langs/ts-tags.mjs')`) to register it up front,
225+
as a transformer highlighting code in another language needs too.
221226
- `langAlias` {Object} Aliases of languages, such as `{ conf: 'ini' }`.
222227
- `themes` {Object} The `light` and `dark` themes: names of themes Shiki
223228
bundles, modules default-exporting themes, or themes.

‎docs/creating-generators.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,9 @@ A plugin needing asynchronous setup can export an async `load(options)`
524524
returning the plugin, instead of a default export. The Shiki plugin does, and
525525
`getHighlighter(name)` (from `@doc-kit/core/plugins/shiki/highlighter.mjs`)
526526
returns the highlighter of a generator's Shiki plugin, to highlight code as its
527-
pipeline does.
527+
pipeline does. It registers the languages Shiki bundles as code in them is
528+
highlighted: pass a language through its `resolveLanguage` before using its
529+
`shiki` instance directly.
528530

529531
## File Output
530532

‎packages/core/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
],
5454
"dependencies": {
5555
"@actions/core": "^3.0.0",
56-
"@node-core/rehype-shiki": "^1.5.0",
56+
"@node-core/rehype-shiki": "^1.5.1",
5757
"@swc/html-wasm": "^1.16.2",
5858
"@swc/wasm": "^1.15.46",
5959
"acorn": "^8.17.0",

‎packages/core/src/plugins/shiki/__tests__/highlighter.test.mjs‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,57 @@ describe('createHighlighter', () => {
8686
);
8787
});
8888

89+
it('registers a bundled language once code in it is highlighted', async () => {
90+
const highlighter = await createHighlighter({
91+
langAlias: { py: 'python' },
92+
});
93+
const loaded = () => highlighter.shiki.getLoadedLanguages();
94+
95+
assert.ok(!loaded().includes('python'));
96+
assert.ok(!loaded().includes('javascript'));
97+
98+
// By its name, an alias of its own, or one of the options
99+
assert.equal(highlighter.resolveLanguage('py'), 'py');
100+
assert.equal(highlighter.resolveLanguage('mjs'), 'mjs');
101+
102+
assert.ok(loaded().includes('python'));
103+
assert.ok(loaded().includes('javascript'));
104+
assert.ok(loaded().includes('cjs'));
105+
106+
assert.equal(highlighter.resolveLanguage(undefined), 'text');
107+
assert.equal(highlighter.resolveLanguage('plaintext'), 'plaintext');
108+
});
109+
110+
it('lists the bundled languages without registering them', async () => {
111+
const highlighter = await createHighlighter({ langs: [grammar] });
112+
113+
assert.deepStrictEqual(
114+
highlighter.langs.find(({ name }) => name === 'rust'),
115+
{ name: 'rust', displayName: 'Rust', aliases: ['rs'] }
116+
);
117+
assert.equal(highlighter.langs.at(-1), grammar);
118+
assert.ok(!highlighter.shiki.getLoadedLanguages().includes('rust'));
119+
});
120+
121+
it('registers the bundled languages a language embeds', async () => {
122+
const highlighter = await createHighlighter({
123+
langs: [
124+
{
125+
...grammar,
126+
name: 'oxcscript',
127+
scopeName: 'source.oxcscript',
128+
embeddedLangs: ['javascript'],
129+
patterns: [{ include: 'source.js' }],
130+
},
131+
],
132+
});
133+
134+
assert.match(
135+
highlighter.highlightToHtml('const on = 1', 'oxcscript'),
136+
/--shiki-dark/
137+
);
138+
});
139+
89140
it('gives the same highlighter for the same options', async () => {
90141
const highlighter = await createHighlighter({ langs: [grammar] });
91142

‎packages/core/src/plugins/shiki/highlighter.mjs‎

Lines changed: 177 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
'use strict';
22

3+
import { createRequire } from 'node:module';
34
import { endianness } from 'node:os';
45

5-
import { LANGS } from '@node-core/rehype-shiki';
66
import createSyntaxHighlighter from '@node-core/rehype-shiki/highlighter';
7+
import { isSpecialLang } from 'shiki/core';
8+
import { bundledLanguagesInfo } from 'shiki/langs';
79
import { bundledThemes } from 'shiki/themes';
810

911
import { importFromURL } from '#utils/loaders.mjs';
1012
import { getMarkdownPlugins } from '#utils/markdown/plugins.mjs';
1113

14+
const require = createRequire(import.meta.url);
15+
16+
/**
17+
* A language a highlighter highlights.
18+
*
19+
* @typedef {Pick<import('shiki').LanguageRegistration, 'name' | 'displayName' | 'aliases'>} Language
20+
*/
21+
1222
/**
1323
* A syntax highlighter, creating its Shiki instance on first use.
1424
*
@@ -17,7 +27,7 @@ import { getMarkdownPlugins } from '#utils/markdown/plugins.mjs';
1727
* @property {(languageId?: string) => string} resolveLanguage - Resolves a language, falling back to plain text for unknown ones
1828
* @property {(code: string, lang: string, meta?: Record<string, unknown>) => string} highlightToHtml - Highlights code, returning the inner HTML of its `<code>` element
1929
* @property {(code: string, lang: string, meta?: Record<string, unknown>) => ReturnType<import('shiki').HighlighterCore['codeToHast']>} highlightToHast - Highlights code, returning a HAST tree
20-
* @property {Array<import('shiki').LanguageRegistration>} langs - The languages it highlights
30+
* @property {Array<Language>} langs - The languages it highlights
2131
*/
2232

2333
/**
@@ -53,6 +63,87 @@ let engine;
5363
// The highlighters of the options given, by their JSON
5464
const highlighters = new Map();
5565

66+
// What code in an unknown language is highlighted as
67+
const FALLBACK_LANGUAGE = 'text';
68+
69+
// The languages Shiki bundles. Registering a grammar takes time and memory,
70+
// and slows down every highlight after it, so each one is only imported and
71+
// registered once code in it is highlighted (see `resolveLanguage`).
72+
const BUNDLED_LANGUAGES = bundledLanguagesInfo.map(({ id, name, aliases }) => ({
73+
name: id,
74+
displayName: name,
75+
aliases,
76+
}));
77+
78+
// The bundled language each of their names and aliases stands for, as code
79+
// names its language by either
80+
const BUNDLED_NAMES = new Map(
81+
BUNDLED_LANGUAGES.flatMap(({ name, aliases = [] }) =>
82+
[name, ...aliases].map(alias => [alias, name])
83+
)
84+
);
85+
86+
/**
87+
* Imports a bundled language: its grammar, and those of the languages it
88+
* embeds, lazily too. Shiki only highlights a language embedded lazily (a
89+
* fenced code block in Markdown) once it is registered, so registering it
90+
* later would make code highlight differently depending on what was
91+
* highlighted before it.
92+
*
93+
* For the same reason, a grammar injecting into others' (`ts-tags`', into
94+
* JavaScript's tagged templates) only injects into the language it was
95+
* imported for: Shiki only applies an injection to the grammars it creates
96+
* after it. A language given in `langs` keeps its injections.
97+
*
98+
* @param {string} name - A name or alias of the language
99+
* @returns {Array<import('shiki').LanguageRegistration>}
100+
*/
101+
const importBundledLanguage = name => {
102+
const id = BUNDLED_NAMES.get(name);
103+
const langs = new Map();
104+
const pending = [id];
105+
106+
while (pending.length > 0) {
107+
const grammars = require(`shiki/langs/${pending.pop()}.mjs`).default;
108+
109+
for (const lang of grammars) {
110+
if (!langs.has(lang.name)) {
111+
langs.set(lang.name, lang);
112+
113+
pending.push(
114+
...[...(lang.embeddedLangs ?? []), ...(lang.embeddedLangsLazy ?? [])]
115+
.filter(embedded => BUNDLED_NAMES.has(embedded))
116+
.map(embedded => BUNDLED_NAMES.get(embedded))
117+
);
118+
}
119+
}
120+
}
121+
122+
const { scopeName } = langs.get(id);
123+
124+
return [...langs.values()].map(lang =>
125+
lang.injectTo ? { ...lang, injectTo: [scopeName] } : lang
126+
);
127+
};
128+
129+
/**
130+
* Adds the bundled languages that languages embed, which Shiki registers
131+
* along with them.
132+
*
133+
* @param {Array<import('shiki').LanguageRegistration>} langs - The languages
134+
* @returns {Array<import('shiki').LanguageRegistration>}
135+
*/
136+
const withEmbeddedLanguages = langs => {
137+
const names = new Set(langs.map(({ name }) => name));
138+
139+
const embedded = langs
140+
.flatMap(({ embeddedLangs = [] }) => embeddedLangs)
141+
.filter(name => !names.has(name) && BUNDLED_NAMES.has(name))
142+
.flatMap(importBundledLanguage);
143+
144+
return [...embedded, ...langs];
145+
};
146+
56147
/**
57148
* Imports a list of options, each given as it is, or as a module (a path
58149
* relative to the working directory, or a URL) default-exporting it, or a list
@@ -119,13 +210,12 @@ const importHighlighter = async ({
119210

120211
const coreOptions = {
121212
engine: regexEngine,
122-
langs: [...LANGS, ...importedLangs],
123-
// A copy, as Shiki adds the aliases of the languages it bundles to it
213+
// The bundled languages are registered as code uses them
214+
langs: withEmbeddedLanguages(importedLangs),
215+
// A copy, as Shiki adds the aliases of the languages it registers to it
124216
langAlias: { ...langAlias },
125217
};
126218

127-
const highlighterOptions = { transformers: importedTransformers };
128-
129219
// Without themes of its own, the highlighter has a default light and dark one
130220
if (themes) {
131221
const [light, dark] = await Promise.all([
@@ -134,58 +224,117 @@ const importHighlighter = async ({
134224
]);
135225

136226
coreOptions.themes = [light, dark];
137-
highlighterOptions.themes = { light: light.name, dark: dark.name };
138-
highlighterOptions.defaultColor = 'light';
139227
}
140228

141-
let highlighter;
229+
let shiki;
230+
let highlightOptions;
231+
232+
/**
233+
* Gives the Shiki instance, creating it on first use.
234+
*
235+
* @returns {import('shiki').HighlighterCore}
236+
*/
237+
const current = () => {
238+
if (!shiki) {
239+
({ shiki } = createSyntaxHighlighter({ coreOptions }));
240+
241+
// The themes are given by name: Shiki keeps the themes it loaded parsed,
242+
// but parses a theme object it's given again for every highlight
243+
const [light, dark] =
244+
coreOptions.themes?.map(theme => theme.name) ?? shiki.getLoadedThemes();
245+
246+
highlightOptions = {
247+
themes: { light, dark },
248+
defaultColor: 'light',
249+
transformers: importedTransformers,
250+
};
251+
}
252+
253+
return shiki;
254+
};
142255

143256
/**
144-
* Gives the Shiki highlighter, creating it on first use.
257+
* Resolves a language, falling back to plain text for unknown ones. A
258+
* bundled language is registered the first time it's resolved.
259+
*
260+
* @param {string} [languageId]
261+
* @returns {string}
145262
*/
146-
const current = () =>
147-
(highlighter ??= createSyntaxHighlighter({
148-
coreOptions,
149-
highlighterOptions,
150-
}));
263+
const resolveLanguage = languageId => {
264+
if (!languageId) {
265+
return FALLBACK_LANGUAGE;
266+
}
267+
268+
const instance = current();
269+
const name = instance.resolveLangAlias(languageId.toLowerCase());
270+
271+
if (isSpecialLang(name) || instance.getLoadedLanguages().includes(name)) {
272+
return languageId;
273+
}
274+
275+
if (!BUNDLED_NAMES.has(name)) {
276+
return FALLBACK_LANGUAGE;
277+
}
278+
279+
instance.loadLanguageSync(importBundledLanguage(name));
280+
281+
return languageId;
282+
};
283+
284+
/**
285+
* The options Shiki highlights code with.
286+
*
287+
* @param {string} lang - The language of the code
288+
* @param {Record<string, unknown>} meta - Its metadata
289+
*/
290+
const optionsFor = (lang, meta) => ({
291+
lang: resolveLanguage(lang),
292+
...highlightOptions,
293+
meta,
294+
});
151295

152296
return {
153-
langs: coreOptions.langs,
297+
langs: [...BUNDLED_LANGUAGES, ...importedLangs],
154298

155299
/**
156300
* The Shiki instance.
157301
*/
158302
get shiki() {
159-
return current().shiki;
303+
return current();
160304
},
161305

162-
/**
163-
* Resolves a language, falling back to plain text for unknown ones.
164-
*
165-
* @param {string} [languageId]
166-
*/
167-
resolveLanguage: languageId => current().resolveLanguage(languageId),
306+
resolveLanguage,
168307

169308
/**
170309
* Highlights code, returning the inner HTML of its `<code>` element.
171310
*
172-
* @param {...any} args - The code, its language, and its metadata
311+
* @param {string} code - The code
312+
* @param {string} lang - Its language
313+
* @param {Record<string, unknown>} [meta] - Its metadata
173314
*/
174-
highlightToHtml: (...args) => current().highlightToHtml(...args),
315+
highlightToHtml: (code, lang, meta = {}) =>
316+
current()
317+
.codeToHtml(code, optionsFor(lang, meta))
318+
// Shiki wraps the highlighted code in a <pre> and a <code>
319+
.match(/<code>(.+?)<\/code>/s)[1],
175320

176321
/**
177322
* Highlights code, returning a HAST tree.
178323
*
179-
* @param {...any} args - The code, its language, and its metadata
324+
* @param {string} code - The code
325+
* @param {string} lang - Its language
326+
* @param {Record<string, unknown>} [meta] - Its metadata
180327
*/
181-
highlightToHast: (...args) => current().highlightToHast(...args),
328+
highlightToHast: (code, lang, meta = {}) =>
329+
current().codeToHast(code, optionsFor(lang, meta)),
182330
};
183331
};
184332

185333
/**
186334
* Creates a highlighter of every language Shiki bundles, with the given
187335
* options (see `./rehype.mjs`). Its Shiki instance is created on first use,
188-
* and the same options give the same highlighter.
336+
* each bundled language is registered once code in it is highlighted, and the
337+
* same options give the same highlighter.
189338
*
190339
* @param {import('./rehype.mjs').ShikiOptions} [options] - The options
191340
* @returns {Promise<SyntaxHighlighter>}

‎packages/core/src/plugins/type-annotations/highlighter.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export const createTypeAnnotationHandler = getHighlighter => (state, node) => {
2323
return typeAnnotationToHast(state, node);
2424
}
2525

26-
const { shiki } = getHighlighter();
26+
const { shiki, resolveLanguage } = getHighlighter();
2727
const [lightTheme, darkTheme] = shiki.getLoadedThemes();
2828

2929
const root = shiki.codeToHast(node.value, {
30-
lang: node.data?.typescript ? 'typescript' : 'text',
30+
lang: resolveLanguage(node.data?.typescript ? 'typescript' : 'text'),
3131
themes: { light: lightTheme, dark: darkTheme },
3232
decorations: links.map(({ start, end, href }) => ({
3333
start,

0 commit comments

Comments
 (0)