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"]
     }
   }