diff --git a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx index 0497ca0d234a9..7e18a7bea4b9b 100644 --- a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx +++ b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx @@ -1,6 +1,6 @@ 'use client'; -import { highlightToHtml } from '@node-core/rehype-shiki'; +import { highlightToHtml } from '@node-core/rehype-shiki/minimal'; import AlertBox from '@node-core/ui-components/Common/AlertBox'; import Skeleton from '@node-core/ui-components/Common/Skeleton'; import { useTranslations } from 'next-intl'; diff --git a/apps/site/next.mdx.plugins.mjs b/apps/site/next.mdx.plugins.mjs index 016407b360422..a857a298de193 100644 --- a/apps/site/next.mdx.plugins.mjs +++ b/apps/site/next.mdx.plugins.mjs @@ -1,6 +1,6 @@ 'use strict'; -import rehypeShikiji from '@node-core/rehype-shiki'; +import rehypeShikiji from '@node-core/rehype-shiki/plugin'; import remarkHeadings from '@vcarl/remark-headings'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypeSlug from 'rehype-slug'; diff --git a/packages/rehype-shiki/package.json b/packages/rehype-shiki/package.json index ed32e84e52e9e..09a03b003e373 100644 --- a/packages/rehype-shiki/package.json +++ b/packages/rehype-shiki/package.json @@ -1,11 +1,14 @@ { "name": "@node-core/rehype-shiki", "type": "module", - "main": "./src/index.mjs", - "module": "./src/index.mjs", + "exports": { + ".": "./src/index.mjs", + "./*": "./src/*.mjs" + }, "scripts": { "lint:js": "eslint \"**/*.mjs\"", - "test": "node --test" + "test": "turbo test:unit", + "test:unit": "node --experimental-test-coverage --experimental-test-module-mocks --test \"**/*.test.mjs\"" }, "dependencies": { "@shikijs/core": "^3.3.0", diff --git a/packages/rehype-shiki/src/__tests__/highlighter.test.mjs b/packages/rehype-shiki/src/__tests__/highlighter.test.mjs new file mode 100644 index 0000000000000..7ab0fcaa6e1b0 --- /dev/null +++ b/packages/rehype-shiki/src/__tests__/highlighter.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; + +// Mock dependencies +const mockShiki = { + codeToHtml: mock.fn(() => '
highlighted code'),
+ codeToHast: mock.fn(() => ({ type: 'element', tagName: 'pre' })),
+};
+
+mock.module('@shikijs/core', {
+ namedExports: { createHighlighterCoreSync: () => mockShiki },
+});
+
+mock.module('@shikijs/engine-javascript', {
+ namedExports: { createJavaScriptRegexEngine: () => ({}) },
+});
+
+mock.module('shiki/themes/nord.mjs', {
+ defaultExport: { name: 'nord', colors: { 'editor.background': '#2e3440' } },
+});
+
+describe('createHighlighter', async () => {
+ const { createHighlighter } = await import('../highlighter.mjs');
+
+ describe('getLanguageDisplayName', () => {
+ it('returns display name for known languages', () => {
+ const langs = [
+ { name: 'javascript', displayName: 'JavaScript', aliases: ['js'] },
+ ];
+ const highlighter = createHighlighter({ langs });
+
+ assert.strictEqual(
+ highlighter.getLanguageDisplayName('javascript'),
+ 'JavaScript'
+ );
+ assert.strictEqual(
+ highlighter.getLanguageDisplayName('js'),
+ 'JavaScript'
+ );
+ });
+
+ it('returns original name for unknown languages', () => {
+ const highlighter = createHighlighter({ langs: [] });
+ assert.strictEqual(
+ highlighter.getLanguageDisplayName('unknown'),
+ 'unknown'
+ );
+ });
+ });
+
+ describe('highlightToHtml', () => {
+ it('extracts inner HTML from code tag', () => {
+ mockShiki.codeToHtml.mock.mockImplementationOnce(
+ () => 'const x = 1;'
+ );
+
+ const highlighter = createHighlighter({});
+ const result = highlighter.highlightToHtml('const x = 1;', 'javascript');
+
+ assert.strictEqual(result, 'const x = 1;');
+ });
+ });
+
+ describe('highlightToHast', () => {
+ it('returns HAST tree from shiki', () => {
+ const expectedHast = { type: 'element', tagName: 'pre' };
+ mockShiki.codeToHast.mock.mockImplementationOnce(() => expectedHast);
+
+ const highlighter = createHighlighter({});
+ const result = highlighter.highlightToHast('code', 'javascript');
+
+ assert.deepStrictEqual(result, expectedHast);
+ });
+ });
+});
diff --git a/packages/rehype-shiki/src/__tests__/languages.test.mjs b/packages/rehype-shiki/src/__tests__/languages.test.mjs
deleted file mode 100644
index 0900f25ff365b..0000000000000
--- a/packages/rehype-shiki/src/__tests__/languages.test.mjs
+++ /dev/null
@@ -1,27 +0,0 @@
-import assert from 'node:assert/strict';
-import { it, describe } from 'node:test';
-
-import { getLanguageDisplayName, LANGUAGES } from '../languages.mjs';
-
-LANGUAGES.splice(
- 0,
- LANGUAGES.length,
- { name: 'javascript', aliases: ['js'], displayName: 'JavaScript' },
- { name: 'typescript', aliases: ['ts'], displayName: 'TypeScript' }
-);
-
-describe('getLanguageDisplayName', async () => {
- it('should return the display name for a known language', () => {
- assert.equal(getLanguageDisplayName('javascript'), 'JavaScript');
- assert.equal(getLanguageDisplayName('js'), 'JavaScript');
- });
-
- it('should return the display name for another known language', () => {
- assert.equal(getLanguageDisplayName('typescript'), 'TypeScript');
- assert.equal(getLanguageDisplayName('ts'), 'TypeScript');
- });
-
- it('should return the input language if it is not known', () => {
- assert.equal(getLanguageDisplayName('unknown'), 'unknown');
- });
-});
diff --git a/packages/rehype-shiki/src/__tests__/plugin.test.mjs b/packages/rehype-shiki/src/__tests__/plugin.test.mjs
new file mode 100644
index 0000000000000..11f545ad28ba3
--- /dev/null
+++ b/packages/rehype-shiki/src/__tests__/plugin.test.mjs
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import { describe, it, mock } from 'node:test';
+
+// Simplified mocks - only mock what's actually needed
+mock.module('../index.mjs', {
+ namedExports: { highlightToHast: mock.fn(() => ({ children: [] })) },
+});
+
+mock.module('classnames', {
+ defaultExport: (...args) => args.filter(Boolean).join(' '),
+});
+
+mock.module('hast-util-to-string', {
+ namedExports: { toString: () => 'code' },
+});
+
+const mockVisit = mock.fn();
+mock.module('unist-util-visit', {
+ namedExports: { visit: mockVisit, SKIP: Symbol() },
+});
+
+describe('rehypeShikiji', async () => {
+ const { default: rehypeShikiji } = await import('../plugin.mjs');
+ const mockTree = { type: 'root', children: [] };
+
+ it('calls visit twice', () => {
+ mockVisit.mock.resetCalls();
+ rehypeShikiji()(mockTree);
+ assert.strictEqual(mockVisit.mock.calls.length, 2);
+ });
+
+ it('creates CodeTabs for multiple code blocks', () => {
+ const parent = {
+ children: [
+ {
+ tagName: 'pre',
+ children: [
+ {
+ tagName: 'code',
+ data: { meta: 'displayName="JS"' },
+ properties: { className: ['language-js'] },
+ },
+ ],
+ },
+ {
+ tagName: 'pre',
+ children: [
+ {
+ tagName: 'code',
+ data: { meta: 'displayName="TS"' },
+ properties: { className: ['language-ts'] },
+ },
+ ],
+ },
+ ],
+ };
+
+ mockVisit.mock.mockImplementation((tree, selector, visitor) => {
+ if (selector === 'element') {
+ visitor(parent.children[0], 0, parent);
+ }
+ });
+
+ rehypeShikiji()(mockTree);
+ assert.ok(parent.children.some(child => child.tagName === 'CodeTabs'));
+ });
+});
diff --git a/packages/rehype-shiki/src/highlighter.mjs b/packages/rehype-shiki/src/highlighter.mjs
index a31fda8da7300..9f37e26ae179e 100644
--- a/packages/rehype-shiki/src/highlighter.mjs
+++ b/packages/rehype-shiki/src/highlighter.mjs
@@ -1,47 +1,67 @@
import { createHighlighterCoreSync } from '@shikijs/core';
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript';
+import shikiNordTheme from 'shiki/themes/nord.mjs';
-import { LANGUAGES, DEFAULT_THEME } from './languages.mjs';
-
-let _shiki;
-
-/**
- * Lazy-load and memoize the minimal Shikiji Syntax Highlighter
- * @returns {import('@shikijs/core').HighlighterCore}
- */
-export const getShiki = () => {
- if (!_shiki) {
- _shiki = createHighlighterCoreSync({
- themes: [DEFAULT_THEME],
- langs: LANGUAGES,
- // Let's use Shiki's new Experimental JavaScript-based regex engine!
- engine: createJavaScriptRegexEngine(),
- });
- }
- return _shiki;
+const DEFAULT_THEME = {
+ // We are updating this color because the background color and comment text color
+ // in the Codebox component do not comply with accessibility standards
+ // @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
+ colorReplacements: { '#616e88': '#707e99' },
+ ...shikiNordTheme,
};
/**
- * Highlights code and returns the inner HTML inside the tag
- *
- * @param {string} code - The code to highlight
- * @param {string} language - The programming language to use for highlighting
- * @returns {string} The inner HTML of the highlighted code
+ * Creates a syntax highlighter with utility functions
+ * @param {import('@shikijs/core').HighlighterCoreOptions} options - Configuration options for the highlighter
*/
-export const highlightToHtml = (code, language) =>
- getShiki()
- .codeToHtml(code, { lang: language, theme: DEFAULT_THEME })
- // 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
- .match(/(.+?)<\/code>/s)[1];
+export const createHighlighter = options => {
+ const shiki = createHighlighterCoreSync({
+ themes: [DEFAULT_THEME],
+ engine: createJavaScriptRegexEngine(),
+ ...options,
+ });
-/**
- * Highlights code and returns a HAST tree
- *
- * @param {string} code - The code to highlight
- * @param {string} language - The programming language to use for highlighting
- * @returns {import('hast').Element} The HAST representation of the highlighted code
- */
-export const highlightToHast = (code, language) =>
- getShiki().codeToHast(code, { lang: language, theme: DEFAULT_THEME });
+ const theme = options.themes?.[0] ?? DEFAULT_THEME;
+ const langs = options.langs ?? [];
+
+ const getLanguageDisplayName = language => {
+ const languageByIdOrAlias = langs.find(
+ ({ name, aliases }) =>
+ name.toLowerCase() === language.toLowerCase() ||
+ (aliases !== undefined && aliases.includes(language.toLowerCase()))
+ );
+
+ return languageByIdOrAlias?.displayName ?? language;
+ };
+
+ /**
+ * Highlights code and returns the inner HTML inside the tag
+ *
+ * @param {string} code - The code to highlight
+ * @param {string} language - The programming language to use for highlighting
+ * @returns {string} The inner HTML of the highlighted code
+ */
+ const highlightToHtml = (code, language) =>
+ shiki
+ .codeToHtml(code, { lang: language, theme })
+ // 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
+ .match(/(.+?)<\/code>/s)[1];
+
+ /**
+ * Highlights code and returns a HAST tree
+ *
+ * @param {string} code - The code to highlight
+ * @param {string} language - The programming language to use for highlighting
+ */
+ const highlightToHast = (code, language) =>
+ shiki.codeToHast(code, { lang: language, theme });
+
+ return {
+ shiki,
+ getLanguageDisplayName,
+ highlightToHtml,
+ highlightToHast,
+ };
+};
diff --git a/packages/rehype-shiki/src/index.mjs b/packages/rehype-shiki/src/index.mjs
index 68cfc211ea64c..1a7e144a46719 100644
--- a/packages/rehype-shiki/src/index.mjs
+++ b/packages/rehype-shiki/src/index.mjs
@@ -1,5 +1,43 @@
-import { rehypeShikiji } from './plugin.mjs';
-export * from './highlighter.mjs';
-export * from './languages.mjs';
+import cLanguage from 'shiki/langs/c.mjs';
+import coffeeScriptLanguage from 'shiki/langs/coffeescript.mjs';
+import cPlusPlusLanguage from 'shiki/langs/cpp.mjs';
+import diffLanguage from 'shiki/langs/diff.mjs';
+import dockerLanguage from 'shiki/langs/docker.mjs';
+import httpLanguage from 'shiki/langs/http.mjs';
+import iniLanguage from 'shiki/langs/ini.mjs';
+import javaScriptLanguage from 'shiki/langs/javascript.mjs';
+import jsonLanguage from 'shiki/langs/json.mjs';
+import powershellLanguage from 'shiki/langs/powershell.mjs';
+import shellScriptLanguage from 'shiki/langs/shellscript.mjs';
+import shellSessionLanguage from 'shiki/langs/shellsession.mjs';
+import typeScriptLanguage from 'shiki/langs/typescript.mjs';
+import yamlLanguage from 'shiki/langs/yaml.mjs';
-export default rehypeShikiji;
+import { createHighlighter } from './highlighter.mjs';
+
+const { shiki, getLanguageDisplayName, highlightToHast, highlightToHtml } =
+ createHighlighter({
+ langs: [
+ ...cLanguage,
+ ...coffeeScriptLanguage,
+ ...cPlusPlusLanguage,
+ ...diffLanguage,
+ ...dockerLanguage,
+ ...httpLanguage,
+ ...iniLanguage,
+ {
+ ...javaScriptLanguage[0],
+ // We patch the JavaScript language to include the CommonJS and ES Module aliases
+ // that are commonly used (non-standard aliases) within our API docs and Blog posts
+ aliases: javaScriptLanguage[0].aliases.concat('cjs', 'mjs'),
+ },
+ ...jsonLanguage,
+ ...powershellLanguage,
+ ...shellScriptLanguage,
+ ...shellSessionLanguage,
+ ...typeScriptLanguage,
+ ...yamlLanguage,
+ ],
+ });
+
+export { shiki, getLanguageDisplayName, highlightToHast, highlightToHtml };
diff --git a/packages/rehype-shiki/src/languages.mjs b/packages/rehype-shiki/src/languages.mjs
deleted file mode 100644
index d9764697ff335..0000000000000
--- a/packages/rehype-shiki/src/languages.mjs
+++ /dev/null
@@ -1,60 +0,0 @@
-'use strict';
-
-import diffLanguage from 'shiki/langs/diff.mjs';
-import dockerLanguage from 'shiki/langs/docker.mjs';
-import iniLanguage from 'shiki/langs/ini.mjs';
-import javaScriptLanguage from 'shiki/langs/javascript.mjs';
-import jsonLanguage from 'shiki/langs/json.mjs';
-import powershellLanguage from 'shiki/langs/powershell.mjs';
-import shellScriptLanguage from 'shiki/langs/shellscript.mjs';
-import shellSessionLanguage from 'shiki/langs/shellsession.mjs';
-import typeScriptLanguage from 'shiki/langs/typescript.mjs';
-import yamlLanguage from 'shiki/langs/yaml.mjs';
-import shikiNordTheme from 'shiki/themes/nord.mjs';
-
-/**
- * All languages needed within the Node.js website for syntax highlighting.
- *
- * @type {Array}
- */
-export const LANGUAGES = [
- {
- ...javaScriptLanguage[0],
- // We path the JavaScript language to include the CommonJS and ES Module aliases
- // that are commonly used (non-standard aliases) within our API docs and Blog posts
- aliases: javaScriptLanguage[0].aliases.concat('cjs', 'mjs'),
- },
- ...iniLanguage,
- ...jsonLanguage,
- ...typeScriptLanguage,
- ...shellScriptLanguage,
- ...powershellLanguage,
- ...shellSessionLanguage,
- ...dockerLanguage,
- ...diffLanguage,
- ...yamlLanguage,
-];
-
-// This is the default theme we use for our Shiki Syntax Highlighter
-export const DEFAULT_THEME = {
- // We updating this color because the background color and comment text color
- // in the Codebox component do not comply with accessibility standards
- // @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
- colorReplacements: { '#616e88': '#707e99' },
- ...shikiNordTheme,
-};
-
-/**
- * Get the display name of a given language
- * @param {string} language The language ID
- * @returns {string} The display name of the language, or the input as a fallback
- */
-export const getLanguageDisplayName = language => {
- const languageByIdOrAlias = LANGUAGES.find(
- ({ name, aliases }) =>
- name.toLowerCase() === language.toLowerCase() ||
- (aliases !== undefined && aliases.includes(language.toLowerCase()))
- );
-
- return languageByIdOrAlias?.displayName ?? language;
-};
diff --git a/packages/rehype-shiki/src/minimal.mjs b/packages/rehype-shiki/src/minimal.mjs
new file mode 100644
index 0000000000000..cdbb5c8f2ca9c
--- /dev/null
+++ b/packages/rehype-shiki/src/minimal.mjs
@@ -0,0 +1,11 @@
+import powershellLanguage from 'shiki/langs/powershell.mjs';
+import shellScriptLanguage from 'shiki/langs/shellscript.mjs';
+
+import { createHighlighter } from './highlighter.mjs';
+
+const { shiki, getLanguageDisplayName, highlightToHast, highlightToHtml } =
+ createHighlighter({
+ langs: [...powershellLanguage, ...shellScriptLanguage],
+ });
+
+export { shiki, getLanguageDisplayName, highlightToHast, highlightToHtml };
diff --git a/packages/rehype-shiki/src/plugin.mjs b/packages/rehype-shiki/src/plugin.mjs
index d486615296c40..c07416a4db326 100644
--- a/packages/rehype-shiki/src/plugin.mjs
+++ b/packages/rehype-shiki/src/plugin.mjs
@@ -4,7 +4,7 @@ import classNames from 'classnames';
import { toString } from 'hast-util-to-string';
import { SKIP, visit } from 'unist-util-visit';
-import { highlightToHast } from './highlighter.mjs';
+import { highlightToHast } from './index.mjs';
// This is what Remark will use as prefix within a className
// to attribute the current language of the element
@@ -53,7 +53,7 @@ function isCodeBlock(node) {
);
}
-export function rehypeShikiji() {
+export default function rehypeShikiji() {
return function (tree) {
visit(tree, 'element', (_, index, parent) => {
const languages = [];
diff --git a/packages/rehype-shiki/turbo.json b/packages/rehype-shiki/turbo.json
index aa0ce04f53ac8..1dd6dce7da26a 100644
--- a/packages/rehype-shiki/turbo.json
+++ b/packages/rehype-shiki/turbo.json
@@ -6,7 +6,7 @@
"lint:js": {
"inputs": ["src/**/*.mjs"]
},
- "test": {
+ "test:unit": {
"inputs": ["src/**/*.mjs"]
}
}