Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/utils/__tests__/code-tabs.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
106 changes: 106 additions & 0 deletions src/utils/code-tabs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use strict';

import { SKIP, visit } from 'unist-util-visit';

const languagePrefix = 'language-';

/**
* Checks if a HAST node is a <pre><code> 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 <code> 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];
});
};
}
2 changes: 2 additions & 0 deletions src/utils/remark.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
Loading