diff --git a/src/utils/__tests__/remark.test.mjs b/src/utils/__tests__/remark.test.mjs
new file mode 100644
index 00000000..6f5486d6
--- /dev/null
+++ b/src/utils/__tests__/remark.test.mjs
@@ -0,0 +1,64 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { getRemarkRecma } from '../remark.mjs';
+
+const getCodeTabsAttributes = tree => {
+ const attributes = [];
+
+ const visit = node => {
+ if (!node || typeof node !== 'object') {
+ return;
+ }
+
+ if (node.type === 'JSXOpeningElement' && node.name?.name === 'CodeTabs') {
+ attributes.push(
+ Object.fromEntries(
+ node.attributes.map(attribute => [
+ attribute.name.name,
+ attribute.value?.value,
+ ])
+ )
+ );
+ }
+
+ Object.values(node).forEach(value => {
+ if (Array.isArray(value)) {
+ value.forEach(visit);
+ } else {
+ visit(value);
+ }
+ });
+ };
+
+ visit(tree);
+
+ return attributes;
+};
+
+describe('getRemarkRecma', () => {
+ it('preserves code tab display names when raw HTML is enabled', async () => {
+ const processor = getRemarkRecma();
+ const tree = await processor.run(
+ processor.parse(`
+
raw html
+
+\`\`\`cjs displayName="main.js"
+console.log(1);
+\`\`\`
+
+\`\`\`cjs displayName="main.test.js"
+console.log(2);
+\`\`\`
+`)
+ );
+
+ assert.deepEqual(getCodeTabsAttributes(tree), [
+ {
+ languages: 'cjs|cjs',
+ displayNames: 'main.js|main.test.js',
+ defaultTab: '0',
+ },
+ ]);
+ });
+});
diff --git a/src/utils/remark.mjs b/src/utils/remark.mjs
index 58550945..18385866 100644
--- a/src/utils/remark.mjs
+++ b/src/utils/remark.mjs
@@ -12,6 +12,7 @@ import remarkParse from 'remark-parse';
import remarkRehype from 'remark-rehype';
import remarkStringify from 'remark-stringify';
import { unified } from 'unified';
+import { visit } from 'unist-util-visit';
import syntaxHighlighter, { highlighter } from './highlighter.mjs';
import { lazy } from './misc.mjs';
@@ -20,6 +21,35 @@ import transformAlerts from '../generators/jsx-ast/utils/plugins/alerts.mjs';
import transformElements from '../generators/jsx-ast/utils/plugins/transformer.mjs';
const passThrough = ['element', ...Object.values(AST_NODE_TYPES.MDX)];
+const codeMetaProperty = 'codeMeta';
+
+/**
+ * Stores fenced code metadata on properties before rehypeRaw reparses the tree.
+ */
+const preserveCodeMeta = () => tree => {
+ visit(tree, 'element', node => {
+ const meta = node.data?.meta;
+
+ if (node.tagName === 'code' && typeof meta === 'string') {
+ node.properties ||= {};
+ node.properties[codeMetaProperty] = meta;
+ }
+ });
+};
+
+/**
+ * Restores fenced code metadata so the Shiki plugin can read displayName.
+ */
+const restoreCodeMeta = () => tree => {
+ visit(tree, 'element', node => {
+ const meta = node.properties?.[codeMetaProperty];
+
+ if (node.tagName === 'code' && typeof meta === 'string') {
+ node.data = { ...node.data, meta };
+ delete node.properties[codeMetaProperty];
+ }
+ });
+};
/**
* Retrieves an instance of Remark configured to parse GFM (GitHub Flavored Markdown)
@@ -91,8 +121,10 @@ export const getRemarkRecma = lazy(() =>
// We also allow dangerous HTML to be passed through, since we have HTML within our Markdown
// and we trust the sources of the Markdown files
.use(remarkRehype, { allowDangerousHtml: true, passThrough })
+ .use(preserveCodeMeta)
// Any `raw` HTML in the markdown must be converted to AST in order for Recma to understand it
.use(rehypeRaw, { passThrough })
+ .use(restoreCodeMeta)
.use(() => singletonShiki)
.use(transformElements)
.use(rehypeRecma)