diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs
index b4dd6007b8de62..a99a21ec852abc 100644
--- a/scripts/generate-md-exports.mjs
+++ b/scripts/generate-md-exports.mjs
@@ -27,10 +27,12 @@ import remarkStringify from 'remark-stringify';
import {unified} from 'unified';
import {remove} from 'unist-util-remove';
+import {rehypeExpandCodeTabs} from './rehype-expand-code-tabs.mjs';
+
const DOCS_ORIGIN = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
? 'https://develop.sentry.dev'
: 'https://docs.sentry.io';
-const CACHE_VERSION = 7;
+const CACHE_VERSION = 8;
const CACHE_COMPRESS_LEVEL = 4;
const R2_BUCKET = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
? 'sentry-develop-docs'
@@ -1004,6 +1006,7 @@ async function genMDFromHTML(source, {cacheDir, noCache, usedCacheFiles}) {
properties: {},
children: tree,
}))
+ .use(rehypeExpandCodeTabs)
.use(rehypeRemark, {
document: false,
handlers: {
diff --git a/scripts/generate-md-exports.test.mjs b/scripts/generate-md-exports.test.mjs
new file mode 100644
index 00000000000000..bca320add3be49
--- /dev/null
+++ b/scripts/generate-md-exports.test.mjs
@@ -0,0 +1,217 @@
+import rehypeParse from 'rehype-parse';
+import rehypeRemark from 'rehype-remark';
+import remarkGfm from 'remark-gfm';
+import remarkStringify from 'remark-stringify';
+import {describe, expect, it} from 'vitest';
+import {unified} from 'unified';
+import {remove} from 'unist-util-remove';
+
+import {rehypeExpandCodeTabs} from './rehype-expand-code-tabs.mjs';
+
+function htmlToMarkdown(html) {
+ return String(
+ unified()
+ .use(rehypeParse)
+ .use(rehypeExpandCodeTabs)
+ .use(rehypeRemark, {
+ document: false,
+ handlers: {
+ button() {},
+ },
+ })
+ .use(() => tree => remove(tree, {type: 'inlineCode', value: ''}))
+ .use(remarkGfm)
+ .use(remarkStringify)
+ .processSync(html)
+ );
+}
+
+function buildCodeTabsHTML(tabs) {
+ const firstTab = tabs[0];
+
+ const codeTabsRendered =
+ '
' +
+ tabs.map((t, i) => `
`).join('') +
+ '
' +
+ `
${firstTab.filename || ''}` +
+ `
${firstTab.code}
` +
+ '
' +
+ '
';
+
+ const exportBlocks = tabs
+ .map(t => {
+ const filenameAttr = t.filename ? ` data-code-tab-filename="${t.filename}"` : '';
+ return (
+ `'
+ );
+ })
+ .join('');
+
+ return `${codeTabsRendered}${exportBlocks}
`;
+}
+
+describe('rehypeExpandCodeTabs', () => {
+ it('outputs one fenced code block per tab with "[Title] filename" headings', () => {
+ const html = buildCodeTabsHTML([
+ {
+ title: 'Cloudflare Workers',
+ filename: 'index.ts',
+ lang: 'typescript',
+ code: 'import { sentry } from "@sentry/hono/cloudflare";',
+ },
+ {
+ title: 'Node.js',
+ filename: 'app.ts',
+ lang: 'typescript',
+ code: 'import { sentry } from "@sentry/hono/node";',
+ },
+ {
+ title: 'Bun',
+ filename: 'index.ts',
+ lang: 'typescript',
+ code: 'import { sentry } from "@sentry/hono/bun";',
+ },
+ ]);
+
+ const md = htmlToMarkdown(html);
+
+ const codeBlocks = md.match(/```[\s\S]*?```/g);
+ expect(codeBlocks).toHaveLength(3);
+ expect(codeBlocks[0]).toContain('@sentry/hono/cloudflare');
+ expect(codeBlocks[1]).toContain('@sentry/hono/node');
+ expect(codeBlocks[2]).toContain('@sentry/hono/bun');
+ expect(md).toContain('**\\[Cloudflare Workers] index.ts**');
+ expect(md).toContain('**\\[Node.js] app.ts**');
+ expect(md).toContain('**\\[Bun] index.ts**');
+ });
+
+ it('removes the CodeTabs-rendered active tab to avoid duplication', () => {
+ const html = buildCodeTabsHTML([
+ {title: 'ESM', filename: 'instrument.mjs', lang: 'javascript', code: 'import init'},
+ {title: 'CJS', filename: 'instrument.js', lang: 'javascript', code: 'require init'},
+ ]);
+
+ const md = htmlToMarkdown(html);
+
+ expect(md).not.toContain('`instrument.mjs`');
+ });
+
+ it('uses tab title alone when filename is absent', () => {
+ const html = buildCodeTabsHTML([
+ {title: 'Cloudflare Workers', lang: 'javascript', code: 'workers();'},
+ {title: 'Bun', lang: 'javascript', code: 'bun();'},
+ ]);
+
+ const md = htmlToMarkdown(html);
+
+ const headings = md.match(/\*\*.*?\*\*/g);
+ expect(headings).toHaveLength(2);
+ expect(headings[0]).toBe('**Cloudflare Workers**');
+ expect(headings[1]).toBe('**Bun**');
+ });
+
+ it('treats empty filename attribute the same as missing filename', () => {
+ const html =
+ '';
+
+ const md = htmlToMarkdown(html);
+
+ const headings = md.match(/\*\*.*?\*\*/g);
+ expect(headings).toHaveLength(1);
+ expect(headings[0]).toBe('**JavaScript**');
+ });
+
+ it('does not modify code blocks outside tab wrappers', () => {
+ const html =
+ 'curl -sL https://sentry.io/get-cli/ | bash
';
+
+ const md = htmlToMarkdown(html);
+
+ const codeBlocks = md.match(/```[\s\S]*?```/g);
+ expect(codeBlocks).toHaveLength(1);
+ expect(codeBlocks[0]).toContain('curl -sL');
+ expect(md).not.toMatch(/\*\*.*\*\*\n/);
+ });
+
+ it('preserves standalone code blocks when mixed with tab groups', () => {
+ const standalone =
+ 'npm install @sentry/node
';
+ const tabs = buildCodeTabsHTML([
+ {
+ title: 'Node.js',
+ filename: 'instrument.mjs',
+ lang: 'javascript',
+ code: 'Sentry.init();',
+ },
+ {title: 'Bun', lang: 'javascript', code: 'init();'},
+ ]);
+
+ const md = htmlToMarkdown(`${standalone}${tabs}
`);
+
+ const codeBlocks = md.match(/```[\s\S]*?```/g);
+ expect(codeBlocks).toHaveLength(3);
+ expect(codeBlocks[0]).toContain('npm install');
+ expect(md).toContain('**\\[Node.js] instrument.mjs**');
+ expect(md).toContain('**Bun**');
+ });
+
+ it('expands multiple tab groups independently on the same page', () => {
+ const group1 = buildCodeTabsHTML([
+ {title: 'ESM', filename: 'instrument.mjs', lang: 'javascript', code: 'import init'},
+ {title: 'CJS', filename: 'instrument.js', lang: 'javascript', code: 'require init'},
+ ]);
+ const group2 = buildCodeTabsHTML([
+ {title: 'Python', filename: 'main.py', lang: 'python', code: 'import sentry_sdk'},
+ {title: 'Ruby', filename: 'config.rb', lang: 'ruby', code: 'require "sentry-ruby"'},
+ ]);
+
+ const md = htmlToMarkdown(`${group1}${group2}
`);
+
+ const codeBlocks = md.match(/```[\s\S]*?```/g);
+ expect(codeBlocks).toHaveLength(4);
+ expect(codeBlocks[0]).toContain('import init');
+ expect(codeBlocks[1]).toContain('require init');
+ expect(codeBlocks[2]).toContain('import sentry_sdk');
+ expect(codeBlocks[3]).toContain('require "sentry-ruby"');
+ });
+
+ it('drops export blocks that contain no pre element', () => {
+ const html =
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+
+ const md = htmlToMarkdown(html);
+
+ const codeBlocks = md.match(/```[\s\S]*?```/g);
+ expect(codeBlocks).toHaveLength(1);
+ expect(codeBlocks[0]).toContain('works()');
+ expect(md).toContain('**ok**');
+ expect(md).not.toContain('broken');
+ expect(md).not.toContain('active tab');
+ });
+
+ it('leaves wrapper unchanged when it has no export blocks', () => {
+ const html =
+ '';
+
+ const md = htmlToMarkdown(html);
+
+ const codeBlocks = md.match(/```[\s\S]*?```/g);
+ expect(codeBlocks).toHaveLength(1);
+ expect(codeBlocks[0]).toContain('solo()');
+ });
+});
diff --git a/scripts/rehype-expand-code-tabs.mjs b/scripts/rehype-expand-code-tabs.mjs
new file mode 100644
index 00000000000000..f6f0a528cffa34
--- /dev/null
+++ b/scripts/rehype-expand-code-tabs.mjs
@@ -0,0 +1,79 @@
+import {visit} from 'unist-util-visit';
+
+/**
+ * Rehype plugin that expands CodeTabs for markdown export.
+ *
+ * The remark-code-tabs plugin injects hidden
+ * blocks alongside the interactive component inside each
+ * .code-tabs-wrapper. These hidden blocks contain the raw code for every
+ * tab and are always present in the static HTML (unlike CodeTabs output,
+ * which may only include the active tab due to RSC serialization).
+ *
+ * This plugin:
+ * 1. Finds parent elements that contain [data-code-tab-title] children
+ * 2. Replaces ALL children with expanded export blocks (removing the
+ * CodeTabs-rendered content to avoid duplication)
+ * 3. Each export block becomes a bold heading + fenced code block.
+ * The heading format is "[Tab Title] filename" when both exist,
+ * or just the tab title / filename when only one is present
+ */
+export function rehypeExpandCodeTabs() {
+ return tree => {
+ visit(tree, 'element', node => {
+ if (!node.children) {
+ return;
+ }
+
+ const exportBlocks = node.children.filter(
+ child => child.type === 'element' && child.properties?.dataCodeTabTitle
+ );
+ if (exportBlocks.length === 0) {
+ return;
+ }
+
+ node.children = exportBlocks.flatMap(block => {
+ const title = block.properties.dataCodeTabTitle;
+ const filename = block.properties.dataCodeTabFilename;
+ const label = filename && title ? `[${title}] ${filename}` : filename || title;
+
+ const preElements = collectAll(block, el => el.tagName === 'pre');
+ if (preElements.length === 0) {
+ return [];
+ }
+
+ return [
+ {
+ type: 'element',
+ tagName: 'p',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'strong',
+ properties: {},
+ children: [{type: 'text', value: label}],
+ },
+ ],
+ },
+ ...preElements,
+ ];
+ });
+ });
+ };
+}
+
+function collectAll(node, predicate) {
+ const results = [];
+ (function walk(n) {
+ if (n.type === 'element' && predicate(n)) {
+ results.push(n);
+ return;
+ }
+ if (n.children) {
+ for (const child of n.children) {
+ walk(child);
+ }
+ }
+ })(node);
+ return results;
+}
diff --git a/src/remark-code-tabs.js b/src/remark-code-tabs.js
index c613776cb27ac8..2f1bdb654accab 100644
--- a/src/remark-code-tabs.js
+++ b/src/remark-code-tabs.js
@@ -66,6 +66,24 @@ export default function remarkCodeTabs() {
[]
);
+ const exportBlocks = pendingCode.map(([node]) => {
+ const title = getTabTitle(node);
+ const filename = getFilename(node);
+ const lang = fixLanguage(node);
+ const hProperties = {
+ hidden: true,
+ dataCodeTabTitle: title || lang,
+ };
+ if (filename) {
+ hProperties.dataCodeTabFilename = filename;
+ }
+ return {
+ type: 'element',
+ data: {hName: 'div', hProperties},
+ children: [{type: 'code', lang, value: node.value}],
+ };
+ });
+
rootNode.type = 'element';
rootNode.data = {
hName: 'div',
@@ -79,6 +97,7 @@ export default function remarkCodeTabs() {
name: 'CodeTabs',
children,
},
+ ...exportBlocks,
];
toRemove = toRemove.concat(pendingCode.splice(1));