From c60e4c07f9ec910bef6d03eb51874fc31a9d5f26 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 26 May 2026 15:46:30 +0200 Subject: [PATCH 1/4] only expand code snippets in markdown if explicitly wanted --- scripts/generate-md-exports.mjs | 2 +- scripts/generate-md-exports.test.mjs | 370 ++++++++++++++++----------- scripts/rehype-expand-code-tabs.mjs | 104 +++++--- src/remark-code-tabs.js | 8 + 4 files changed, 300 insertions(+), 184 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index a99a21ec852ab..e07e71fc4ef6d 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -32,7 +32,7 @@ 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 = 8; +const CACHE_VERSION = 9; const CACHE_COMPRESS_LEVEL = 4; const R2_BUCKET = process.env.NEXT_PUBLIC_DEVELOPER_DOCS ? 'sentry-develop-docs' diff --git a/scripts/generate-md-exports.test.mjs b/scripts/generate-md-exports.test.mjs index bca320add3be4..c9dc307f97009 100644 --- a/scripts/generate-md-exports.test.mjs +++ b/scripts/generate-md-exports.test.mjs @@ -26,7 +26,7 @@ function htmlToMarkdown(html) { ); } -function buildCodeTabsHTML(tabs) { +function buildCodeTabsHTML(tabs, {expand = false} = {}) { const firstTab = tabs[0]; const codeTabsRendered = @@ -49,169 +49,233 @@ function buildCodeTabsHTML(tabs) { }) .join(''); - return `
${codeTabsRendered}${exportBlocks}
`; + const expandAttr = expand ? ' data-code-tab-md-expand-tabs' : ''; + 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**'); + describe('default (collapsed)', () => { + it('outputs only the first tab code block', () => { + const html = buildCodeTabsHTML([ + {title: 'npm', lang: 'bash', code: 'npm install @sentry/node'}, + {title: 'yarn', lang: 'bash', code: 'yarn add @sentry/node'}, + {title: 'pnpm', lang: 'bash', code: 'pnpm add @sentry/node'}, + ]); + + const md = htmlToMarkdown(html); + + const codeBlocks = md.match(/```[\s\S]*?```/g); + expect(codeBlocks).toHaveLength(1); + expect(codeBlocks[0]).toContain('npm install @sentry/node'); + }); + + it('appends "Also available for" note listing other tab titles', () => { + const html = buildCodeTabsHTML([ + {title: 'npm', lang: 'bash', code: 'npm install @sentry/node'}, + {title: 'yarn', lang: 'bash', code: 'yarn add @sentry/node'}, + {title: 'pnpm', lang: 'bash', code: 'pnpm add @sentry/node'}, + ]); + + const md = htmlToMarkdown(html); + + expect(md).toContain('*Also available for: yarn, pnpm*'); + }); + + it('omits the note for a single-tab group', () => { + const html = buildCodeTabsHTML([ + {title: 'typescript', lang: 'typescript', code: 'Sentry.init();'}, + ]); + + const md = htmlToMarkdown(html); + + const codeBlocks = md.match(/```[\s\S]*?```/g); + expect(codeBlocks).toHaveLength(1); + expect(md).not.toContain('Also available for'); + }); + + 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('does not add bold headings to the code block', () => { + 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.toMatch(/\*\*.*\*\*/); + }); }); - 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); + describe('opt-in expanded ({mdExpandTabs})', () => { + 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";', + }, + ], + {expand: true} + ); - const codeBlocks = md.match(/```[\s\S]*?```/g); - expect(codeBlocks).toHaveLength(1); - expect(codeBlocks[0]).toContain('curl -sL'); - expect(md).not.toMatch(/\*\*.*\*\*\n/); - }); + 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('uses tab title alone when filename is absent', () => { + const html = buildCodeTabsHTML( + [ + {title: 'Cloudflare Workers', lang: 'javascript', code: 'workers();'}, + {title: 'Bun', lang: 'javascript', code: 'bun();'}, + ], + {expand: true} + ); - 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**'); + 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('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('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"'); - }); + describe('mixed page', () => { + it('collapses and expands groups independently on the same page', () => { + const collapsed = buildCodeTabsHTML([ + {title: 'npm', lang: 'bash', code: 'npm install @sentry/node'}, + {title: 'yarn', lang: 'bash', code: 'yarn add @sentry/node'}, + ]); + const expanded = buildCodeTabsHTML( + [ + {title: 'Cloudflare Workers', filename: 'index.ts', lang: 'typescript', code: 'cf()'}, + {title: 'Node.js', filename: 'app.ts', lang: 'typescript', code: 'node()'}, + ], + {expand: true} + ); - 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'); + const md = htmlToMarkdown(`
${collapsed}${expanded}
`); + + const codeBlocks = md.match(/```[\s\S]*?```/g); + expect(codeBlocks).toHaveLength(3); + expect(codeBlocks[0]).toContain('npm install'); + expect(codeBlocks[1]).toContain('cf()'); + expect(codeBlocks[2]).toContain('node()'); + expect(md).toContain('*Also available for: yarn*'); + expect(md).toContain('**\\[Cloudflare Workers] index.ts**'); + expect(md).toContain('**\\[Node.js] app.ts**'); + }); + + it('preserves standalone code blocks alongside tab groups', () => { + const standalone = + '
npm install @sentry/node
'; + const tabs = buildCodeTabsHTML([ + {title: 'ESM', lang: 'javascript', code: 'import init'}, + {title: 'CJS', lang: 'javascript', code: 'require init'}, + ]); + + const md = htmlToMarkdown(`
${standalone}${tabs}
`); + + const codeBlocks = md.match(/```[\s\S]*?```/g); + expect(codeBlocks).toHaveLength(2); + expect(codeBlocks[0]).toContain('npm install'); + expect(codeBlocks[1]).toContain('import init'); + expect(md).toContain('*Also available for: CJS*'); + }); }); - 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()'); + describe('edge cases', () => { + 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('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 index f6f0a528cffa3..29d603c3ea55a 100644 --- a/scripts/rehype-expand-code-tabs.mjs +++ b/scripts/rehype-expand-code-tabs.mjs @@ -1,7 +1,7 @@ import {visit} from 'unist-util-visit'; /** - * Rehype plugin that expands CodeTabs for markdown export. + * Rehype plugin that converts CodeTabs export blocks for markdown export. * * The remark-code-tabs plugin injects hidden
* blocks alongside the interactive component inside each @@ -9,13 +9,12 @@ import {visit} from 'unist-util-visit'; * 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 + * Behavior depends on whether the wrapper has data-code-tab-md-expand-tabs + * (set via {mdExpandTabs} in any code fence of the group): + * + * - With flag: all tabs are expanded with "[Title] filename" headings. + * - Without flag (default): only the first tab is kept, plus a note + * listing the other available tab titles. */ export function rehypeExpandCodeTabs() { return tree => { @@ -31,35 +30,80 @@ export function rehypeExpandCodeTabs() { 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 expandAll = node.properties?.dataCodeTabMdExpandTabs != null; + + if (expandAll) { + node.children = expandAllTabs(exportBlocks); + } else { + node.children = collapseToFirstTab(exportBlocks); + } + }); + }; +} + +function expandAllTabs(exportBlocks) { + return 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 []; - } + const preElements = collectAll(block, el => el.tagName === 'pre'); + if (preElements.length === 0) { + return []; + } - return [ + return [ + { + type: 'element', + tagName: 'p', + properties: {}, + children: [ { type: 'element', - tagName: 'p', + tagName: 'strong', properties: {}, - children: [ - { - type: 'element', - tagName: 'strong', - properties: {}, - children: [{type: 'text', value: label}], - }, - ], + children: [{type: 'text', value: label}], }, - ...preElements, - ]; - }); + ], + }, + ...preElements, + ]; + }); +} + +function collapseToFirstTab(exportBlocks) { + const first = exportBlocks[0]; + const preElements = collectAll(first, el => el.tagName === 'pre'); + if (preElements.length === 0) { + return []; + } + + const result = [...preElements]; + + const otherTitles = exportBlocks + .slice(1) + .map(block => block.properties.dataCodeTabTitle) + .filter(Boolean); + + if (otherTitles.length > 0) { + result.push({ + type: 'element', + tagName: 'p', + properties: {}, + children: [ + { + type: 'element', + tagName: 'em', + properties: {}, + children: [ + {type: 'text', value: `Also available for: ${otherTitles.join(', ')}`}, + ], + }, + ], }); - }; + } + + return result; } function collectAll(node, predicate) { diff --git a/src/remark-code-tabs.js b/src/remark-code-tabs.js index 2f1bdb654acca..ebc957a53edeb 100644 --- a/src/remark-code-tabs.js +++ b/src/remark-code-tabs.js @@ -32,6 +32,11 @@ function getTabTitle(node) { return (match && match[1]) || ''; } +function getMdExpandTabs(node) { + const meta = getFullMeta(node); + return /\{mdExpandTabs}/.test(meta || ''); +} + // TODO(dcramer): this should only operate on MDX export default function remarkCodeTabs() { return markdownAST => { @@ -84,11 +89,14 @@ export default function remarkCodeTabs() { }; }); + const shouldExpand = pendingCode.some(([node]) => getMdExpandTabs(node)); + rootNode.type = 'element'; rootNode.data = { hName: 'div', hProperties: { className: 'code-tabs-wrapper', + ...(shouldExpand && {dataCodeTabMdExpandTabs: true}), }, }; rootNode.children = [ From accb718f8da9c295a709859b119e464c19eee01d Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 26 May 2026 15:47:07 +0200 Subject: [PATCH 2/4] improve skill to include info about code tabs --- .claude/skills/technical-docs/SKILL.md | 58 +++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/.claude/skills/technical-docs/SKILL.md b/.claude/skills/technical-docs/SKILL.md index 66601af0f403b..da9c546573576 100644 --- a/.claude/skills/technical-docs/SKILL.md +++ b/.claude/skills/technical-docs/SKILL.md @@ -130,12 +130,68 @@ Link to related docs rather than repeating content: For automatic tracing, see API Reference. ``` -### Code Block Filenames +### Code Block Meta Flags Always include filename when showing file-specific code: ```tsx {filename:app/error.tsx} ``` +Consecutive fenced code blocks are automatically grouped into tabbed code snippets. +Each tab can have a title and filename: + +~~~ +```swift {tabTitle:Swift} +SentrySDK.capture(error: error) +``` + +```objc {tabTitle:Objective-C} +[SentrySDK captureError:error]; +``` +~~~ + +#### Markdown Export and `{mdExpandTabs}` + +The `.md` export (mainly used by LLMs via the "Copy page" button) **collapses tab groups +by default**: only the first tab is included, with a note listing the other tabs +(e.g. *Also available for: yarn, pnpm*). This keeps context lean when tabs show +trivial variations an LLM can infer on its own. + +Add `{mdExpandTabs}` to the first code fence in a group when the tabs contain code an LLM +**cannot reliably derive** from seeing just one tab. This is rare — most times, adding only +one tab to the produced `.md` is enough. + +~~~ +```swift {tabTitle:Swift} {mdExpandTabs} +SentrySDK.start { options in + options.dsn = "..." +} +``` + +```objc {tabTitle:Objective-C} +[SentrySDK startWithConfigureOptions:^(SentryOptions *options) { + options.dsn = @"..."; +}]; +``` +~~~ + +**Expand** — the code is too different for an LLM to infer: +- Different languages: Swift / Objective-C, cross-language guides (JS/Python/PHP/Ruby/...) +- Different setup flows: Hono guide init (Cloudflare vs Node.js `--import` vs Bun) +- Different APIs or wrappers: GCP Cloud Functions (`wrapHttpFunction` vs `wrapCloudEventFunction`), serverless async/sync handlers +- Different framework versions with distinct imports: Spring 5/6/7, Spring Boot 2/3/4, Svelte v5+ / v3 +- Client / Server splits: Next.js, Remix, React Router (Replay + browser tracing vs Node integrations) +- Different platform tooling: KMP (`commonMain` / `iosApp` / `androidApp`), Flutter navigation (Navigator / GoRouter / AutoRoute) +- Install methods with different patterns: npm (`import`) vs CDN (`