Skip to content

Commit 3792e19

Browse files
committed
perf: highlight with fewer grammars by default
Each grammar costs every thread its own memory, about 60MB for JavaScript's on Oniguruma, and time to register. Types and signatures are already highlighted as TypeScript, whose grammar highlights JavaScript too, so `js`, `mjs`, `cjs` and `javascript` code is now highlighted with it by default, and C++ (`cpp`, `c++`) with C's. On Node.js's docs that saves ~190MB of peak memory and 2-7% of build time. In the light theme, the default, JavaScript comes out the same but for one block of console output marked as `js`. Nord, the dark theme, has a few colors only for JavaScript's scopes. C++-only keywords lose their color. `langAlias` and the languages in `langs` take precedence, and doc-kit now resolves aliases itself: Shiki replaced configured aliases with a grammar's own as it registered. Aliases now also lead on through other aliases and are found in any case. Transformers, and the language `resolveLanguage` returns, are the one code is highlighted as. Assisted-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dd305c0 commit 3792e19

6 files changed

Lines changed: 158 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@doc-kit/core': minor
3+
---
4+
5+
Code is highlighted with fewer grammars by default, as each one takes its own memory on every thread: JavaScript (`js`, `mjs`, `cjs`, `javascript`) with TypeScript's grammar, which already highlights types, and C++ (`cpp`, `c++`) with C's. The Shiki plugin's `langAlias` option and the languages given in `langs` take precedence over these defaults, so `{ cpp: 'cpp', 'c++': 'cpp' }` brings back C++'s own grammar, and aliases now keep working once a grammar with the same aliases is registered. A highlighter's `resolveLanguage` now returns the language code is highlighted as, as its Shiki instance names it (`typescript` for `mjs`): highlight with what it returns. Transformers see that language too.

‎docs/configuration.md‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,20 @@ bundles. To configure it, list it with options in
219219
dependencies, so your configuration file can resolve it):
220220

