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 = + '
' + + '
' + + '
active()
' + + '' + + '
'; + + 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 = + '
' + + '
active tab
' + + '' + + '' + + '
'; + + 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 = + '
' + + '
' + + '
solo();
' + + '
' + + '
'; + + 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));