Skip to content
Open
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
5 changes: 4 additions & 1 deletion scripts/generate-md-exports.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1004,6 +1006,7 @@ async function genMDFromHTML(source, {cacheDir, noCache, usedCacheFiles}) {
properties: {},
children: tree,
}))
.use(rehypeExpandCodeTabs)
Comment thread
sentry[bot] marked this conversation as resolved.
.use(rehypeRemark, {
document: false,
handlers: {
Expand Down
217 changes: 217 additions & 0 deletions scripts/generate-md-exports.test.mjs
Original file line number Diff line number Diff line change
@@ -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 =
'<div>' +
tabs.map((t, i) => `<button data-active="${i === 0}">${t.title}</button>`).join('') +
'<div class="code-block">' +
`<code class="filename">${firstTab.filename || ''}</code>` +
`<pre class="language-${firstTab.lang}"><code>${firstTab.code}</code></pre>` +
'</div>' +
'</div>';

const exportBlocks = tabs
.map(t => {
const filenameAttr = t.filename ? ` data-code-tab-filename="${t.filename}"` : '';
return (
`<div hidden data-code-tab-title="${t.title}"${filenameAttr}>` +
`<pre class="language-${t.lang}"><code>${t.code}</code></pre>` +
'</div>'
);
})
.join('');

return `<div class="code-tabs-wrapper">${codeTabsRendered}${exportBlocks}</div>`;
}

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 =
'<div class="code-tabs-wrapper">' +
'<div><button>Tab</button><div class="code-block"><code class="filename"></code>' +
'<pre class="language-js"><code>active()</code></pre></div></div>' +
'<div hidden data-code-tab-title="JavaScript" data-code-tab-filename="">' +
'<pre class="language-js"><code>hello();</code></pre></div>' +
'</div>';

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 =
'<div><pre class="language-bash"><code>curl -sL https://sentry.io/get-cli/ | bash</code></pre></div>';

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 =
'<pre class="language-bash"><code>npm install @sentry/node</code></pre>';
const tabs = buildCodeTabsHTML([
{
title: 'Node.js',
filename: 'instrument.mjs',
lang: 'javascript',
code: 'Sentry.init();',
},
{title: 'Bun', lang: 'javascript', code: 'init();'},
]);

const md = htmlToMarkdown(`<div>${standalone}${tabs}</div>`);

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(`<div>${group1}${group2}</div>`);

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 =
'<div class="code-tabs-wrapper">' +
'<div><pre class="language-js"><code>active tab</code></pre></div>' +
'<div hidden data-code-tab-title="broken"><p>Not a code block</p></div>' +
'<div hidden data-code-tab-title="ok"><pre class="language-js"><code>works();</code></pre></div>' +
'</div>';

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 =
'<div class="code-tabs-wrapper">' +
'<div><button>Only Tab</button>' +
'<div class="code-block"><pre class="language-js"><code>solo();</code></pre></div>' +
'</div>' +
'</div>';

const md = htmlToMarkdown(html);

const codeBlocks = md.match(/```[\s\S]*?```/g);
expect(codeBlocks).toHaveLength(1);
expect(codeBlocks[0]).toContain('solo()');
});
});
79 changes: 79 additions & 0 deletions scripts/rehype-expand-code-tabs.mjs
Original file line number Diff line number Diff line change
@@ -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 <div data-code-tab-title>
* blocks alongside the interactive <CodeTabs> 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;
}
19 changes: 19 additions & 0 deletions src/remark-code-tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -79,6 +97,7 @@ export default function remarkCodeTabs() {
name: 'CodeTabs',
children,
},
...exportBlocks,
];

toRemove = toRemove.concat(pendingCode.splice(1));
Expand Down
Loading