Skip to content

Commit bb0429e

Browse files
committed
fix: assign display names to same-language code tabs
1 parent 7e3f5d2 commit bb0429e

3 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
'use strict';
2+
3+
import { strictEqual } from 'node:assert';
4+
import { describe, it } from 'node:test';
5+
6+
import remarkParse from 'remark-parse';
7+
import remarkRehype from 'remark-rehype';
8+
import { unified } from 'unified';
9+
import { visit } from 'unist-util-visit';
10+
11+
import codeTabs from '../code-tabs.mjs';
12+
13+
function process(markdown) {
14+
const processor = unified().use(remarkParse).use(remarkRehype).use(codeTabs);
15+
16+
return processor.run(processor.parse(markdown));
17+
}
18+
19+
function collectCodeMeta(tree) {
20+
const meta = [];
21+
22+
visit(tree, 'element', node => {
23+
if (node.tagName === 'code') {
24+
meta.push(node.data?.meta ?? null);
25+
}
26+
});
27+
28+
return meta;
29+
}
30+
31+
describe('codeTabs', () => {
32+
it('assigns display names to consecutive blocks with the same language', async () => {
33+
const tree = await process(`
34+
\`\`\`js
35+
console.log('one');
36+
\`\`\`
37+
38+
\`\`\`js
39+
console.log('two');
40+
\`\`\`
41+
`);
42+
43+
const meta = collectCodeMeta(tree);
44+
45+
strictEqual(meta[0], 'displayName="(1)"');
46+
strictEqual(meta[1], 'displayName="(2)"');
47+
});
48+
49+
it('does not modify blocks when languages are different', async () => {
50+
const tree = await process(`
51+
\`\`\`js
52+
console.log('hello');
53+
\`\`\`
54+
55+
\`\`\`python
56+
print('hello')
57+
\`\`\`
58+
`);
59+
60+
const meta = collectCodeMeta(tree);
61+
62+
strictEqual(meta[0], null);
63+
strictEqual(meta[1], null);
64+
});
65+
66+
it('does not modify a single code block', async () => {
67+
const tree = await process(`
68+
\`\`\`js
69+
console.log('hello');
70+
\`\`\`
71+
`);
72+
73+
const meta = collectCodeMeta(tree);
74+
75+
strictEqual(meta[0], null);
76+
});
77+
});

src/utils/code-tabs.mjs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use strict';
2+
3+
import { SKIP, visit } from 'unist-util-visit';
4+
5+
const languagePrefix = 'language-';
6+
7+
/**
8+
* Checks if a HAST node is a <pre><code> code block.
9+
*
10+
* @param {import('hast').Node} node
11+
* @returns {boolean}
12+
*/
13+
function isCodeBlock(node) {
14+
return Boolean(
15+
node?.tagName === 'pre' && node?.children[0].tagName === 'code'
16+
);
17+
}
18+
19+
/**
20+
* Extracts the language identifier from a <code> element's className.
21+
*
22+
* @param {import('hast').Element} codeElement
23+
* @returns {string}
24+
*/
25+
function getLanguage(codeElement) {
26+
const className = codeElement.properties?.className;
27+
28+
if (!Array.isArray(className)) {
29+
return 'text';
30+
}
31+
32+
const langClass = className.find(
33+
c => typeof c === 'string' && c.startsWith(languagePrefix)
34+
);
35+
36+
return langClass ? langClass.slice(languagePrefix.length) : 'text';
37+
}
38+
39+
/**
40+
* A rehype plugin that assigns display names to consecutive code blocks
41+
* sharing the same language, preventing ambiguous tab labels like "JS | JS".
42+
*
43+
* Must run before @node-core/rehype-shiki so that displayName metadata
44+
* is available when CodeTabs are assembled.
45+
*
46+
* @type {import('unified').Plugin}
47+
*/
48+
export default function codeTabs() {
49+
return function (tree) {
50+
visit(tree, 'element', (node, index, parent) => {
51+
if (!parent || index == null || !isCodeBlock(node)) {
52+
return;
53+
}
54+
55+
const group = [];
56+
let currentIndex = index;
57+
58+
while (isCodeBlock(parent.children[currentIndex])) {
59+
group.push(currentIndex);
60+
61+
const nextNode = parent.children[currentIndex + 1];
62+
currentIndex += nextNode && nextNode.type === 'text' ? 2 : 1;
63+
}
64+
65+
if (group.length < 2) {
66+
return;
67+
}
68+
69+
const languages = group.map(idx =>
70+
getLanguage(parent.children[idx].children[0])
71+
);
72+
73+
const counts = {};
74+
for (const lang of languages) {
75+
counts[lang] = (counts[lang] || 0) + 1;
76+
}
77+
78+
// If no language appears more than once, rehype-shiki handles it fine
79+
const hasDuplicates = Object.values(counts).some(c => c > 1);
80+
81+
if (!hasDuplicates) {
82+
return;
83+
}
84+
85+
// Assign display names like (1), (2) for duplicated languages
86+
const counters = {};
87+
88+
for (let i = 0; i < group.length; i++) {
89+
const lang = languages[i];
90+
91+
if (counts[lang] < 2) {
92+
continue;
93+
}
94+
95+
counters[lang] = (counters[lang] || 0) + 1;
96+
97+
const codeElement = parent.children[group[i]].children[0];
98+
codeElement.data = codeElement.data || {};
99+
codeElement.data.meta =
100+
`${codeElement.data.meta || ''} displayName="(${counters[lang]})"`.trim();
101+
}
102+
103+
return [SKIP];
104+
});
105+
};
106+
}

src/utils/remark.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import remarkRehype from 'remark-rehype';
1212
import remarkStringify from 'remark-stringify';
1313
import { unified } from 'unified';
1414

15+
import codeTabs from './code-tabs.mjs';
1516
import syntaxHighlighter, { highlighter } from './highlighter.mjs';
1617
import { AST_NODE_TYPES } from '../generators/jsx-ast/constants.mjs';
1718
import transformElements from '../generators/jsx-ast/utils/transformer.mjs';
@@ -74,6 +75,7 @@ export const getRemarkRecma = () =>
7475
.use(remarkRehype, { allowDangerousHtml: true, passThrough })
7576
// Any `raw` HTML in the markdown must be converted to AST in order for Recma to understand it
7677
.use(rehypeRaw, { passThrough })
78+
.use(codeTabs)
7779
.use(() => singletonShiki)
7880
.use(transformElements)
7981
.use(rehypeRecma)

0 commit comments

Comments
 (0)