Skip to content

Commit 2b215f2

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` takes 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> Assisted-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent c8b228e commit 2b215f2

6 files changed

Lines changed: 150 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 takes 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 take precedence over these defaults:
228+
`{ cpp: 'cpp', 'c++': 'cpp' }` highlights C++ with its own grammar, and a
229+
grammar in `langs` for one of these languages needs an alias to it.
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
@@ -526,7 +526,9 @@ returning the plugin, instead of a default export. The Shiki plugin does, and
526526
returns the highlighter of a generator's Shiki plugin, to highlight code as its
527527
pipeline does. It registers Shiki's bundled languages as code in them is
528528
highlighted, so before using its `shiki` instance directly, pass the language
529-
through its `resolveLanguage`.
529+
through its `resolveLanguage`, and highlight with the language it returns,
530+
which may not be the one the code names (`mjs` gives `typescript`, and an
531+
unknown language `text`).
530532

531533
## File Output
532534

‎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('highlights with a language of `langs` an alias leads to', 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+
langAlias: { js: 'javascript' },
176+
});
177+
178+
assert.equal(highlighter.resolveLanguage('js'), '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 = new Map([
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: 33 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,12 @@ 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, as Shiki replaces an alias with a grammar's own as it
225+
// registers
226+
const aliases = new Map(Object.entries(langAlias));
227+
227228
// Without themes of its own, the highlighter has a default light and dark one
228229
if (themes) {
229230
const [light, dark] = await Promise.all([
@@ -256,8 +257,9 @@ const importHighlighter = async ({
256257
});
257258

258259
/**
259-
* Resolves a language, falling back to plain text for unknown ones. A
260-
* bundled language is registered the first time it's resolved.
260+
* Resolves the language code is highlighted as, through the aliases, falling
261+
* back to plain text for unknown ones. A bundled language is registered the
262+
* first time it's resolved.
261263
*
262264
* @param {string} [languageId]
263265
* @returns {string}
@@ -267,20 +269,39 @@ const importHighlighter = async ({
267269
return FALLBACK_LANGUAGE;
268270
}
269271

272+
const followed = new Set();
273+
274+
let language = languageId;
275+
276+
// Each configured alias once, so circular ones end
277+
while (aliases.has(language.toLowerCase()) && !followed.has(language)) {
278+
followed.add(language);
279+
language = aliases.get(language.toLowerCase());
280+
}
281+
282+
if (followed.size === 0) {
283+
language = DEFAULT_ALIASES.get(language.toLowerCase()) ?? language;
284+
}
285+
270286
const instance = load().shiki;
271-
const name = instance.resolveLangAlias(languageId.toLowerCase());
287+
const loaded = instance.getLoadedLanguages();
272288

273-
if (isSpecialLang(name) || instance.getLoadedLanguages().includes(name)) {
274-
return languageId;
289+
// Shiki has bundled languages' names and aliases in lowercase
290+
for (const name of [language, language.toLowerCase()]) {
291+
if (isSpecialLang(name) || loaded.includes(name)) {
292+
return name;
293+
}
275294
}
276295

296+
const name = language.toLowerCase();
297+
277298
if (!BUNDLED_NAMES.has(name)) {
278299
return FALLBACK_LANGUAGE;
279300
}
280301

281302
instance.loadLanguageSync(importBundledLanguage(name));
282303

283-
return languageId;
304+
return name;
284305
};
285306

286307
/**

0 commit comments

Comments
 (0)