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)