221221
- `langs` {Array} More languages: grammars, or modules default-exporting them.
222-
- `langAlias` {Object} Aliases of languages, such as `{ conf: 'ini' }`.
222+
- `langAlias` {Object} Aliases of languages, such as `{ conf: 'ini' }`. An
223+
alias leads to the language it names, or on to the one that language is an
224+
alias of. By default, JavaScript (`javascript`, `js`, `mjs`, `cjs`) is
225+
highlighted with TypeScript's grammar, and C++ (`cpp`, `c++`) with C's:
226+
every grammar takes its own memory on each thread, and those highlight that
227+
code too. Aliases, and the names and aliases of the languages in `langs`,
228+
take precedence over these defaults: `{ cpp: 'cpp', 'c++': 'cpp' }`
229+
highlights C++ with its own grammar.
223230
- `themes` {Object} The `light` and `dark` themes: names of themes Shiki
224231
bundles, modules default-exporting themes, or themes.
225232
- `transformers` {Array} Modules default-exporting
226233
[Shiki transformers](https://shiki.style/guide/transformers), or lists of
227-
them.
234+
them. They see the language code is highlighted as (`options.lang`), such as
235+
`typescript` for `mjs`.
228236

229237
These modules are paths relative to the working directory, or URLs, such as
230238
the ones `import.meta.resolve()` gives:

‎docs/creating-generators.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,9 @@ returning the plugin, instead of a default export. The Shiki plugin does, and
581581
returns the highlighter of a generator's Shiki plugin, to highlight code as its
582582
pipeline does. It registers Shiki's bundled languages as code in them is
583583
highlighted, so before using its `shiki` instance directly, pass the language
584-
through its `resolveLanguage`.
584+
through its `resolveLanguage`, and highlight with the language it returns,
585+
which may not be the one the code names (`mjs` gives `typescript`, and an
586+
unknown language `text`).
585587

586588
## File Output
587589

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

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,97 @@ describe('createHighlighter', () => {
9393
const loaded = () => highlighter.shiki.getLoadedLanguages();
9494

9595
assert.ok(!loaded().includes('python'));
96-
assert.ok(!loaded().includes('javascript'));
96+
assert.ok(!loaded().includes('rust'));
9797

9898
// 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');
99+
assert.equal(highlighter.resolveLanguage('py'), 'python');
100+
assert.equal(highlighter.resolveLanguage('rs'), 'rs');
101101

102102
assert.ok(loaded().includes('python'));
103-
assert.ok(loaded().includes('javascript'));
104-
assert.ok(loaded().includes('cjs'));
103+
assert.ok(loaded().includes('rust'));
105104

106105
assert.equal(highlighter.resolveLanguage(undefined), 'text');
107106
assert.equal(highlighter.resolveLanguage('plaintext'), 'plaintext');
108107
});
109108

109+
it('highlights JavaScript as TypeScript, and C++ as C', async () => {
110+
const highlighter = await createHighlighter();
111+
const loaded = () => highlighter.shiki.getLoadedLanguages();
112+
113+
for (const language of ['javascript', 'JS', 'mjs', 'cjs']) {
114+
assert.equal(highlighter.resolveLanguage(language), 'typescript');
115+
}
116+
117+
assert.equal(highlighter.resolveLanguage('cpp'), 'c');
118+
assert.equal(highlighter.resolveLanguage('c++'), 'c');
119+
assert.ok(!loaded().includes('cpp'));
120+
121+
// Even once a grammar that has them as its own aliases is registered
122+
assert.equal(highlighter.resolveLanguage('coffee'), 'coffee');
123+
assert.ok(loaded().includes('javascript'));
124+
assert.equal(highlighter.resolveLanguage('js'), 'typescript');
125+
126+
// Not the names every object inherits
127+
assert.equal(highlighter.resolveLanguage('constructor'), 'text');
128+
});
129+
130+
it('takes aliases over the default ones', async () => {
131+
const highlighter = await createHighlighter({
132+
langAlias: { cpp: 'cpp', js: 'javascript' },
133+
});
134+
135+
assert.equal(highlighter.resolveLanguage('cpp'), 'cpp');
136+
assert.equal(highlighter.resolveLanguage('js'), 'javascript');
137+
assert.equal(highlighter.resolveLanguage('mjs'), 'typescript');
138+
assert.ok(highlighter.shiki.getLoadedLanguages().includes('cpp'));
139+
});
140+
141+
it('follows configured aliases, and finds languages named in any case', async () => {
142+
const highlighter = await createHighlighter({
143+
langs: [{ ...grammar, name: 'OxcConf', scopeName: 'source.oxcconf2' }],
144+
langAlias: {
145+
a: 'b',
146+
b: 'python',
147+
loop: 'pool',
148+
pool: 'loop',
149+
oc: 'OxcConf',
150+
conf: 'INI',
151+
},
152+
});
153+
154+
assert.equal(highlighter.resolveLanguage('a'), 'python');
155+
assert.equal(highlighter.resolveLanguage('loop'), 'text');
156+
assert.equal(highlighter.resolveLanguage('Python'), 'python');
157+
assert.equal(highlighter.resolveLanguage('oc'), 'OxcConf');
158+
assert.equal(highlighter.resolveLanguage('conf'), 'ini');
159+
assert.equal(
160+
highlighter.highlightToHtml('[section]', 'conf'),
161+
highlighter.highlightToHtml('[section]', 'ini')
162+
);
163+
});
164+
165+
it('takes the languages of `langs` over the default aliases', async () => {
166+
const highlighter = await createHighlighter({
167+
langs: [
168+
{
169+
...grammar,
170+
name: 'javascript',
171+
aliases: ['js'],
172+
scopeName: 'source.oxc-js',
173+
},
174+
],
175+
});
176+
177+
assert.equal(highlighter.resolveLanguage('js'), 'js');
178+
assert.equal(highlighter.resolveLanguage('javascript'), 'javascript');
179+
assert.match(
180+
highlighter.highlightToHtml('minify on', 'js'),
181+
/--shiki-dark/
182+
);
183+
assert.ok(!highlighter.shiki.getLoadedLanguages().includes('typescript'));
184+
assert.equal(highlighter.resolveLanguage('mjs'), 'typescript');
185+
});
186+
110187
it('lists the bundled languages without registering them', async () => {
111188
const highlighter = await createHighlighter({ langs: [grammar] });
112189

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
'use strict';
2+
3+
// What code in an unknown language is highlighted as
4+
export const FALLBACK_LANGUAGE = 'text';
5+
6+
// What code in these languages is highlighted as, unless configured otherwise.
7+
// Every grammar takes its own memory on each thread, and time to register, and
8+
// TypeScript's highlights JavaScript (types are highlighted as TypeScript
9+
// anyway), as C's does most of C++
10+
export const DEFAULT_ALIASES = {
11+
javascript: 'typescript',
12+
js: 'typescript',
13+
mjs: 'typescript',
14+
cjs: 'typescript',
15+
cpp: 'c',
16+
'c++': 'c',
17+
};

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

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { importFromURL } from '#utils/loaders.mjs';
1212
import { getMarkdownPlugins } from '#utils/markdown/plugins.mjs';
1313
import { lazy } from '#utils/misc.mjs';
1414

15+
import { DEFAULT_ALIASES, FALLBACK_LANGUAGE } from './constants.mjs';
16+
1517
const require = createRequire(import.meta.url);
1618

1719
/**
@@ -25,7 +27,7 @@ const require = createRequire(import.meta.url);
2527
*
2628
* @typedef {Object} SyntaxHighlighter
2729
* @property {import('shiki').HighlighterCore} shiki - The Shiki instance
28-
* @property {(languageId?: string) => string} resolveLanguage - Resolves a language, falling back to plain text for unknown ones
30+
* @property {(languageId?: string) => string} resolveLanguage - Resolves the language code is highlighted as, falling back to plain text for unknown ones
2931
* @property {(code: string, lang: string, meta?: Record<string, unknown>) => string} highlightToHtml - Highlights code, returning the inner HTML of its `<code>` element
3032
* @property {(code: string, lang: string, meta?: Record<string, unknown>) => ReturnType<import('shiki').HighlighterCore['codeToHast']>} highlightToHast - Highlights code, returning a HAST tree
3133
* @property {Array<Language>} langs - The languages it highlights
@@ -64,9 +66,6 @@ let engine;
6466
// The highlighters of the options given, by their JSON
6567
const highlighters = new Map();
6668

67-
// What code in an unknown language is highlighted as
68-
const FALLBACK_LANGUAGE = 'text';
69-
7069
// The languages Shiki bundles. Registering a grammar takes time and memory,
7170
// and slows down every highlight after it, so each one is only imported and
7271
// registered once code in it is highlighted (see `resolveLanguage`).
@@ -220,10 +219,20 @@ const importHighlighter = async ({
220219
engine: regexEngine,
221220
// The bundled languages are registered as code uses them
222221
langs: withEmbeddedLanguages(importedLangs),
223-
// A copy, as Shiki adds the aliases of the languages it registers to it
224-
langAlias: { ...langAlias },
225222
};
226223

224+
// Resolved here rather than by Shiki, which replaces an alias with a
225+
// grammar's own as it registers. The default ones give way to the languages
226+
// of `langs`
227+
const aliases = new Map(Object.entries(langAlias));
228+
const claimed = importedLangs.flatMap(({ name, aliases = [] }) => [
229+
name,
230+
...aliases,
231+
]);
232+
const defaultAliases = new Map(
233+
Object.entries(DEFAULT_ALIASES).filter(([id]) => !claimed.includes(id))
234+
);
235+
227236
// Without themes of its own, the highlighter has a default light and dark one
228237
if (themes) {
229238
const [light, dark] = await Promise.all([
@@ -256,8 +265,9 @@ const importHighlighter = async ({
256265
});
257266

258267
/**
259-
* Resolves a language, falling back to plain text for unknown ones. A
260-
* bundled language is registered the first time it's resolved.
268+
* Resolves the language code is highlighted as, through the aliases, falling
269+
* back to plain text for unknown ones. A bundled language is registered the
270+
* first time it's resolved.
261271
*
262272
* @param {string} [languageId]
263273
* @returns {string}
@@ -267,20 +277,39 @@ const importHighlighter = async ({
267277
return FALLBACK_LANGUAGE;
268278
}
269279

280+
const followed = new Set();
281+
282+
let language = languageId;
283+
284+
// Each configured alias once, so circular ones end
285+
while (aliases.has(language.toLowerCase()) && !followed.has(language)) {
286+
followed.add(language);
287+
language = aliases.get(language.toLowerCase());
288+
}
289+
290+
if (followed.size === 0) {
291+
language = defaultAliases.get(language.toLowerCase()) ?? language;
292+
}
293+
270294
const instance = load().shiki;
271-
const name = instance.resolveLangAlias(languageId.toLowerCase());
295+
const loaded = instance.getLoadedLanguages();
272296

273-
if (isSpecialLang(name) || instance.getLoadedLanguages().includes(name)) {
274-
return languageId;
297+
// Shiki has bundled languages' names and aliases in lowercase
298+
for (const name of [language, language.toLowerCase()]) {
299+
if (isSpecialLang(name) || loaded.includes(name)) {
300+
return name;
301+
}
275302
}
276303

304+
const name = language.toLowerCase();
305+
277306
if (!BUNDLED_NAMES.has(name)) {
278307
return FALLBACK_LANGUAGE;
279308
}
280309

281310
instance.loadLanguageSync(importBundledLanguage(name));
282311

283-
return languageId;
312+
return name;
284313
};
285314

286315
/**

0 commit comments

Comments
 (0)