Skip to content

Commit d5c536b

Browse files
authored
fix(shiki): fallback to plaintext on unknown lang (#8952)
1 parent b05c48d commit d5c536b

3 files changed

Lines changed: 89 additions & 8 deletions

File tree

packages/rehype-shiki/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@node-core/rehype-shiki",
3-
"version": "1.4.1",
3+
"version": "1.4.2",
44
"type": "module",
55
"types": "./dist/index.d.mts",
66
"exports": {

packages/rehype-shiki/src/__tests__/highlighter.test.mjs

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import { describe, it, mock } from 'node:test';
55
const mockShiki = {
66
codeToHtml: mock.fn(() => '<pre><code>highlighted code</code></pre>'),
77
codeToHast: mock.fn(() => ({ type: 'element', tagName: 'pre' })),
8+
getLoadedLanguages: mock.fn(() => ['javascript', 'js']),
89
};
910

11+
const SPECIAL_LANGS = ['text', 'plaintext', 'txt', 'ansi'];
12+
1013
mock.module('@shikijs/core', {
11-
namedExports: { createHighlighterCoreSync: () => mockShiki },
14+
namedExports: {
15+
createHighlighterCoreSync: () => mockShiki,
16+
isSpecialLang: lang => SPECIAL_LANGS.includes(lang),
17+
},
1218
});
1319

1420
mock.module('@shikijs/engine-javascript', {
@@ -22,6 +28,29 @@ mock.module('shiki/themes/nord.mjs', {
2228
describe('createHighlighter', async () => {
2329
const { default: createHighlighter } = await import('../highlighter.mjs');
2430

31+
describe('resolveLanguage', () => {
32+
it('returns the language when it is loaded', () => {
33+
const highlighter = createHighlighter({});
34+
35+
assert.strictEqual(
36+
highlighter.resolveLanguage('javascript'),
37+
'javascript'
38+
);
39+
});
40+
41+
it('returns the language when it is a special language', () => {
42+
const highlighter = createHighlighter({});
43+
44+
assert.strictEqual(highlighter.resolveLanguage('plaintext'), 'plaintext');
45+
});
46+
47+
it('falls back to text for unknown languages', () => {
48+
const highlighter = createHighlighter({});
49+
50+
assert.strictEqual(highlighter.resolveLanguage('unknown'), 'text');
51+
});
52+
});
53+
2554
describe('highlightToHtml', () => {
2655
it('extracts inner HTML from code tag', () => {
2756
mockShiki.codeToHtml.mock.mockImplementationOnce(
@@ -33,6 +62,14 @@ describe('createHighlighter', async () => {
3362

3463
assert.strictEqual(result, 'const x = 1;');
3564
});
65+
66+
it('falls back to text for unknown languages', () => {
67+
const highlighter = createHighlighter({});
68+
highlighter.highlightToHtml('code', 'not-a-language');
69+
70+
const [, options] = mockShiki.codeToHtml.mock.calls.at(-1).arguments;
71+
assert.strictEqual(options.lang, 'text');
72+
});
3673
});
3774

3875
describe('highlightToHast', () => {
@@ -45,5 +82,13 @@ describe('createHighlighter', async () => {
4582

4683
assert.deepStrictEqual(result, expectedHast);
4784
});
85+
86+
it('falls back to text for unknown languages', () => {
87+
const highlighter = createHighlighter({});
88+
highlighter.highlightToHast('code', 'not-a-language');
89+
90+
const [, options] = mockShiki.codeToHast.mock.calls.at(-1).arguments;
91+
assert.strictEqual(options.lang, 'text');
92+
});
4893
});
4994
});

packages/rehype-shiki/src/highlighter.mjs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHighlighterCoreSync } from '@shikijs/core';
1+
import { createHighlighterCoreSync, isSpecialLang } from '@shikijs/core';
22
import shikiNordTheme from 'shiki/themes/nord.mjs';
33

44
const DEFAULT_THEME = {
@@ -9,6 +9,8 @@ const DEFAULT_THEME = {
99
...shikiNordTheme,
1010
};
1111

12+
const FALLBACK_LANGUAGE = 'text';
13+
1214
/**
1315
* @template {{ name: string; aliases?: string[] }} T
1416
* @param {string} language
@@ -17,7 +19,6 @@ const DEFAULT_THEME = {
1719
*/
1820
export const getLanguageByName = (language, langs) => {
1921
const normalized = language.toLowerCase();
20-
2122
return langs.find(
2223
({ name, aliases }) =>
2324
name.toLowerCase() === normalized || aliases?.includes(normalized)
@@ -27,6 +28,7 @@ export const getLanguageByName = (language, langs) => {
2728
/**
2829
* @typedef {Object} SyntaxHighlighter
2930
* @property {import('@shikijs/core').HighlighterCore} shiki - The underlying shiki core instance.
31+
* @property {(languageId?: string) => string} resolveLanguage - Resolves a language id to a loaded language, falling back to plain text.
3032
* @property {(code: string, lang: string, meta?: Record<string, any>) => string} highlightToHtml - Highlights code and returns inner HTML of the <code> tag.
3133
* @property {(code: string, lang: string, meta?: Record<string, any>) => any} highlightToHast - Highlights code and returns a HAST tree.
3234
*/
@@ -44,11 +46,34 @@ const createHighlighter = ({ coreOptions = {}, highlighterOptions = {} }) => {
4446
themes: [DEFAULT_THEME],
4547
...coreOptions,
4648
};
47-
4849
const shiki = createHighlighterCoreSync(options);
49-
5050
const theme = options.themes[0];
5151

52+
const loadedLanguages = new Set(
53+
shiki.getLoadedLanguages().map(lang => lang.toLowerCase())
54+
);
55+
56+
/**
57+
* Resolves a language id to one this highlighter can handle.
58+
* Falls back to plain text for unknown/unloaded languages so
59+
* highlighting never throws on unrecognized code fences.
60+
*
61+
* @param {string} [languageId]
62+
* @returns {string}
63+
*/
64+
const resolveLanguage = languageId => {
65+
const normalized = languageId?.toLowerCase();
66+
67+
if (
68+
normalized &&
69+
(isSpecialLang(normalized) || loadedLanguages.has(normalized))
70+
) {
71+
return languageId;
72+
}
73+
74+
return FALLBACK_LANGUAGE;
75+
};
76+
5277
/**
5378
* Highlights code and returns the inner HTML inside the <code> tag
5479
*
@@ -59,7 +84,12 @@ const createHighlighter = ({ coreOptions = {}, highlighterOptions = {} }) => {
5984
*/
6085
const highlightToHtml = (code, lang, meta = {}) =>
6186
shiki
62-
.codeToHtml(code, { lang, theme, meta, ...highlighterOptions })
87+
.codeToHtml(code, {
88+
lang: resolveLanguage(lang),
89+
theme,
90+
meta,
91+
...highlighterOptions,
92+
})
6393
// Shiki will always return the Highlighted code encapsulated in a <pre> and <code> tag
6494
// since our own CodeBox component handles the <code> tag, we just want to extract
6595
// the inner highlighted code to the CodeBox
@@ -73,10 +103,16 @@ const createHighlighter = ({ coreOptions = {}, highlighterOptions = {} }) => {
73103
* @param {Record<string, any>} meta - Metadata
74104
*/
75105
const highlightToHast = (code, lang, meta = {}) =>
76-
shiki.codeToHast(code, { lang, theme, meta, ...highlighterOptions });
106+
shiki.codeToHast(code, {
107+
lang: resolveLanguage(lang),
108+
theme,
109+
meta,
110+
...highlighterOptions,
111+
});
77112

78113
return {
79114
shiki,
115+
resolveLanguage,
80116
highlightToHtml,
81117
highlightToHast,
82118
};

0 commit comments

Comments
 (0)