diff --git a/src/utils/__tests__/code-tabs.test.mjs b/src/utils/__tests__/code-tabs.test.mjs new file mode 100644 index 00000000..350239df --- /dev/null +++ b/src/utils/__tests__/code-tabs.test.mjs @@ -0,0 +1,77 @@ +'use strict'; + +import { strictEqual } from 'node:assert'; +import { describe, it } from 'node:test'; + +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import { unified } from 'unified'; +import { visit } from 'unist-util-visit'; + +import codeTabs from '../code-tabs.mjs'; + +function process(markdown) { + const processor = unified().use(remarkParse).use(remarkRehype).use(codeTabs); + + return processor.run(processor.parse(markdown)); +} + +function collectCodeMeta(tree) { + const meta = []; + + visit(tree, 'element', node => { + if (node.tagName === 'code') { + meta.push(node.data?.meta ?? null); + } + }); + + return meta; +} + +describe('codeTabs', () => { + it('assigns display names to consecutive blocks with the same language', async () => { + const tree = await process(` +\`\`\`js +console.log('one'); +\`\`\` + +\`\`\`js +console.log('two'); +\`\`\` + `); + + const meta = collectCodeMeta(tree); + + strictEqual(meta[0], 'displayName="(1)"'); + strictEqual(meta[1], 'displayName="(2)"'); + }); + + it('does not modify blocks when languages are different', async () => { + const tree = await process(` +\`\`\`js +console.log('hello'); +\`\`\` + +\`\`\`python +print('hello') +\`\`\` + `); + + const meta = collectCodeMeta(tree); + + strictEqual(meta[0], null); + strictEqual(meta[1], null); + }); + + it('does not modify a single code block', async () => { + const tree = await process(` +\`\`\`js +console.log('hello'); +\`\`\` + `); + + const meta = collectCodeMeta(tree); + + strictEqual(meta[0], null); + }); +}); diff --git a/src/utils/code-tabs.mjs b/src/utils/code-tabs.mjs new file mode 100644 index 00000000..5d92c9db --- /dev/null +++ b/src/utils/code-tabs.mjs @@ -0,0 +1,106 @@ +'use strict'; + +import { SKIP, visit } from 'unist-util-visit'; + +const languagePrefix = 'language-'; + +/** + * Checks if a HAST node is a
 code block.
+ *
+ * @param {import('hast').Node} node
+ * @returns {boolean}
+ */
+function isCodeBlock(node) {
+  return Boolean(
+    node?.tagName === 'pre' && node?.children[0].tagName === 'code'
+  );
+}
+
+/**
+ * Extracts the language identifier from a  element's className.
+ *
+ * @param {import('hast').Element} codeElement
+ * @returns {string}
+ */
+function getLanguage(codeElement) {
+  const className = codeElement.properties?.className;
+
+  if (!Array.isArray(className)) {
+    return 'text';
+  }
+
+  const langClass = className.find(
+    c => typeof c === 'string' && c.startsWith(languagePrefix)
+  );
+
+  return langClass ? langClass.slice(languagePrefix.length) : 'text';
+}
+
+/**
+ * A rehype plugin that assigns display names to consecutive code blocks
+ * sharing the same language, preventing ambiguous tab labels like "JS | JS".
+ *
+ * Must run before @node-core/rehype-shiki so that displayName metadata
+ * is available when CodeTabs are assembled.
+ *
+ * @type {import('unified').Plugin}
+ */
+export default function codeTabs() {
+  return function (tree) {
+    visit(tree, 'element', (node, index, parent) => {
+      if (!parent || index == null || !isCodeBlock(node)) {
+        return;
+      }
+
+      const group = [];
+      let currentIndex = index;
+
+      while (isCodeBlock(parent.children[currentIndex])) {
+        group.push(currentIndex);
+
+        const nextNode = parent.children[currentIndex + 1];
+        currentIndex += nextNode && nextNode.type === 'text' ? 2 : 1;
+      }
+
+      if (group.length < 2) {
+        return;
+      }
+
+      const languages = group.map(idx =>
+        getLanguage(parent.children[idx].children[0])
+      );
+
+      const counts = {};
+      for (const lang of languages) {
+        counts[lang] = (counts[lang] || 0) + 1;
+      }
+
+      // If no language appears more than once, rehype-shiki handles it fine
+      const hasDuplicates = Object.values(counts).some(c => c > 1);
+
+      if (!hasDuplicates) {
+        return;
+      }
+
+      // Assign display names like (1), (2) for duplicated languages
+      const counters = {};
+
+      for (let i = 0; i < group.length; i++) {
+        const lang = languages[i];
+
+        if (counts[lang] < 2) {
+          continue;
+        }
+
+        counters[lang] = (counters[lang] || 0) + 1;
+
+        const codeElement = parent.children[group[i]].children[0];
+        codeElement.data = codeElement.data || {};
+        codeElement.data.meta =
+          `${codeElement.data.meta || ''} displayName="(${counters[lang]})"`.trim();
+      }
+
+      return [SKIP];
+    });
+  };
+}
diff --git a/src/utils/remark.mjs b/src/utils/remark.mjs
index 2192516b..96a13c2a 100644
--- a/src/utils/remark.mjs
+++ b/src/utils/remark.mjs
@@ -12,6 +12,7 @@ import remarkRehype from 'remark-rehype';
 import remarkStringify from 'remark-stringify';
 import { unified } from 'unified';
 
+import codeTabs from './code-tabs.mjs';
 import syntaxHighlighter, { highlighter } from './highlighter.mjs';
 import { AST_NODE_TYPES } from '../generators/jsx-ast/constants.mjs';
 import transformElements from '../generators/jsx-ast/utils/transformer.mjs';
@@ -74,6 +75,7 @@ export const getRemarkRecma = () =>
     .use(remarkRehype, { allowDangerousHtml: true, passThrough })
     // Any `raw` HTML in the markdown must be converted to AST in order for Recma to understand it
     .use(rehypeRaw, { passThrough })
+    .use(codeTabs)
     .use(() => singletonShiki)
     .use(transformElements)
     .use(rehypeRecma)