diff --git a/packages/rehype-shiki/package.json b/packages/rehype-shiki/package.json index 9781a3386b634..ac73d3030e02e 100644 --- a/packages/rehype-shiki/package.json +++ b/packages/rehype-shiki/package.json @@ -1,6 +1,6 @@ { "name": "@node-core/rehype-shiki", - "version": "1.4.1", + "version": "1.4.2", "type": "module", "types": "./dist/index.d.mts", "exports": { diff --git a/packages/rehype-shiki/src/__tests__/highlighter.test.mjs b/packages/rehype-shiki/src/__tests__/highlighter.test.mjs index b128b7c68a498..05b1f208e0cf2 100644 --- a/packages/rehype-shiki/src/__tests__/highlighter.test.mjs +++ b/packages/rehype-shiki/src/__tests__/highlighter.test.mjs @@ -5,10 +5,16 @@ import { describe, it, mock } from 'node:test'; const mockShiki = { codeToHtml: mock.fn(() => '
highlighted code'),
codeToHast: mock.fn(() => ({ type: 'element', tagName: 'pre' })),
+ getLoadedLanguages: mock.fn(() => ['javascript', 'js']),
};
+const SPECIAL_LANGS = ['text', 'plaintext', 'txt', 'ansi'];
+
mock.module('@shikijs/core', {
- namedExports: { createHighlighterCoreSync: () => mockShiki },
+ namedExports: {
+ createHighlighterCoreSync: () => mockShiki,
+ isSpecialLang: lang => SPECIAL_LANGS.includes(lang),
+ },
});
mock.module('@shikijs/engine-javascript', {
@@ -22,6 +28,29 @@ mock.module('shiki/themes/nord.mjs', {
describe('createHighlighter', async () => {
const { default: createHighlighter } = await import('../highlighter.mjs');
+ describe('resolveLanguage', () => {
+ it('returns the language when it is loaded', () => {
+ const highlighter = createHighlighter({});
+
+ assert.strictEqual(
+ highlighter.resolveLanguage('javascript'),
+ 'javascript'
+ );
+ });
+
+ it('returns the language when it is a special language', () => {
+ const highlighter = createHighlighter({});
+
+ assert.strictEqual(highlighter.resolveLanguage('plaintext'), 'plaintext');
+ });
+
+ it('falls back to text for unknown languages', () => {
+ const highlighter = createHighlighter({});
+
+ assert.strictEqual(highlighter.resolveLanguage('unknown'), 'text');
+ });
+ });
+
describe('highlightToHtml', () => {
it('extracts inner HTML from code tag', () => {
mockShiki.codeToHtml.mock.mockImplementationOnce(
@@ -33,6 +62,14 @@ describe('createHighlighter', async () => {
assert.strictEqual(result, 'const x = 1;');
});
+
+ it('falls back to text for unknown languages', () => {
+ const highlighter = createHighlighter({});
+ highlighter.highlightToHtml('code', 'not-a-language');
+
+ const [, options] = mockShiki.codeToHtml.mock.calls.at(-1).arguments;
+ assert.strictEqual(options.lang, 'text');
+ });
});
describe('highlightToHast', () => {
@@ -45,5 +82,13 @@ describe('createHighlighter', async () => {
assert.deepStrictEqual(result, expectedHast);
});
+
+ it('falls back to text for unknown languages', () => {
+ const highlighter = createHighlighter({});
+ highlighter.highlightToHast('code', 'not-a-language');
+
+ const [, options] = mockShiki.codeToHast.mock.calls.at(-1).arguments;
+ assert.strictEqual(options.lang, 'text');
+ });
});
});
diff --git a/packages/rehype-shiki/src/highlighter.mjs b/packages/rehype-shiki/src/highlighter.mjs
index 56c5d5e11eb29..3b2c971563119 100644
--- a/packages/rehype-shiki/src/highlighter.mjs
+++ b/packages/rehype-shiki/src/highlighter.mjs
@@ -1,4 +1,4 @@
-import { createHighlighterCoreSync } from '@shikijs/core';
+import { createHighlighterCoreSync, isSpecialLang } from '@shikijs/core';
import shikiNordTheme from 'shiki/themes/nord.mjs';
const DEFAULT_THEME = {
@@ -9,6 +9,8 @@ const DEFAULT_THEME = {
...shikiNordTheme,
};
+const FALLBACK_LANGUAGE = 'text';
+
/**
* @template {{ name: string; aliases?: string[] }} T
* @param {string} language
@@ -17,7 +19,6 @@ const DEFAULT_THEME = {
*/
export const getLanguageByName = (language, langs) => {
const normalized = language.toLowerCase();
-
return langs.find(
({ name, aliases }) =>
name.toLowerCase() === normalized || aliases?.includes(normalized)
@@ -27,6 +28,7 @@ export const getLanguageByName = (language, langs) => {
/**
* @typedef {Object} SyntaxHighlighter
* @property {import('@shikijs/core').HighlighterCore} shiki - The underlying shiki core instance.
+ * @property {(languageId?: string) => string} resolveLanguage - Resolves a language id to a loaded language, falling back to plain text.
* @property {(code: string, lang: string, meta?: Record tag.
* @property {(code: string, lang: string, meta?: Record) => any} highlightToHast - Highlights code and returns a HAST tree.
*/
@@ -44,11 +46,34 @@ const createHighlighter = ({ coreOptions = {}, highlighterOptions = {} }) => {
themes: [DEFAULT_THEME],
...coreOptions,
};
-
const shiki = createHighlighterCoreSync(options);
-
const theme = options.themes[0];
+ const loadedLanguages = new Set(
+ shiki.getLoadedLanguages().map(lang => lang.toLowerCase())
+ );
+
+ /**
+ * Resolves a language id to one this highlighter can handle.
+ * Falls back to plain text for unknown/unloaded languages so
+ * highlighting never throws on unrecognized code fences.
+ *
+ * @param {string} [languageId]
+ * @returns {string}
+ */
+ const resolveLanguage = languageId => {
+ const normalized = languageId?.toLowerCase();
+
+ if (
+ normalized &&
+ (isSpecialLang(normalized) || loadedLanguages.has(normalized))
+ ) {
+ return languageId;
+ }
+
+ return FALLBACK_LANGUAGE;
+ };
+
/**
* Highlights code and returns the inner HTML inside the tag
*
@@ -59,7 +84,12 @@ const createHighlighter = ({ coreOptions = {}, highlighterOptions = {} }) => {
*/
const highlightToHtml = (code, lang, meta = {}) =>
shiki
- .codeToHtml(code, { lang, theme, meta, ...highlighterOptions })
+ .codeToHtml(code, {
+ lang: resolveLanguage(lang),
+ theme,
+ meta,
+ ...highlighterOptions,
+ })
// Shiki will always return the Highlighted code encapsulated in a and tag
// since our own CodeBox component handles the tag, we just want to extract
// the inner highlighted code to the CodeBox
@@ -73,10 +103,16 @@ const createHighlighter = ({ coreOptions = {}, highlighterOptions = {} }) => {
* @param {Record} meta - Metadata
*/
const highlightToHast = (code, lang, meta = {}) =>
- shiki.codeToHast(code, { lang, theme, meta, ...highlighterOptions });
+ shiki.codeToHast(code, {
+ lang: resolveLanguage(lang),
+ theme,
+ meta,
+ ...highlighterOptions,
+ });
return {
shiki,
+ resolveLanguage,
highlightToHtml,
highlightToHast,
};