Skip to content

Commit c12dbf1

Browse files
feat: Add snippets from all code tabs to copied markdown (#17745)
## DESCRIBE YOUR PR Example page "Hono" (as there are some tabs): https://sentry-docs-git-sig-copy-page-all-snippets.sentry.dev/platforms/javascript/guides/hono/ Markdown: https://sentry-docs-git-sig-copy-page-all-snippets.sentry.dev/platforms/javascript/guides/hono.md fixes #17743 ## IS YOUR CHANGE URGENT? Help us prioritize incoming PRs by letting us know when the change needs to go live. - [ ] Urgent deadline (GA date, etc.): <!-- ENTER DATE HERE --> - [ ] Other deadline: <!-- ENTER DATE HERE --> - [ ] None: Not urgent, can wait up to 1 week+ ## SLA - Teamwork makes the dream work, so please add a reviewer to your PRs. - Please give the docs team up to 1 week to review your PR unless you've added an urgent due date to it. Thanks in advance for your help! ## PRE-MERGE CHECKLIST *Make sure you've checked the following before merging your changes:* - [ ] Checked Vercel preview for correctness, including links - [ ] PR was reviewed and approved by any necessary SMEs (subject matter experts) - [ ] PR was reviewed and approved by a member of the [Sentry docs team](https://github.com/orgs/getsentry/teams/docs) --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 27161a6 commit c12dbf1

4 files changed

Lines changed: 319 additions & 1 deletion

File tree

scripts/generate-md-exports.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,12 @@ import remarkStringify from 'remark-stringify';
2727
import {unified} from 'unified';
2828
import {remove} from 'unist-util-remove';
2929

30+
import {rehypeExpandCodeTabs} from './rehype-expand-code-tabs.mjs';
31+
3032
const DOCS_ORIGIN = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
3133
? 'https://develop.sentry.dev'
3234
: 'https://docs.sentry.io';
33-
const CACHE_VERSION = 7;
35+
const CACHE_VERSION = 8;
3436
const CACHE_COMPRESS_LEVEL = 4;
3537
const R2_BUCKET = process.env.NEXT_PUBLIC_DEVELOPER_DOCS
3638
? 'sentry-develop-docs'
@@ -1004,6 +1006,7 @@ async function genMDFromHTML(source, {cacheDir, noCache, usedCacheFiles}) {
10041006
properties: {},
10051007
children: tree,
10061008
}))
1009+
.use(rehypeExpandCodeTabs)
10071010
.use(rehypeRemark, {
10081011
document: false,
10091012
handlers: {
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import rehypeParse from 'rehype-parse';
2+
import rehypeRemark from 'rehype-remark';
3+
import remarkGfm from 'remark-gfm';
4+
import remarkStringify from 'remark-stringify';
5+
import {describe, expect, it} from 'vitest';
6+
import {unified} from 'unified';
7+
import {remove} from 'unist-util-remove';
8+
9+
import {rehypeExpandCodeTabs} from './rehype-expand-code-tabs.mjs';
10+
11+
function htmlToMarkdown(html) {
12+
return String(
13+
unified()
14+
.use(rehypeParse)
15+
.use(rehypeExpandCodeTabs)
16+
.use(rehypeRemark, {
17+
document: false,
18+
handlers: {
19+
button() {},
20+
},
21+
})
22+
.use(() => tree => remove(tree, {type: 'inlineCode', value: ''}))
23+
.use(remarkGfm)
24+
.use(remarkStringify)
25+
.processSync(html)
26+
);
27+
}
28+
29+
function buildCodeTabsHTML(tabs) {
30+
const firstTab = tabs[0];
31+
32+
const codeTabsRendered =
33+
'<div>' +
34+
tabs.map((t, i) => `<button data-active="${i === 0}">${t.title}</button>`).join('') +
35+
'<div class="code-block">' +
36+
`<code class="filename">${firstTab.filename || ''}</code>` +
37+
`<pre class="language-${firstTab.lang}"><code>${firstTab.code}</code></pre>` +
38+
'</div>' +
39+
'</div>';
40+
41+
const exportBlocks = tabs
42+
.map(t => {
43+
const filenameAttr = t.filename ? ` data-code-tab-filename="${t.filename}"` : '';
44+
return (
45+
`<div hidden data-code-tab-title="${t.title}"${filenameAttr}>` +
46+
`<pre class="language-${t.lang}"><code>${t.code}</code></pre>` +
47+
'</div>'
48+
);
49+
})
50+
.join('');
51+
52+
return `<div class="code-tabs-wrapper">${codeTabsRendered}${exportBlocks}</div>`;
53+
}
54+
55+
describe('rehypeExpandCodeTabs', () => {
56+
it('outputs one fenced code block per tab with "[Title] filename" headings', () => {
57+
const html = buildCodeTabsHTML([
58+
{
59+
title: 'Cloudflare Workers',
60+
filename: 'index.ts',
61+
lang: 'typescript',
62+
code: 'import { sentry } from "@sentry/hono/cloudflare";',
63+
},
64+
{
65+
title: 'Node.js',
66+
filename: 'app.ts',
67+
lang: 'typescript',
68+
code: 'import { sentry } from "@sentry/hono/node";',
69+
},
70+
{
71+
title: 'Bun',
72+
filename: 'index.ts',
73+
lang: 'typescript',
74+
code: 'import { sentry } from "@sentry/hono/bun";',
75+
},
76+
]);
77+
78+
const md = htmlToMarkdown(html);
79+
80+
const codeBlocks = md.match(/```[\s\S]*?```/g);
81+
expect(codeBlocks).toHaveLength(3);
82+
expect(codeBlocks[0]).toContain('@sentry/hono/cloudflare');
83+
expect(codeBlocks[1]).toContain('@sentry/hono/node');
84+
expect(codeBlocks[2]).toContain('@sentry/hono/bun');
85+
expect(md).toContain('**\\[Cloudflare Workers] index.ts**');
86+
expect(md).toContain('**\\[Node.js] app.ts**');
87+
expect(md).toContain('**\\[Bun] index.ts**');
88+
});
89+
90+
it('removes the CodeTabs-rendered active tab to avoid duplication', () => {
91+
const html = buildCodeTabsHTML([
92+
{title: 'ESM', filename: 'instrument.mjs', lang: 'javascript', code: 'import init'},
93+
{title: 'CJS', filename: 'instrument.js', lang: 'javascript', code: 'require init'},
94+
]);
95+
96+
const md = htmlToMarkdown(html);
97+
98+
expect(md).not.toContain('`instrument.mjs`');
99+
});
100+
101+
it('uses tab title alone when filename is absent', () => {
102+
const html = buildCodeTabsHTML([
103+
{title: 'Cloudflare Workers', lang: 'javascript', code: 'workers();'},
104+
{title: 'Bun', lang: 'javascript', code: 'bun();'},
105+
]);
106+
107+
const md = htmlToMarkdown(html);
108+
109+
const headings = md.match(/\*\*.*?\*\*/g);
110+
expect(headings).toHaveLength(2);
111+
expect(headings[0]).toBe('**Cloudflare Workers**');
112+
expect(headings[1]).toBe('**Bun**');
113+
});
114+
115+
it('treats empty filename attribute the same as missing filename', () => {
116+
const html =
117+
'<div class="code-tabs-wrapper">' +
118+
'<div><button>Tab</button><div class="code-block"><code class="filename"></code>' +
119+
'<pre class="language-js"><code>active()</code></pre></div></div>' +
120+
'<div hidden data-code-tab-title="JavaScript" data-code-tab-filename="">' +
121+
'<pre class="language-js"><code>hello();</code></pre></div>' +
122+
'</div>';
123+
124+
const md = htmlToMarkdown(html);
125+
126+
const headings = md.match(/\*\*.*?\*\*/g);
127+
expect(headings).toHaveLength(1);
128+
expect(headings[0]).toBe('**JavaScript**');
129+
});
130+
131+
it('does not modify code blocks outside tab wrappers', () => {
132+
const html =
133+
'<div><pre class="language-bash"><code>curl -sL https://sentry.io/get-cli/ | bash</code></pre></div>';
134+
135+
const md = htmlToMarkdown(html);
136+
137+
const codeBlocks = md.match(/```[\s\S]*?```/g);
138+
expect(codeBlocks).toHaveLength(1);
139+
expect(codeBlocks[0]).toContain('curl -sL');
140+
expect(md).not.toMatch(/\*\*.*\*\*\n/);
141+
});
142+
143+
it('preserves standalone code blocks when mixed with tab groups', () => {
144+
const standalone =
145+
'<pre class="language-bash"><code>npm install @sentry/node</code></pre>';
146+
const tabs = buildCodeTabsHTML([
147+
{
148+
title: 'Node.js',
149+
filename: 'instrument.mjs',
150+
lang: 'javascript',
151+
code: 'Sentry.init();',
152+
},
153+
{title: 'Bun', lang: 'javascript', code: 'init();'},
154+
]);
155+
156+
const md = htmlToMarkdown(`<div>${standalone}${tabs}</div>`);
157+
158+
const codeBlocks = md.match(/```[\s\S]*?```/g);
159+
expect(codeBlocks).toHaveLength(3);
160+
expect(codeBlocks[0]).toContain('npm install');
161+
expect(md).toContain('**\\[Node.js] instrument.mjs**');
162+
expect(md).toContain('**Bun**');
163+
});
164+
165+
it('expands multiple tab groups independently on the same page', () => {
166+
const group1 = buildCodeTabsHTML([
167+
{title: 'ESM', filename: 'instrument.mjs', lang: 'javascript', code: 'import init'},
168+
{title: 'CJS', filename: 'instrument.js', lang: 'javascript', code: 'require init'},
169+
]);
170+
const group2 = buildCodeTabsHTML([
171+
{title: 'Python', filename: 'main.py', lang: 'python', code: 'import sentry_sdk'},
172+
{title: 'Ruby', filename: 'config.rb', lang: 'ruby', code: 'require "sentry-ruby"'},
173+
]);
174+
175+
const md = htmlToMarkdown(`<div>${group1}${group2}</div>`);
176+
177+
const codeBlocks = md.match(/```[\s\S]*?```/g);
178+
expect(codeBlocks).toHaveLength(4);
179+
expect(codeBlocks[0]).toContain('import init');
180+
expect(codeBlocks[1]).toContain('require init');
181+
expect(codeBlocks[2]).toContain('import sentry_sdk');
182+
expect(codeBlocks[3]).toContain('require "sentry-ruby"');
183+
});
184+
185+
it('drops export blocks that contain no pre element', () => {
186+
const html =
187+
'<div class="code-tabs-wrapper">' +
188+
'<div><pre class="language-js"><code>active tab</code></pre></div>' +
189+
'<div hidden data-code-tab-title="broken"><p>Not a code block</p></div>' +
190+
'<div hidden data-code-tab-title="ok"><pre class="language-js"><code>works();</code></pre></div>' +
191+
'</div>';
192+
193+
const md = htmlToMarkdown(html);
194+
195+
const codeBlocks = md.match(/```[\s\S]*?```/g);
196+
expect(codeBlocks).toHaveLength(1);
197+
expect(codeBlocks[0]).toContain('works()');
198+
expect(md).toContain('**ok**');
199+
expect(md).not.toContain('broken');
200+
expect(md).not.toContain('active tab');
201+
});
202+
203+
it('leaves wrapper unchanged when it has no export blocks', () => {
204+
const html =
205+
'<div class="code-tabs-wrapper">' +
206+
'<div><button>Only Tab</button>' +
207+
'<div class="code-block"><pre class="language-js"><code>solo();</code></pre></div>' +
208+
'</div>' +
209+
'</div>';
210+
211+
const md = htmlToMarkdown(html);
212+
213+
const codeBlocks = md.match(/```[\s\S]*?```/g);
214+
expect(codeBlocks).toHaveLength(1);
215+
expect(codeBlocks[0]).toContain('solo()');
216+
});
217+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {visit} from 'unist-util-visit';
2+
3+
/**
4+
* Rehype plugin that expands CodeTabs for markdown export.
5+
*
6+
* The remark-code-tabs plugin injects hidden <div data-code-tab-title>
7+
* blocks alongside the interactive <CodeTabs> component inside each
8+
* .code-tabs-wrapper. These hidden blocks contain the raw code for every
9+
* tab and are always present in the static HTML (unlike CodeTabs output,
10+
* which may only include the active tab due to RSC serialization).
11+
*
12+
* This plugin:
13+
* 1. Finds parent elements that contain [data-code-tab-title] children
14+
* 2. Replaces ALL children with expanded export blocks (removing the
15+
* CodeTabs-rendered content to avoid duplication)
16+
* 3. Each export block becomes a bold heading + fenced code block.
17+
* The heading format is "[Tab Title] filename" when both exist,
18+
* or just the tab title / filename when only one is present
19+
*/
20+
export function rehypeExpandCodeTabs() {
21+
return tree => {
22+
visit(tree, 'element', node => {
23+
if (!node.children) {
24+
return;
25+
}
26+
27+
const exportBlocks = node.children.filter(
28+
child => child.type === 'element' && child.properties?.dataCodeTabTitle
29+
);
30+
if (exportBlocks.length === 0) {
31+
return;
32+
}
33+
34+
node.children = exportBlocks.flatMap(block => {
35+
const title = block.properties.dataCodeTabTitle;
36+
const filename = block.properties.dataCodeTabFilename;
37+
const label = filename && title ? `[${title}] ${filename}` : filename || title;
38+
39+
const preElements = collectAll(block, el => el.tagName === 'pre');
40+
if (preElements.length === 0) {
41+
return [];
42+
}
43+
44+
return [
45+
{
46+
type: 'element',
47+
tagName: 'p',
48+
properties: {},
49+
children: [
50+
{
51+
type: 'element',
52+
tagName: 'strong',
53+
properties: {},
54+
children: [{type: 'text', value: label}],
55+
},
56+
],
57+
},
58+
...preElements,
59+
];
60+
});
61+
});
62+
};
63+
}
64+
65+
function collectAll(node, predicate) {
66+
const results = [];
67+
(function walk(n) {
68+
if (n.type === 'element' && predicate(n)) {
69+
results.push(n);
70+
return;
71+
}
72+
if (n.children) {
73+
for (const child of n.children) {
74+
walk(child);
75+
}
76+
}
77+
})(node);
78+
return results;
79+
}

src/remark-code-tabs.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ export default function remarkCodeTabs() {
6666
[]
6767
);
6868

69+
const exportBlocks = pendingCode.map(([node]) => {
70+
const title = getTabTitle(node);
71+
const filename = getFilename(node);
72+
const lang = fixLanguage(node);
73+
const hProperties = {
74+
hidden: true,
75+
dataCodeTabTitle: title || lang,
76+
};
77+
if (filename) {
78+
hProperties.dataCodeTabFilename = filename;
79+
}
80+
return {
81+
type: 'element',
82+
data: {hName: 'div', hProperties},
83+
children: [{type: 'code', lang, value: node.value}],
84+
};
85+
});
86+
6987
rootNode.type = 'element';
7088
rootNode.data = {
7189
hName: 'div',
@@ -79,6 +97,7 @@ export default function remarkCodeTabs() {
7997
name: 'CodeTabs',
8098
children,
8199
},
100+
...exportBlocks,
82101
];
83102

84103
toRemove = toRemove.concat(pendingCode.splice(1));

0 commit comments

Comments
 (0)