From 3a9e547bd352efb72dfd8b094f9c63f4fe08428e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 19 Feb 2026 13:22:10 -0800 Subject: [PATCH 01/13] feat(md-exports): Inject frontmatter descriptions into MD exports for LLM relevance Inject frontmatter `description` as italic text after the H1 heading in generated .md exports so LLM agents can quickly assess page relevance. MDX override pages are skipped since they have custom intros. Also: - Add missing descriptions to 11 platform root pages - Update specs and contributing docs with description injection details - Set up @sentry/dotagents with brand-guidelines skill - Add Content Authoring section to AGENTS.md referencing skills - Wire dotagents install into Makefile develop target Fixes #16420 Co-Authored-By: Claude --- .agents/skills/brand-guidelines/SKILL.md | 168 +++++++++++++++++++++++ AGENTS.md | 16 ++- Makefile | 1 + agents.lock | 10 ++ agents.toml | 8 ++ docs/contributing/environment.mdx | 2 + docs/contributing/pages/llm-support.mdx | 3 +- docs/platforms/android/index.mdx | 1 + docs/platforms/apple/index.mdx | 1 + docs/platforms/godot/index.mdx | 1 + docs/platforms/kotlin/index.mdx | 1 + docs/platforms/nintendo-switch/index.mdx | 1 + docs/platforms/playstation/index.mdx | 1 + docs/platforms/powershell/index.mdx | 1 + docs/platforms/rust/index.mdx | 1 + docs/platforms/unity/index.mdx | 1 + docs/platforms/unreal/index.mdx | 1 + docs/platforms/xbox/index.mdx | 1 + scripts/generate-md-exports.mjs | 72 ++++++++-- specs/llm-friendly-docs.md | 24 ++++ 20 files changed, 299 insertions(+), 16 deletions(-) create mode 100644 .agents/skills/brand-guidelines/SKILL.md create mode 100644 agents.lock create mode 100644 agents.toml diff --git a/.agents/skills/brand-guidelines/SKILL.md b/.agents/skills/brand-guidelines/SKILL.md new file mode 100644 index 0000000000000..2f974141c88b1 --- /dev/null +++ b/.agents/skills/brand-guidelines/SKILL.md @@ -0,0 +1,168 @@ +--- +name: brand-guidelines +description: Write copy following Sentry brand guidelines. Use when writing UI text, error messages, empty states, onboarding flows, 404 pages, documentation, marketing copy, or any user-facing content. Covers both Plain Speech (default) and Sentry Voice tones. +--- + +# Brand Guidelines + +Write user-facing copy following Sentry's brand guidelines. + +## Tone Selection + +Choose the appropriate tone based on context: + +| Use Plain Speech | Use Sentry Voice | +|------------------|------------------| +| Product UI (buttons, labels, forms) | 404 pages | +| Documentation | Empty states | +| Error messages | Onboarding flows | +| Settings pages | Loading states | +| Transactional emails | "What's New" announcements | +| Help text | Marketing copy | + +**Default to Plain Speech** unless the context specifically calls for personality. + +## Plain Speech (Default) + +Plain Speech is clear, direct, and functional. Use it for most UI elements. + +### Rules + +1. **Be concise** - Use the fewest words needed +2. **Be direct** - Tell users what to do, not what they can do +3. **Use active voice** - "Save your changes" not "Your changes will be saved" +4. **Avoid jargon** - Use simple words users understand +5. **Be specific** - "3 errors found" not "Some errors found" + +### Examples + +| Instead of | Write | +|------------|-------| +| "Click here to save your changes" | "Save" | +| "You can filter results by date" | "Filter by date" | +| "An error has occurred" | "Something went wrong" | +| "Please enter a valid email address" | "Enter a valid email" | +| "Are you sure you want to delete?" | "Delete this item?" | + +## Sentry Voice + +Sentry Voice adds personality in appropriate moments. It's empathetic, self-aware, and occasionally snarky. + +### Principles + +1. **Empathetic snark** - Direct frustration at the situation, never the user +2. **Self-aware** - Acknowledge the absurdity of software +3. **Fun but functional** - Personality should enhance, not obscure meaning +4. **Earned moments** - Only use when users have time to appreciate it + +### Examples + +**404 Pages:** +> "This page doesn't exist. Maybe it never did. Maybe it was a dream. Either way, let's get you back on track." + +**Empty States:** +> "No errors yet. Enjoy this moment of peace while it lasts." + +**Onboarding:** +> "Let's get your first error. Don't worry, it's not as scary as it sounds." + +**Loading States:** +> "Crunching the numbers..." +> "Fetching your data..." + +### When NOT to Use Sentry Voice + +- Error messages (users are frustrated) +- Settings pages (users are focused) +- Documentation (users need information) +- Billing/payment flows (users need trust) + +## General Rules + +### Spelling and Grammar + +- Use **American English** spelling (color, not colour) +- Use **Title Case** for headings and page titles +- Use **Sentence case** for body text, buttons, and labels + +### Punctuation + +- **No exclamation marks** in UI text (exception: celebratory moments) +- **No periods** in short UI labels or button text +- **Use periods** in complete sentences and help text +- **No ALL CAPS** except for acronyms (API, SDK, URL) + +### Word Choices + +| Avoid | Prefer | +|-------|--------| +| Please | (omit) | +| Sorry | (be specific about the problem) | +| Error occurred | Something went wrong | +| Invalid | (explain what's wrong) | +| Success! | (describe what happened) | +| Oops | (be specific) | + +## Dash Usage + +| Type | Use | Example | +|------|-----|---------| +| Hyphen (-) | Compound words, ranges | "real-time", "1-10" | +| En-dash (--) | Ranges, relationships | "2023--2024", "parent--child" | +| Em-dash (---) | Interruption, emphasis | "Errors---even small ones---matter" | + +In most UI contexts, use hyphens. Reserve en-dashes for date ranges and em-dashes for longer prose. + +## UI Element Guidelines + +### Buttons + +- Use action verbs: "Save", "Delete", "Create" +- Be specific: "Create Project" not just "Create" +- Max 2-3 words when possible +- No periods or exclamation marks + +### Error Messages + +1. Say what happened +2. Say why (if helpful) +3. Say what to do next + +**Good:** "Could not save changes. Check your connection and try again." +**Bad:** "Error: Save failed." + +### Empty States + +1. Explain what would normally be here +2. Provide a clear action to populate the state +3. Sentry Voice is appropriate here + +**Good:** "No projects yet. Create your first project to start tracking errors." + +### Confirmation Dialogs + +- Make the action clear in the title +- Explain consequences if destructive +- Use specific button labels ("Delete Project", not "OK") + +### Tooltips and Help Text + +- Keep under 2 sentences +- Explain the "why", not just the "what" +- Link to docs for complex topics + +## Anti-Patterns + +Avoid these common mistakes: + +- **Robot speak:** "Item has been successfully deleted" -> "Deleted" +- **Passive voice:** "Changes were saved" -> "Changes saved" +- **Unnecessary words:** "In order to" -> "To" +- **Hedging:** "This might cause..." -> "This will cause..." +- **Double negatives:** "Not unlike..." -> "Similar to..." +- **Marketing speak in UI:** "Supercharge your workflow" -> "Speed up your workflow" + +## References + +- [Sentry Voice Guidelines](https://develop.sentry.dev/frontend/sentry-voice/) +- [Sentry Frontend Handbook](https://develop.sentry.dev/frontend/) diff --git a/AGENTS.md b/AGENTS.md index cad25dbeea42a..c7e1ad9c327d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,11 +75,25 @@ When writing requirements in `develop-docs/`: ``` +## Content Authoring + +- **ALWAYS** run `/brand-guidelines` to audit any user-facing content before committing. See `.agents/skills/brand-guidelines/SKILL.md` +- Use `docs-review` skill for Sentry style and voice review. See `.claude/skills/docs-review/SKILL.md` +- Use `technical-docs` skill when writing or reviewing SDK documentation. See `.claude/skills/technical-docs/SKILL.md` + +## LLM-Friendly MD Exports + +- Every page at `docs.sentry.io/` has a `.md` export at `docs.sentry.io/.md` +- `scripts/generate-md-exports.mjs` generates these as a post-build step +- Frontmatter `description` fields are injected as italic text after the H1 in MD exports — pages missing descriptions lose LLM relevance signal +- MDX override templates live in `md-overrides/` +- Architecture spec: `specs/llm-friendly-docs.md` + ## Plan Mode - Make the plan extremely concise. Sacrifice grammar for the sake of concision. - At the end of each plan, give me a list of unresolved questions to answer, if any. -## Pull Request generation +## Pull Request generation Use .github/PULL_REQUEST_TEMPLATE.md and add Co-Authored-By: Claude diff --git a/Makefile b/Makefile index 3d20e1636a206..54c185968a232 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ develop: setup-git [ -f .env.development ] || cp .env.example .env.development pnpm install + npx @sentry/dotagents install setup-git: ifneq (, $(shell which pre-commit)) diff --git a/agents.lock b/agents.lock new file mode 100644 index 0000000000000..833496d70d012 --- /dev/null +++ b/agents.lock @@ -0,0 +1,10 @@ +# Auto-generated by dotagents. Do not edit. +version = 1 + +[skills.brand-guidelines] +source = "getsentry/skills" +resolved_url = "https://github.com/getsentry/skills.git" +resolved_path = ".agents/skills/brand-guidelines" +commit = "5bf1b9870096afd55e62069514c7e77d4e62b5f0" +integrity = "sha256-OI4bxji4am79cV/3TNDVUfsnt6L2HBysG7epE4HFkVI=" + diff --git a/agents.toml b/agents.toml new file mode 100644 index 0000000000000..bfceb01c47f51 --- /dev/null +++ b/agents.toml @@ -0,0 +1,8 @@ +version = 1 +# Check skills into git so collaborators get them without running 'dotagents install'. +# Set to true (or remove) to gitignore managed skills instead. +gitignore = false + +[[skills]] +name = "brand-guidelines" +source = "getsentry/skills" diff --git a/docs/contributing/environment.mdx b/docs/contributing/environment.mdx index 8c9dbcd8e59e8..c3cf1a7cef931 100644 --- a/docs/contributing/environment.mdx +++ b/docs/contributing/environment.mdx @@ -22,6 +22,8 @@ Next, navigate into the cloned repo and run the following to install dependencie make ``` +This also installs agent skills via `@sentry/dotagents` for AI-assisted development workflows. + Now, run the development webserver (depending on the docs you want to run): ```bash diff --git a/docs/contributing/pages/llm-support.mdx b/docs/contributing/pages/llm-support.mdx index 6aeab6476ccbd..da6f4a0687047 100644 --- a/docs/contributing/pages/llm-support.mdx +++ b/docs/contributing/pages/llm-support.mdx @@ -29,7 +29,8 @@ Our existing documentation principles naturally support LLM consumption: When writing documentation, keep in mind: -- **Frontmatter descriptions** appear in section listings and help LLMs understand page purpose +- **Frontmatter descriptions are injected into MD exports** as italic text after the H1 heading. Pages without a `description` in their frontmatter lose this LLM relevance signal — LLM agents read the first ~500 lines to decide if a page is useful, so a good description helps them prioritize correctly. - **Code examples** should be complete and runnable - LLMs often copy them directly - **Avoid ambiguous references** like "click the button above" that require visual context - Pages with `noindex: true` are excluded from search and LLM discovery +- **Hub pages** (pages that only contain ``) rely on their frontmatter `description` for context in MD exports — make sure they have one diff --git a/docs/platforms/android/index.mdx b/docs/platforms/android/index.mdx index ded14c1bd01fc..addfbc8d3011a 100644 --- a/docs/platforms/android/index.mdx +++ b/docs/platforms/android/index.mdx @@ -1,5 +1,6 @@ --- title: Android +description: "Learn how to set up Sentry's Android SDK for error monitoring and performance tracking." caseStyle: camelCase supportLevel: production sdk: sentry.java.android diff --git a/docs/platforms/apple/index.mdx b/docs/platforms/apple/index.mdx index 87e696cd5bc85..57f27863a61c9 100644 --- a/docs/platforms/apple/index.mdx +++ b/docs/platforms/apple/index.mdx @@ -1,5 +1,6 @@ --- title: Apple +description: "Learn how to set up Sentry's Apple SDK for error monitoring and performance tracking." sdk: sentry.cocoa caseStyle: camelCase supportLevel: production diff --git a/docs/platforms/godot/index.mdx b/docs/platforms/godot/index.mdx index f2ccb5b1a4c8f..d5478fa2a2150 100644 --- a/docs/platforms/godot/index.mdx +++ b/docs/platforms/godot/index.mdx @@ -1,5 +1,6 @@ --- title: Godot Engine +description: "Learn how to set up Sentry's Godot Engine SDK for error monitoring and performance tracking." caseStyle: snake_case supportLevel: production sdk: sentry.godot diff --git a/docs/platforms/kotlin/index.mdx b/docs/platforms/kotlin/index.mdx index b4118c394dc07..f4362da74e7e6 100644 --- a/docs/platforms/kotlin/index.mdx +++ b/docs/platforms/kotlin/index.mdx @@ -1,5 +1,6 @@ --- title: Kotlin +description: "Learn how to set up Sentry's Kotlin SDK for error monitoring and performance tracking." caseStyle: camelCase supportLevel: production categories: diff --git a/docs/platforms/nintendo-switch/index.mdx b/docs/platforms/nintendo-switch/index.mdx index 99e92a848ad3d..cc81e89922bb3 100644 --- a/docs/platforms/nintendo-switch/index.mdx +++ b/docs/platforms/nintendo-switch/index.mdx @@ -1,5 +1,6 @@ --- title: Nintendo Switch +description: "Learn how to set up Sentry's Nintendo Switch SDK for error monitoring and performance tracking." caseStyle: snake_case supportLevel: production sdk: sentry.nintendo-switch diff --git a/docs/platforms/playstation/index.mdx b/docs/platforms/playstation/index.mdx index 50e4eadb257c8..1e768ef49d213 100644 --- a/docs/platforms/playstation/index.mdx +++ b/docs/platforms/playstation/index.mdx @@ -1,5 +1,6 @@ --- title: PlayStation +description: "Learn how to set up Sentry's PlayStation SDK for error monitoring and performance tracking." caseStyle: snake_case supportLevel: production sdk: sentry.playstation diff --git a/docs/platforms/powershell/index.mdx b/docs/platforms/powershell/index.mdx index 6e7df0d05760b..146c61e5fffeb 100644 --- a/docs/platforms/powershell/index.mdx +++ b/docs/platforms/powershell/index.mdx @@ -1,5 +1,6 @@ --- title: PowerShell +description: "Learn how to set up Sentry's PowerShell SDK for error monitoring and performance tracking." caseStyle: PascalCase supportLevel: production sdk: sentry.dotnet.powershell diff --git a/docs/platforms/rust/index.mdx b/docs/platforms/rust/index.mdx index 116ad0d3b95a8..4826683268991 100644 --- a/docs/platforms/rust/index.mdx +++ b/docs/platforms/rust/index.mdx @@ -1,5 +1,6 @@ --- title: Rust +description: "Learn how to set up Sentry's Rust SDK for error monitoring and performance tracking." sdk: sentry.rust caseStyle: snake_case supportLevel: production diff --git a/docs/platforms/unity/index.mdx b/docs/platforms/unity/index.mdx index fdf7385f89f0a..331a8d8f6bfb1 100644 --- a/docs/platforms/unity/index.mdx +++ b/docs/platforms/unity/index.mdx @@ -1,5 +1,6 @@ --- title: Unity +description: "Learn how to set up Sentry's Unity SDK for error monitoring and performance tracking." caseStyle: PascalCase supportLevel: production sdk: sentry.dotnet.unity diff --git a/docs/platforms/unreal/index.mdx b/docs/platforms/unreal/index.mdx index 31a2c13181a99..f4e435acf4cf6 100644 --- a/docs/platforms/unreal/index.mdx +++ b/docs/platforms/unreal/index.mdx @@ -1,5 +1,6 @@ --- title: Unreal Engine +description: "Learn how to set up Sentry's Unreal Engine SDK for error monitoring and performance tracking." caseStyle: PascalCase supportLevel: production sdk: sentry.unreal diff --git a/docs/platforms/xbox/index.mdx b/docs/platforms/xbox/index.mdx index f7216782be22c..63bdf069fd3b0 100644 --- a/docs/platforms/xbox/index.mdx +++ b/docs/platforms/xbox/index.mdx @@ -1,5 +1,6 @@ --- title: Xbox +description: "Learn how to set up Sentry's Xbox SDK for error monitoring and performance tracking." caseStyle: snake_case supportLevel: production sdk: sentry.xbox diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 42400b9cd00be..74d0ecd78e287 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -294,6 +294,14 @@ function buildFallbackChildSection(parentPath, children) { return childSection; } +/** + * Injects a description as italic text after the first H1 heading in markdown. + * Returns the original content unchanged if no H1 is found. + */ +function injectDescription(markdown, description) { + return markdown.replace(/^(# .+)$/m, `$1\n\n*${description}*\n`); +} + // --- MDX template rendering for full-page overrides --- function buildMdxComponents(docTree, createElement) { @@ -712,8 +720,20 @@ async function createWork() { // Append child listings to parent index files. // Skip pages whose MDX override sets append_sections: false. + const hasR2 = !!(accessKeyId && secretAccessKey && existingFilesOnR2); let updatedCount = 0; const r2Uploads = []; + + function collectR2Upload(key, data) { + if (!hasR2) { + return; + } + const fileHash = md5(data); + if (existingFilesOnR2.get(key) !== fileHash) { + r2Uploads.push({key, data}); + } + } + for (const [parentPath, children] of pathsByParent) { const overrideFm = mdxOverrides.get(parentPath)?.frontmatter; if (overrideFm?.append_sections === false) { @@ -749,30 +769,54 @@ async function createWork() { const updatedContent = existingContent + childSection; await writeFile(parentFile, updatedContent, {encoding: 'utf8'}); updatedCount++; + collectR2Upload(parentPath, updatedContent); + } + } + console.log(`📑 Added child page listings to ${updatedCount} section index files`); - // Collect R2 uploads needed (will be uploaded in parallel below) - if (accessKeyId && secretAccessKey && existingFilesOnR2) { - const fileHash = md5(updatedContent); - const existingHash = existingFilesOnR2.get(parentPath); - if (existingHash !== fileHash) { - r2Uploads.push({parentPath, updatedContent}); - } - } + // Inject frontmatter descriptions after H1 headings in MD exports. + // This helps LLM agents quickly assess page relevance from the first few lines. + // Skip MDX override pages (they have custom intros). + const mdxOverridePaths = new Set(mdxOverrides.keys()); + let descriptionCount = 0; + for (const relativePath of allPaths) { + if (mdxOverridePaths.has(relativePath)) { + continue; } + const pathParts = relativePath.replace(/\.md$/, '').split('/'); + const node = docTree ? findNode(docTree, pathParts) : null; + const description = node?.frontmatter?.description; + if (!description) { + continue; + } + const filePath = path.join(OUTPUT_DIR, relativePath); + let content; + try { + content = await readFile(filePath, {encoding: 'utf8'}); + } catch { + continue; + } + const injected = injectDescription(content, description); + if (injected === content) { + continue; + } + await writeFile(filePath, injected, {encoding: 'utf8'}); + descriptionCount++; + collectR2Upload(relativePath, injected); + } + if (descriptionCount > 0) { + console.log(`📝 Injected descriptions into ${descriptionCount} markdown files`); } - // Upload all modified section index files to R2 in parallel + // Upload all modified files to R2 in parallel if (r2Uploads.length > 0) { const limit = pLimit(50); const s3Client = getS3Client(); await Promise.all( - r2Uploads.map(({parentPath, updatedContent}) => - limit(() => uploadToCFR2(s3Client, parentPath, updatedContent)) - ) + r2Uploads.map(({key, data}) => limit(() => uploadToCFR2(s3Client, key, data))) ); - console.log(`📤 Uploaded ${r2Uploads.length} section index files to R2`); + console.log(`📤 Uploaded ${r2Uploads.length} modified files to R2`); } - console.log(`📑 Added child page listings to ${updatedCount} section index files`); // Clean up unused cache files to prevent unbounded growth if (!noCache) { diff --git a/specs/llm-friendly-docs.md b/specs/llm-friendly-docs.md index 6cab1d29dd07d..7ee7d8da40884 100644 --- a/specs/llm-friendly-docs.md +++ b/specs/llm-friendly-docs.md @@ -28,6 +28,7 @@ Markdown exports are not just raw dumps of HTML content. They are adapted for LL | **Links** | Relative HTML paths | Absolute `.md` URLs (e.g., `https://docs.sentry.io/platforms/javascript.md`) | | **Images** | Relative paths | Absolute URLs | | **Page structure** | Header, sidebar, main content, footer | Title + main content + navigation sections | +| **Description** | HTML meta tag only | Injected as italic text after H1 heading | ## Page Customization Architecture @@ -140,6 +141,28 @@ Pages without a matching override get a generic "Pages in this section" listing - Sorted by `sidebar_order`, then alphabetically by title - Hidden/draft/versioned pages filtered out +## Description Injection + +After all markdown files are generated and child sections are appended, the script injects frontmatter `description` values into the markdown output. This gives LLM agents a relevance signal in the first few lines of each page. + +**How it works:** + +1. For each `.md` file, look up the corresponding doctree node and read `frontmatter.description` +2. If a description exists, find the H1 line and insert `*{description}*` (italic) after it +3. Skip MDX override pages (they have custom intros) + +**Result:** + +```markdown +# Python | Sentry for Python + +*Sentry's Python SDK enables automatic reporting of errors and performance data.* + +## Prerequisites +``` + +The `injectDescription(markdown, description)` function finds the first `^# .+$` line and inserts the italic description after it. Files without an H1 or without a description are left unchanged. Modified files are uploaded to R2 if credentials are configured. + ## Current Override Registry ### MDX Template Overrides @@ -183,6 +206,7 @@ During `generate-md-exports`: 3. Discover HTML files, swapping source path for MDX override pages 4. Workers convert HTML → Markdown (parallel, cached, with R2 sync) 5. Append navigation sections to parent pages using `pageOverrides` +6. Inject frontmatter descriptions after H1 headings (skipping MDX overrides) ## Adding a New Override From 3321f6ea9ae507266217ae9ed5ef3eafa41624da Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 19 Feb 2026 13:35:03 -0800 Subject: [PATCH 02/13] feat(md-exports): Add navigation links to MD export headers Inject a documentation index link and platform-specific navigation after the description in MD exports. Guide pages (e.g., Flask) also get a link back to their platform index (e.g., Python SDK docs). This helps LLM agents navigate between related pages. Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 74d0ecd78e287..ec4cb2793edc4 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -295,11 +295,15 @@ function buildFallbackChildSection(parentPath, children) { } /** - * Injects a description as italic text after the first H1 heading in markdown. + * Injects a description and navigation links after the first H1 heading. * Returns the original content unchanged if no H1 is found. */ -function injectDescription(markdown, description) { - return markdown.replace(/^(# .+)$/m, `$1\n\n*${description}*\n`); +function injectDescription(markdown, description, {navLinks = []} = {}) { + let suffix = `\n\n*${description}*\n`; + if (navLinks.length > 0) { + suffix += '\n' + navLinks.map(link => `*${link}*`).join('\n') + '\n'; + } + return markdown.replace(/^(# .+)$/m, `$1${suffix}`); } // --- MDX template rendering for full-page overrides --- @@ -796,7 +800,18 @@ async function createWork() { } catch { continue; } - const injected = injectDescription(content, description); + const navLinks = []; + if (relativePath !== 'index.md') { + navLinks.push(`Full documentation index: ${DOCS_ORIGIN}/`); + } + // Guide pages get a link back to their platform index + const parts = relativePath.replace(/\.md$/, '').split('/'); + if (parts[0] === 'platforms' && parts.includes('guides') && parts.length >= 4) { + const platformNode = docTree ? findNode(docTree, ['platforms', parts[1]]) : null; + const platformName = platformNode ? getTitle(platformNode) : parts[1]; + navLinks.push(`${platformName} SDK docs: ${DOCS_ORIGIN}/platforms/${parts[1]}/`); + } + const injected = injectDescription(content, description, {navLinks}); if (injected === content) { continue; } From 867709e619fcdd49d3c74807d349b6f63315b0ec Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 19 Feb 2026 13:40:18 -0800 Subject: [PATCH 03/13] chore: Gitignore dotagents managed skills Let dotagents manage skill installation at dev time rather than checking them into the repo. `make develop` runs `dotagents install`. Co-Authored-By: Claude --- .agents/.gitignore | 3 + .agents/skills/brand-guidelines/SKILL.md | 168 ----------------------- agents.toml | 2 +- 3 files changed, 4 insertions(+), 169 deletions(-) create mode 100644 .agents/.gitignore delete mode 100644 .agents/skills/brand-guidelines/SKILL.md diff --git a/.agents/.gitignore b/.agents/.gitignore new file mode 100644 index 0000000000000..7c94641724aab --- /dev/null +++ b/.agents/.gitignore @@ -0,0 +1,3 @@ +# Auto-generated by dotagents. Do not edit. +# Managed skills (installed by dotagents) +/skills/brand-guidelines/ diff --git a/.agents/skills/brand-guidelines/SKILL.md b/.agents/skills/brand-guidelines/SKILL.md deleted file mode 100644 index 2f974141c88b1..0000000000000 --- a/.agents/skills/brand-guidelines/SKILL.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -name: brand-guidelines -description: Write copy following Sentry brand guidelines. Use when writing UI text, error messages, empty states, onboarding flows, 404 pages, documentation, marketing copy, or any user-facing content. Covers both Plain Speech (default) and Sentry Voice tones. ---- - -# Brand Guidelines - -Write user-facing copy following Sentry's brand guidelines. - -## Tone Selection - -Choose the appropriate tone based on context: - -| Use Plain Speech | Use Sentry Voice | -|------------------|------------------| -| Product UI (buttons, labels, forms) | 404 pages | -| Documentation | Empty states | -| Error messages | Onboarding flows | -| Settings pages | Loading states | -| Transactional emails | "What's New" announcements | -| Help text | Marketing copy | - -**Default to Plain Speech** unless the context specifically calls for personality. - -## Plain Speech (Default) - -Plain Speech is clear, direct, and functional. Use it for most UI elements. - -### Rules - -1. **Be concise** - Use the fewest words needed -2. **Be direct** - Tell users what to do, not what they can do -3. **Use active voice** - "Save your changes" not "Your changes will be saved" -4. **Avoid jargon** - Use simple words users understand -5. **Be specific** - "3 errors found" not "Some errors found" - -### Examples - -| Instead of | Write | -|------------|-------| -| "Click here to save your changes" | "Save" | -| "You can filter results by date" | "Filter by date" | -| "An error has occurred" | "Something went wrong" | -| "Please enter a valid email address" | "Enter a valid email" | -| "Are you sure you want to delete?" | "Delete this item?" | - -## Sentry Voice - -Sentry Voice adds personality in appropriate moments. It's empathetic, self-aware, and occasionally snarky. - -### Principles - -1. **Empathetic snark** - Direct frustration at the situation, never the user -2. **Self-aware** - Acknowledge the absurdity of software -3. **Fun but functional** - Personality should enhance, not obscure meaning -4. **Earned moments** - Only use when users have time to appreciate it - -### Examples - -**404 Pages:** -> "This page doesn't exist. Maybe it never did. Maybe it was a dream. Either way, let's get you back on track." - -**Empty States:** -> "No errors yet. Enjoy this moment of peace while it lasts." - -**Onboarding:** -> "Let's get your first error. Don't worry, it's not as scary as it sounds." - -**Loading States:** -> "Crunching the numbers..." -> "Fetching your data..." - -### When NOT to Use Sentry Voice - -- Error messages (users are frustrated) -- Settings pages (users are focused) -- Documentation (users need information) -- Billing/payment flows (users need trust) - -## General Rules - -### Spelling and Grammar - -- Use **American English** spelling (color, not colour) -- Use **Title Case** for headings and page titles -- Use **Sentence case** for body text, buttons, and labels - -### Punctuation - -- **No exclamation marks** in UI text (exception: celebratory moments) -- **No periods** in short UI labels or button text -- **Use periods** in complete sentences and help text -- **No ALL CAPS** except for acronyms (API, SDK, URL) - -### Word Choices - -| Avoid | Prefer | -|-------|--------| -| Please | (omit) | -| Sorry | (be specific about the problem) | -| Error occurred | Something went wrong | -| Invalid | (explain what's wrong) | -| Success! | (describe what happened) | -| Oops | (be specific) | - -## Dash Usage - -| Type | Use | Example | -|------|-----|---------| -| Hyphen (-) | Compound words, ranges | "real-time", "1-10" | -| En-dash (--) | Ranges, relationships | "2023--2024", "parent--child" | -| Em-dash (---) | Interruption, emphasis | "Errors---even small ones---matter" | - -In most UI contexts, use hyphens. Reserve en-dashes for date ranges and em-dashes for longer prose. - -## UI Element Guidelines - -### Buttons - -- Use action verbs: "Save", "Delete", "Create" -- Be specific: "Create Project" not just "Create" -- Max 2-3 words when possible -- No periods or exclamation marks - -### Error Messages - -1. Say what happened -2. Say why (if helpful) -3. Say what to do next - -**Good:** "Could not save changes. Check your connection and try again." -**Bad:** "Error: Save failed." - -### Empty States - -1. Explain what would normally be here -2. Provide a clear action to populate the state -3. Sentry Voice is appropriate here - -**Good:** "No projects yet. Create your first project to start tracking errors." - -### Confirmation Dialogs - -- Make the action clear in the title -- Explain consequences if destructive -- Use specific button labels ("Delete Project", not "OK") - -### Tooltips and Help Text - -- Keep under 2 sentences -- Explain the "why", not just the "what" -- Link to docs for complex topics - -## Anti-Patterns - -Avoid these common mistakes: - -- **Robot speak:** "Item has been successfully deleted" -> "Deleted" -- **Passive voice:** "Changes were saved" -> "Changes saved" -- **Unnecessary words:** "In order to" -> "To" -- **Hedging:** "This might cause..." -> "This will cause..." -- **Double negatives:** "Not unlike..." -> "Similar to..." -- **Marketing speak in UI:** "Supercharge your workflow" -> "Speed up your workflow" - -## References - -- [Sentry Voice Guidelines](https://develop.sentry.dev/frontend/sentry-voice/) -- [Sentry Frontend Handbook](https://develop.sentry.dev/frontend/) diff --git a/agents.toml b/agents.toml index bfceb01c47f51..c4b3f97cc84d2 100644 --- a/agents.toml +++ b/agents.toml @@ -1,7 +1,7 @@ version = 1 # Check skills into git so collaborators get them without running 'dotagents install'. # Set to true (or remove) to gitignore managed skills instead. -gitignore = false +gitignore = true [[skills]] name = "brand-guidelines" From 5a189fec5a969d2adba198eef7e69a3bb126a25c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 19 Feb 2026 13:41:44 -0800 Subject: [PATCH 04/13] fix(md-exports): Fix R2 race condition and dollar sign in descriptions Use a Map for R2 uploads so description injection overwrites stale entries from child section appending for the same page. Use a replacer function in injectDescription to avoid $ in descriptions being interpreted as regex replacement patterns. Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index ec4cb2793edc4..eecf6a2650d97 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -303,7 +303,9 @@ function injectDescription(markdown, description, {navLinks = []} = {}) { if (navLinks.length > 0) { suffix += '\n' + navLinks.map(link => `*${link}*`).join('\n') + '\n'; } - return markdown.replace(/^(# .+)$/m, `$1${suffix}`); + // Use a replacer function to avoid $ in descriptions being interpreted as + // special regex replacement patterns (e.g. $1, $&, $') + return markdown.replace(/^(# .+)$/m, match => match + suffix); } // --- MDX template rendering for full-page overrides --- @@ -726,7 +728,9 @@ async function createWork() { // Skip pages whose MDX override sets append_sections: false. const hasR2 = !!(accessKeyId && secretAccessKey && existingFilesOnR2); let updatedCount = 0; - const r2Uploads = []; + // Use a Map so later writes (e.g. description injection) replace earlier + // entries (e.g. child section append) for the same key, avoiding stale uploads. + const r2Uploads = new Map(); function collectR2Upload(key, data) { if (!hasR2) { @@ -734,7 +738,7 @@ async function createWork() { } const fileHash = md5(data); if (existingFilesOnR2.get(key) !== fileHash) { - r2Uploads.push({key, data}); + r2Uploads.set(key, data); } } @@ -824,13 +828,13 @@ async function createWork() { } // Upload all modified files to R2 in parallel - if (r2Uploads.length > 0) { + if (r2Uploads.size > 0) { const limit = pLimit(50); const s3Client = getS3Client(); await Promise.all( - r2Uploads.map(({key, data}) => limit(() => uploadToCFR2(s3Client, key, data))) + [...r2Uploads].map(([key, data]) => limit(() => uploadToCFR2(s3Client, key, data))) ); - console.log(`📤 Uploaded ${r2Uploads.length} modified files to R2`); + console.log(`📤 Uploaded ${r2Uploads.size} modified files to R2`); } // Clean up unused cache files to prevent unbounded growth From f15007b16df9a1a5b71416511ea8e3ca4d74a7c2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 19 Feb 2026 13:57:10 -0800 Subject: [PATCH 05/13] fix(md-exports): Fix stale R2 entry and duplicate path computation Move hash comparison from collectR2Upload to upload time so the Map always holds the latest content per key. Previously, if description injection produced content matching R2's existing hash, the stale child-section-only entry would persist and get uploaded. Also remove duplicate pathParts computation in guide link logic. Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 36 +++++++++++++++++---------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index eecf6a2650d97..9d4e2e99d8845 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -728,18 +728,16 @@ async function createWork() { // Skip pages whose MDX override sets append_sections: false. const hasR2 = !!(accessKeyId && secretAccessKey && existingFilesOnR2); let updatedCount = 0; - // Use a Map so later writes (e.g. description injection) replace earlier - // entries (e.g. child section append) for the same key, avoiding stale uploads. + // Always store the latest content per key. Hash comparison happens at upload + // time so that later writes (description injection) always overwrite earlier + // entries (child section append) even when the final content matches R2. const r2Uploads = new Map(); function collectR2Upload(key, data) { if (!hasR2) { return; } - const fileHash = md5(data); - if (existingFilesOnR2.get(key) !== fileHash) { - r2Uploads.set(key, data); - } + r2Uploads.set(key, data); } for (const [parentPath, children] of pathsByParent) { @@ -809,11 +807,10 @@ async function createWork() { navLinks.push(`Full documentation index: ${DOCS_ORIGIN}/`); } // Guide pages get a link back to their platform index - const parts = relativePath.replace(/\.md$/, '').split('/'); - if (parts[0] === 'platforms' && parts.includes('guides') && parts.length >= 4) { - const platformNode = docTree ? findNode(docTree, ['platforms', parts[1]]) : null; - const platformName = platformNode ? getTitle(platformNode) : parts[1]; - navLinks.push(`${platformName} SDK docs: ${DOCS_ORIGIN}/platforms/${parts[1]}/`); + if (pathParts[0] === 'platforms' && pathParts.includes('guides') && pathParts.length >= 4) { + const platformNode = docTree ? findNode(docTree, ['platforms', pathParts[1]]) : null; + const platformName = platformNode ? getTitle(platformNode) : pathParts[1]; + navLinks.push(`${platformName} SDK docs: ${DOCS_ORIGIN}/platforms/${pathParts[1]}/`); } const injected = injectDescription(content, description, {navLinks}); if (injected === content) { @@ -827,14 +824,19 @@ async function createWork() { console.log(`📝 Injected descriptions into ${descriptionCount} markdown files`); } - // Upload all modified files to R2 in parallel + // Upload modified files to R2, skipping those whose hash already matches if (r2Uploads.size > 0) { - const limit = pLimit(50); - const s3Client = getS3Client(); - await Promise.all( - [...r2Uploads].map(([key, data]) => limit(() => uploadToCFR2(s3Client, key, data))) + const toUpload = [...r2Uploads].filter( + ([key, data]) => existingFilesOnR2.get(key) !== md5(data) ); - console.log(`📤 Uploaded ${r2Uploads.size} modified files to R2`); + if (toUpload.length > 0) { + const limit = pLimit(50); + const s3Client = getS3Client(); + await Promise.all( + toUpload.map(([key, data]) => limit(() => uploadToCFR2(s3Client, key, data))) + ); + console.log(`📤 Uploaded ${toUpload.length} modified files to R2`); + } } // Clean up unused cache files to prevent unbounded growth From 6fdea1261e5b9267d4b9f9adbb353ea9e716606f Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:58:16 +0000 Subject: [PATCH 06/13] [getsentry/action-github-commit] Auto commit --- scripts/generate-md-exports.mjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 9d4e2e99d8845..1f4a927d4ca9d 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -807,10 +807,18 @@ async function createWork() { navLinks.push(`Full documentation index: ${DOCS_ORIGIN}/`); } // Guide pages get a link back to their platform index - if (pathParts[0] === 'platforms' && pathParts.includes('guides') && pathParts.length >= 4) { - const platformNode = docTree ? findNode(docTree, ['platforms', pathParts[1]]) : null; + if ( + pathParts[0] === 'platforms' && + pathParts.includes('guides') && + pathParts.length >= 4 + ) { + const platformNode = docTree + ? findNode(docTree, ['platforms', pathParts[1]]) + : null; const platformName = platformNode ? getTitle(platformNode) : pathParts[1]; - navLinks.push(`${platformName} SDK docs: ${DOCS_ORIGIN}/platforms/${pathParts[1]}/`); + navLinks.push( + `${platformName} SDK docs: ${DOCS_ORIGIN}/platforms/${pathParts[1]}/` + ); } const injected = injectDescription(content, description, {navLinks}); if (injected === content) { From 4681d1534defc21c79252337f0cb306b06f5353d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 24 Feb 2026 10:22:32 -0800 Subject: [PATCH 07/13] ref(md-exports): Replace description injection with YAML frontmatter Replace the post-processing description injection loop (read-modify-write on every .md file) with YAML frontmatter emitted in the worker pipeline. Each task now carries metadata from the doctree, and processTaskList prepends a YAML block (title, description, url) before writing to disk. This eliminates the separate read-modify-write pass, removes duplicate R2 uploads for pages with descriptions, and keeps the cache immune to metadata changes since frontmatter is added after cache resolution. Co-Authored-By: Claude --- AGENTS.md | 2 +- docs/contributing/pages/llm-support.mdx | 2 +- scripts/generate-md-exports.mjs | 179 ++++++++++++------------ specs/llm-friendly-docs.md | 30 ++-- 4 files changed, 106 insertions(+), 107 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7e1ad9c327d9..821bae23713d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ When writing requirements in `develop-docs/`: - Every page at `docs.sentry.io/` has a `.md` export at `docs.sentry.io/.md` - `scripts/generate-md-exports.mjs` generates these as a post-build step -- Frontmatter `description` fields are injected as italic text after the H1 in MD exports — pages missing descriptions lose LLM relevance signal +- Frontmatter metadata (title, description, URL) is emitted as a YAML frontmatter block in MD exports — pages missing descriptions lose LLM relevance signal - MDX override templates live in `md-overrides/` - Architecture spec: `specs/llm-friendly-docs.md` diff --git a/docs/contributing/pages/llm-support.mdx b/docs/contributing/pages/llm-support.mdx index da6f4a0687047..d480e4b3fc8f0 100644 --- a/docs/contributing/pages/llm-support.mdx +++ b/docs/contributing/pages/llm-support.mdx @@ -29,7 +29,7 @@ Our existing documentation principles naturally support LLM consumption: When writing documentation, keep in mind: -- **Frontmatter descriptions are injected into MD exports** as italic text after the H1 heading. Pages without a `description` in their frontmatter lose this LLM relevance signal — LLM agents read the first ~500 lines to decide if a page is useful, so a good description helps them prioritize correctly. +- **Frontmatter descriptions are included in MD exports** as a YAML frontmatter block (with title, description, and canonical URL). Pages without a `description` in their frontmatter lose this LLM relevance signal — LLM agents read the first few lines to decide if a page is useful, so a good description helps them prioritize correctly. - **Code examples** should be complete and runnable - LLMs often copy them directly - **Avoid ambiguous references** like "click the button above" that require visual context - Pages with `noindex: true` are excluded from search and LLM discovery diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 1f4a927d4ca9d..bb3187b531e87 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -295,17 +295,57 @@ function buildFallbackChildSection(parentPath, children) { } /** - * Injects a description and navigation links after the first H1 heading. - * Returns the original content unchanged if no H1 is found. + * Walks the doctree to build a map of relativePath → {title, description, url} + * for YAML frontmatter generation. */ -function injectDescription(markdown, description, {navLinks = []} = {}) { - let suffix = `\n\n*${description}*\n`; - if (navLinks.length > 0) { - suffix += '\n' + navLinks.map(link => `*${link}*`).join('\n') + '\n'; +function buildFrontmatterMap(docTree) { + const map = new Map(); + if (!docTree) { + return map; } - // Use a replacer function to avoid $ in descriptions being interpreted as - // special regex replacement patterns (e.g. $1, $&, $') - return markdown.replace(/^(# .+)$/m, match => match + suffix); + + function walk(node) { + if (node.path) { + const relativePath = node.path + '.md'; + map.set(relativePath, { + title: getTitle(node), + description: node.frontmatter?.description || '', + url: `${DOCS_ORIGIN}/${node.path}/`, + }); + } + if (node.children) { + for (const child of node.children) { + walk(child); + } + } + } + + if (docTree.children) { + for (const child of docTree.children) { + walk(child); + } + } + + return map; +} + +/** + * Formats a YAML frontmatter block for a markdown file. + * Only includes fields that have non-empty values. + */ +function formatYamlFrontmatter({title, description, url}) { + let yaml = '---\n'; + if (title) { + yaml += `title: "${title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`; + } + if (description) { + yaml += `description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`; + } + if (url) { + yaml += `url: ${url}\n`; + } + yaml += '---\n\n'; + return yaml; } // --- MDX template rendering for full-page overrides --- @@ -547,6 +587,9 @@ async function createWork() { console.warn(' Falling back to slug-based navigation'); } + // Build frontmatter map for YAML metadata in markdown output + const frontmatterMap = buildFrontmatterMap(docTree); + // Render MDX template overrides (full-page content replacements) const mdxOverrides = await renderMdxOverrides(root, docTree); @@ -616,11 +659,24 @@ async function createWork() { const relativePath = path.relative(OUTPUT_DIR, targetPath); // Use MDX override HTML if available, otherwise use Next.js build HTML const mdxOverride = mdxOverrides.get(relativePath); + let taskFrontmatter = null; + if (mdxOverride) { + const fm = mdxOverride.frontmatter; + const urlPath = relativePath.replace(/\.md$/, '').replace(/^index$/, ''); + taskFrontmatter = { + title: fm.title || '', + description: fm.description || '', + url: `${DOCS_ORIGIN}/${urlPath}${urlPath ? '/' : ''}`, + }; + } else { + taskFrontmatter = frontmatterMap.get(relativePath) || null; + } workerTasks[workerIdx].push({ sourcePath: mdxOverride ? mdxOverride.htmlPath : sourcePath, targetPath, relativePath, r2Hash: existingFilesOnR2 ? existingFilesOnR2.get(relativePath) : null, + frontmatter: taskFrontmatter, }); workerIdx = (workerIdx + 1) % numWorkers; numFiles++; @@ -728,9 +784,7 @@ async function createWork() { // Skip pages whose MDX override sets append_sections: false. const hasR2 = !!(accessKeyId && secretAccessKey && existingFilesOnR2); let updatedCount = 0; - // Always store the latest content per key. Hash comparison happens at upload - // time so that later writes (description injection) always overwrite earlier - // entries (child section append) even when the final content matches R2. + // Store the latest content per key for R2 upload after child section append. const r2Uploads = new Map(); function collectR2Upload(key, data) { @@ -780,58 +834,6 @@ async function createWork() { } console.log(`📑 Added child page listings to ${updatedCount} section index files`); - // Inject frontmatter descriptions after H1 headings in MD exports. - // This helps LLM agents quickly assess page relevance from the first few lines. - // Skip MDX override pages (they have custom intros). - const mdxOverridePaths = new Set(mdxOverrides.keys()); - let descriptionCount = 0; - for (const relativePath of allPaths) { - if (mdxOverridePaths.has(relativePath)) { - continue; - } - const pathParts = relativePath.replace(/\.md$/, '').split('/'); - const node = docTree ? findNode(docTree, pathParts) : null; - const description = node?.frontmatter?.description; - if (!description) { - continue; - } - const filePath = path.join(OUTPUT_DIR, relativePath); - let content; - try { - content = await readFile(filePath, {encoding: 'utf8'}); - } catch { - continue; - } - const navLinks = []; - if (relativePath !== 'index.md') { - navLinks.push(`Full documentation index: ${DOCS_ORIGIN}/`); - } - // Guide pages get a link back to their platform index - if ( - pathParts[0] === 'platforms' && - pathParts.includes('guides') && - pathParts.length >= 4 - ) { - const platformNode = docTree - ? findNode(docTree, ['platforms', pathParts[1]]) - : null; - const platformName = platformNode ? getTitle(platformNode) : pathParts[1]; - navLinks.push( - `${platformName} SDK docs: ${DOCS_ORIGIN}/platforms/${pathParts[1]}/` - ); - } - const injected = injectDescription(content, description, {navLinks}); - if (injected === content) { - continue; - } - await writeFile(filePath, injected, {encoding: 'utf8'}); - descriptionCount++; - collectR2Upload(relativePath, injected); - } - if (descriptionCount > 0) { - console.log(`📝 Injected descriptions into ${descriptionCount} markdown files`); - } - // Upload modified files to R2, skipping those whose hash already matches if (r2Uploads.size > 0) { const toUpload = [...r2Uploads].filter( @@ -953,7 +955,7 @@ function extractContentForCacheKey(html) { return title + '\0' + canonical + '\0' + normalizedMain; } -async function genMDFromHTML(source, target, {cacheDir, noCache, usedCacheFiles}) { +async function genMDFromHTML(source, {cacheDir, noCache, usedCacheFiles}) { const rawHTML = await readFile(source, {encoding: 'utf8'}); // Strip build-specific HTML elements for faster parsing. // See stripUnstableElements() for details on what's removed and why. @@ -968,7 +970,6 @@ async function genMDFromHTML(source, target, {cacheDir, noCache, usedCacheFiles} const data = await text( compose(createReadStream(cacheFile), createBrotliDecompress()) ); - await writeFile(target, data, {encoding: 'utf8'}); // Track that we used this cache file if (usedCacheFiles) { @@ -1049,28 +1050,18 @@ async function genMDFromHTML(source, target, {cacheDir, noCache, usedCacheFiles} .use(remarkStringify) .process(strippedHTML) ); - const reader = Readable.from(data); - - await Promise.all([ - pipeline( - reader, - createWriteStream(target, { - encoding: 'utf8', - }) - ), - pipeline( - reader, - createBrotliCompress({ - chunkSize: 32 * 1024, - params: { - [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, - [zlibConstants.BROTLI_PARAM_QUALITY]: CACHE_COMPRESS_LEVEL, - [zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.length, - }, - }), - createWriteStream(cacheFile) - ).catch(err => console.warn('Error writing cache file:', err)), - ]); + await pipeline( + Readable.from(data), + createBrotliCompress({ + chunkSize: 32 * 1024, + params: { + [zlibConstants.BROTLI_PARAM_MODE]: zlibConstants.BROTLI_MODE_TEXT, + [zlibConstants.BROTLI_PARAM_QUALITY]: CACHE_COMPRESS_LEVEL, + [zlibConstants.BROTLI_PARAM_SIZE_HINT]: data.length, + }, + }), + createWriteStream(cacheFile) + ).catch(err => console.warn('Error writing cache file:', err)); // Track that we created this cache file if (usedCacheFiles) { @@ -1091,9 +1082,9 @@ async function processTaskList({id, tasks, cacheDir, noCache, usedCacheFiles}) { let cacheMisses = []; let r2CacheMisses = []; console.log(`🤖 Worker[${id}]: Starting to process ${tasks.length} files...`); - for (const {sourcePath, targetPath, relativePath, r2Hash} of tasks) { + for (const {sourcePath, targetPath, relativePath, r2Hash, frontmatter} of tasks) { try { - const {data, cacheHit} = await genMDFromHTML(sourcePath, targetPath, { + const {data, cacheHit} = await genMDFromHTML(sourcePath, { cacheDir, noCache, usedCacheFiles, @@ -1102,12 +1093,16 @@ async function processTaskList({id, tasks, cacheDir, noCache, usedCacheFiles}) { cacheMisses.push(relativePath); } + // Prepend YAML frontmatter and write to target + const output = frontmatter ? formatYamlFrontmatter(frontmatter) + data : data; + await writeFile(targetPath, output, {encoding: 'utf8'}); + if (r2Hash !== null) { - const fileHash = md5(data); + const fileHash = md5(output); if (r2Hash !== fileHash) { r2CacheMisses.push(relativePath); - await uploadToCFR2(s3Client, relativePath, data); + await uploadToCFR2(s3Client, relativePath, output); } } } catch (error) { diff --git a/specs/llm-friendly-docs.md b/specs/llm-friendly-docs.md index 7ee7d8da40884..8c4eb8223cf7d 100644 --- a/specs/llm-friendly-docs.md +++ b/specs/llm-friendly-docs.md @@ -28,7 +28,7 @@ Markdown exports are not just raw dumps of HTML content. They are adapted for LL | **Links** | Relative HTML paths | Absolute `.md` URLs (e.g., `https://docs.sentry.io/platforms/javascript.md`) | | **Images** | Relative paths | Absolute URLs | | **Page structure** | Header, sidebar, main content, footer | Title + main content + navigation sections | -| **Description** | HTML meta tag only | Injected as italic text after H1 heading | +| **Description** | HTML meta tag only | YAML frontmatter block with title, description, and canonical URL | ## Page Customization Architecture @@ -141,27 +141,32 @@ Pages without a matching override get a generic "Pages in this section" listing - Sorted by `sidebar_order`, then alphabetically by title - Hidden/draft/versioned pages filtered out -## Description Injection +## YAML Frontmatter -After all markdown files are generated and child sections are appended, the script injects frontmatter `description` values into the markdown output. This gives LLM agents a relevance signal in the first few lines of each page. +Every markdown export includes a YAML frontmatter block with metadata from the doctree. This gives LLM agents a relevance signal before the content begins and provides the canonical URL for navigation. **How it works:** -1. For each `.md` file, look up the corresponding doctree node and read `frontmatter.description` -2. If a description exists, find the H1 line and insert `*{description}*` (italic) after it -3. Skip MDX override pages (they have custom intros) +1. Before workers start, `buildFrontmatterMap(docTree)` walks the doctree and creates a map of `relativePath → {title, description, url}` +2. Each task carries its frontmatter metadata (from the map, or from MDX override frontmatter) +3. After `genMDFromHTML` returns cached/converted markdown, `processTaskList` prepends the YAML frontmatter +4. The combined output (frontmatter + markdown) is written to the target file and used for R2 hash comparison **Result:** ```markdown -# Python | Sentry for Python +--- +title: "Sentry for Python" +description: "Sentry's Python SDK enables automatic reporting of errors and performance data." +url: https://docs.sentry.io/platforms/python/ +--- -*Sentry's Python SDK enables automatic reporting of errors and performance data.* +# Python | Sentry for Python ## Prerequisites ``` -The `injectDescription(markdown, description)` function finds the first `^# .+$` line and inserts the italic description after it. Files without an H1 or without a description are left unchanged. Modified files are uploaded to R2 if credentials are configured. +Pages without a doctree entry (e.g., error pages) get no frontmatter. The cache stores raw markdown without frontmatter, so metadata changes don't cause unnecessary cache invalidation. ## Current Override Registry @@ -201,12 +206,11 @@ pnpm generate-md-exports → public/md-exports/**/*.md (+ R2 sync) During `generate-md-exports`: -1. Load doctree +1. Load doctree, build frontmatter map 2. Render MDX templates from `md-overrides/` → HTML in `.next/cache/md-override-html/` -3. Discover HTML files, swapping source path for MDX override pages -4. Workers convert HTML → Markdown (parallel, cached, with R2 sync) +3. Discover HTML files, swapping source path for MDX override pages, attaching frontmatter to tasks +4. Workers convert HTML → Markdown, prepend YAML frontmatter, write to target (parallel, cached, with R2 sync) 5. Append navigation sections to parent pages using `pageOverrides` -6. Inject frontmatter descriptions after H1 headings (skipping MDX overrides) ## Adding a New Override From 6a9a5d90c8754c7b3d446176002930dee101bfcc Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 24 Feb 2026 11:12:27 -0800 Subject: [PATCH 08/13] fix(md-exports): Use for...of loop instead of spread-then-filter on Map Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index bb3187b531e87..852661a105376 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -836,9 +836,12 @@ async function createWork() { // Upload modified files to R2, skipping those whose hash already matches if (r2Uploads.size > 0) { - const toUpload = [...r2Uploads].filter( - ([key, data]) => existingFilesOnR2.get(key) !== md5(data) - ); + const toUpload = []; + for (const [key, data] of r2Uploads) { + if (existingFilesOnR2.get(key) !== md5(data)) { + toUpload.push([key, data]); + } + } if (toUpload.length > 0) { const limit = pLimit(50); const s3Client = getS3Client(); From ed1994c3172f23c08e748ff278b6c64e096f52e3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 24 Feb 2026 11:32:07 -0800 Subject: [PATCH 09/13] ref(md-exports): Inline single-use collectR2Upload helper Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 852661a105376..a746732f6cfd6 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -787,13 +787,6 @@ async function createWork() { // Store the latest content per key for R2 upload after child section append. const r2Uploads = new Map(); - function collectR2Upload(key, data) { - if (!hasR2) { - return; - } - r2Uploads.set(key, data); - } - for (const [parentPath, children] of pathsByParent) { const overrideFm = mdxOverrides.get(parentPath)?.frontmatter; if (overrideFm?.append_sections === false) { @@ -829,7 +822,9 @@ async function createWork() { const updatedContent = existingContent + childSection; await writeFile(parentFile, updatedContent, {encoding: 'utf8'}); updatedCount++; - collectR2Upload(parentPath, updatedContent); + if (hasR2) { + r2Uploads.set(parentPath, updatedContent); + } } } console.log(`📑 Added child page listings to ${updatedCount} section index files`); From b2a06c255cefa67a636e35b791815efc7d48447d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 24 Feb 2026 11:40:06 -0800 Subject: [PATCH 10/13] fix(md-exports): Fix nested index URL and YAML newline escaping - Strip /index from nested paths (e.g. dev/index -> dev/) not just top-level index - Replace newlines with spaces in YAML frontmatter title/description to prevent invalid YAML output Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index a746732f6cfd6..56b904691abbb 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -336,10 +336,10 @@ function buildFrontmatterMap(docTree) { function formatYamlFrontmatter({title, description, url}) { let yaml = '---\n'; if (title) { - yaml += `title: "${title.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`; + yaml += `title: "${title.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')}"\n`; } if (description) { - yaml += `description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`; + yaml += `description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, ' ')}"\n`; } if (url) { yaml += `url: ${url}\n`; @@ -662,7 +662,9 @@ async function createWork() { let taskFrontmatter = null; if (mdxOverride) { const fm = mdxOverride.frontmatter; - const urlPath = relativePath.replace(/\.md$/, '').replace(/^index$/, ''); + const urlPath = relativePath + .replace(/\.md$/, '') + .replace(/\/index$|^index$/, ''); taskFrontmatter = { title: fm.title || '', description: fm.description || '', From 4b12de6dd629d321253a72bf8895fadd311677ce Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:40:59 +0000 Subject: [PATCH 11/13] [getsentry/action-github-commit] Auto commit --- scripts/generate-md-exports.mjs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 56b904691abbb..03e5a12383f2a 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -662,9 +662,7 @@ async function createWork() { let taskFrontmatter = null; if (mdxOverride) { const fm = mdxOverride.frontmatter; - const urlPath = relativePath - .replace(/\.md$/, '') - .replace(/\/index$|^index$/, ''); + const urlPath = relativePath.replace(/\.md$/, '').replace(/\/index$|^index$/, ''); taskFrontmatter = { title: fm.title || '', description: fm.description || '', From db7056b8b78059a28e14b6022558ea9139cf0bc6 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 24 Feb 2026 11:56:27 -0800 Subject: [PATCH 12/13] fix(md-exports): Guard S3Client creation and fix division by zero - Only create S3Client when R2 uploads are needed - Prevent NaN in cache miss rate when all tasks fail Co-Authored-By: Claude --- scripts/generate-md-exports.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 03e5a12383f2a..08dc015e9827c 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -1075,7 +1075,8 @@ async function processTaskList({id, tasks, cacheDir, noCache, usedCacheFiles}) { usedCacheFiles = new Set(); } - const s3Client = getS3Client(); + const hasR2 = tasks.some(t => t.r2Hash !== null); + const s3Client = hasR2 ? getS3Client() : null; const failedTasks = []; let cacheMisses = []; let r2CacheMisses = []; @@ -1095,7 +1096,7 @@ async function processTaskList({id, tasks, cacheDir, noCache, usedCacheFiles}) { const output = frontmatter ? formatYamlFrontmatter(frontmatter) + data : data; await writeFile(targetPath, output, {encoding: 'utf8'}); - if (r2Hash !== null) { + if (r2Hash !== null && s3Client) { const fileHash = md5(output); if (r2Hash !== fileHash) { r2CacheMisses.push(relativePath); @@ -1118,8 +1119,9 @@ async function processTaskList({id, tasks, cacheDir, noCache, usedCacheFiles}) { ); } const cacheHits = success - cacheMisses.length; + const missRate = success > 0 ? ((cacheMisses.length / success) * 100).toFixed(1) : '0.0'; console.log( - `📈 Worker[${id}]: Cache stats: ${cacheHits} hits, ${cacheMisses.length} misses (${((cacheMisses.length / success) * 100).toFixed(1)}% miss rate)` + `📈 Worker[${id}]: Cache stats: ${cacheHits} hits, ${cacheMisses.length} misses (${missRate}% miss rate)` ); if (cacheMisses.length / tasks.length > 0.1) { From aed3982421068b7bb63c009b21189b582e07037e Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:57:36 +0000 Subject: [PATCH 13/13] [getsentry/action-github-commit] Auto commit --- scripts/generate-md-exports.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/generate-md-exports.mjs b/scripts/generate-md-exports.mjs index 08dc015e9827c..3a4ed4c3a0b28 100644 --- a/scripts/generate-md-exports.mjs +++ b/scripts/generate-md-exports.mjs @@ -1119,7 +1119,8 @@ async function processTaskList({id, tasks, cacheDir, noCache, usedCacheFiles}) { ); } const cacheHits = success - cacheMisses.length; - const missRate = success > 0 ? ((cacheMisses.length / success) * 100).toFixed(1) : '0.0'; + const missRate = + success > 0 ? ((cacheMisses.length / success) * 100).toFixed(1) : '0.0'; console.log( `📈 Worker[${id}]: Cache stats: ${cacheHits} hits, ${cacheMisses.length} misses (${missRate}% miss rate)` );