diff --git a/app/md-exports/[...path]/route.test.ts b/app/md-exports/[...path]/route.test.ts new file mode 100644 index 0000000000000..23894ae7cae8c --- /dev/null +++ b/app/md-exports/[...path]/route.test.ts @@ -0,0 +1,132 @@ +import {readFile} from 'node:fs/promises'; + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +const SAMPLE_DOCTREE = { + path: '', + slug: '', + frontmatter: {title: 'Home'}, + children: [ + { + path: 'platforms', + slug: 'platforms', + frontmatter: {title: 'Platforms'}, + children: [ + { + path: 'platforms/javascript', + slug: 'javascript', + frontmatter: {title: 'Browser JavaScript'}, + children: [ + { + path: 'platforms/javascript/install', + slug: 'install', + frontmatter: {title: 'Installation Methods'}, + }, + { + path: 'platforms/javascript/usage', + slug: 'usage', + frontmatter: {title: 'Capturing Errors'}, + }, + { + path: 'platforms/javascript/tracing', + slug: 'tracing', + frontmatter: {title: 'Set Up Tracing'}, + }, + ], + }, + { + path: 'platforms/python', + slug: 'python', + frontmatter: {title: 'Python'}, + children: [], + }, + ], + }, + ], +}; + +describe('md-exports 404 catch-all route', () => { + beforeEach(() => { + vi.resetModules(); + vi.mocked(readFile).mockResolvedValue(JSON.stringify(SAMPLE_DOCTREE)); + }); + + async function callRoute(pathSegments: string[]) { + const {GET} = await import('./route'); + const request = new Request('https://docs.sentry.io/test'); + return GET(request, {params: Promise.resolve({path: pathSegments})}); + } + + it('returns 404 status', async () => { + const res = await callRoute(['platforms', 'javascript', 'ai-monitoring.md']); + expect(res.status).toBe(404); + }); + + it('returns text/markdown content type', async () => { + const res = await callRoute(['platforms', 'javascript', 'ai-monitoring.md']); + expect(res.headers.get('Content-Type')).toBe('text/markdown; charset=utf-8'); + }); + + it('includes the requested path in the body', async () => { + const res = await callRoute(['platforms', 'javascript', 'ai-monitoring.md']); + const body = await res.text(); + expect(body).toContain('/platforms/javascript/ai-monitoring'); + }); + + it('includes sibling pages from the parent section', async () => { + const res = await callRoute(['platforms', 'javascript', 'ai-monitoring.md']); + const body = await res.text(); + expect(body).toContain('Installation Methods'); + expect(body).toContain('Capturing Errors'); + expect(body).toContain('Set Up Tracing'); + expect(body).toContain('Pages in Browser JavaScript'); + }); + + it('includes navigation links to llms.txt and index.md', async () => { + const res = await callRoute(['platforms', 'javascript', 'ai-monitoring.md']); + const body = await res.text(); + expect(body).toContain('llms.txt'); + expect(body).toContain('index.md'); + expect(body).toContain('platforms.md'); + }); + + it('walks up the tree when the immediate parent does not exist', async () => { + const res = await callRoute([ + 'platforms', + 'javascript', + 'nonexistent', + 'deep', + 'path.md', + ]); + const body = await res.text(); + expect(body).toContain('Pages in Browser JavaScript'); + }); + + it('falls back to top-level sections when nothing matches', async () => { + const res = await callRoute(['completely', 'unknown', 'path.md']); + const body = await res.text(); + expect(body).not.toContain('Pages in'); + expect(body).toContain('Find what you need'); + }); + + it('handles doctree load failure gracefully', async () => { + vi.mocked(readFile).mockRejectedValue(new Error('file not found')); + const res = await callRoute(['platforms', 'javascript', 'foo.md']); + const body = await res.text(); + expect(res.status).toBe(404); + expect(body).toContain('Page Not Found'); + expect(body).toContain('llms.txt'); + }); + + it('includes YAML frontmatter', async () => { + const res = await callRoute(['platforms', 'javascript', 'foo.md']); + const body = await res.text(); + expect(body).toMatch(/^---\n/); + expect(body).toContain('title: "Page Not Found"'); + expect(body).toContain('url: "https://docs.sentry.io/platforms/javascript/foo"'); + }); +}); diff --git a/app/md-exports/[...path]/route.ts b/app/md-exports/[...path]/route.ts new file mode 100644 index 0000000000000..c8e098b875151 --- /dev/null +++ b/app/md-exports/[...path]/route.ts @@ -0,0 +1,136 @@ +import {readFile} from 'node:fs/promises'; +import {join} from 'node:path'; + +import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; + +interface DocTreeNode { + path: string; + slug: string; + frontmatter: {title?: string; description?: string}; + children?: DocTreeNode[]; +} + +const BASE_URL = isDeveloperDocs + ? 'https://develop.sentry.dev' + : 'https://docs.sentry.io'; + +let cachedDocTree: DocTreeNode | null = null; + +async function getDocTree(): Promise { + if (cachedDocTree) { + return cachedDocTree; + } + const filename = isDeveloperDocs ? 'doctree-dev.json' : 'doctree.json'; + const treePath = join(process.cwd(), 'public', filename); + const raw = await readFile(treePath, 'utf-8'); + cachedDocTree = JSON.parse(raw) as DocTreeNode; + return cachedDocTree; +} + +function findNode(node: DocTreeNode, targetPath: string): DocTreeNode | null { + if (node.path === targetPath) { + return node; + } + for (const child of node.children ?? []) { + const found = findNode(child, targetPath); + if (found) { + return found; + } + } + return null; +} + +function findClosestAncestor( + tree: DocTreeNode, + pathSegments: string[] +): DocTreeNode | null { + for (let i = pathSegments.length; i > 0; i--) { + const candidatePath = pathSegments.slice(0, i).join('/'); + const node = findNode(tree, candidatePath); + if (node) { + return node; + } + } + return null; +} + +function renderSiblingList(siblings: DocTreeNode[], baseUrl: string): string { + return siblings + .slice(0, 15) + .map(s => { + const title = s.frontmatter?.title || s.slug; + return `- [${title}](${baseUrl}/${s.path}.md)`; + }) + .join('\n'); +} + +export async function GET( + _request: Request, + {params}: {params: Promise<{path: string[]}>} +) { + const {path: pathSegments} = await params; + const requestedPath = pathSegments.join('/').replace(/\.md$/, ''); + + let body: string; + + try { + const tree = await getDocTree(); + const ancestor = findClosestAncestor(tree, requestedPath.split('/')); + + const lines: string[] = [ + '---', + `title: "Page Not Found"`, + `url: "${BASE_URL}/${requestedPath}"`, + '---', + '', + '# Page Not Found', + '', + `The page \`/${requestedPath}\` does not exist.`, + '', + ]; + + if (ancestor && ancestor.children?.length) { + const ancestorTitle = + ancestor.frontmatter?.title || ancestor.slug || 'this section'; + lines.push(`## Pages in ${ancestorTitle}`); + lines.push(''); + lines.push(renderSiblingList(ancestor.children, BASE_URL)); + lines.push(''); + } + + lines.push('## Find what you need'); + lines.push(''); + lines.push(`- [Site index](${BASE_URL}/llms.txt) — LLM-optimized page listing`); + lines.push(`- [Documentation root](${BASE_URL}/index.md) — full docs overview`); + lines.push(`- [Platforms](${BASE_URL}/platforms.md) — all SDK platforms`); + lines.push(''); + + body = lines.join('\n'); + } catch { + body = [ + '---', + `title: "Page Not Found"`, + `url: "${BASE_URL}/${requestedPath}"`, + '---', + '', + '# Page Not Found', + '', + `The page \`/${requestedPath}\` does not exist.`, + '', + '## Find what you need', + '', + `- [Site index](${BASE_URL}/llms.txt) — LLM-optimized page listing`, + `- [Documentation root](${BASE_URL}/index.md) — full docs overview`, + `- [Platforms](${BASE_URL}/platforms.md) — all SDK platforms`, + '', + ].join('\n'); + } + + return new Response(body, { + status: 404, + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=300', + }, + }); +} diff --git a/next.config.ts b/next.config.ts index 8e2c428a61ed6..4662d206569fe 100644 --- a/next.config.ts +++ b/next.config.ts @@ -75,6 +75,7 @@ const outputFileTracingIncludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS ? { '/platform-redirect': ['public/doctree-dev.json'], '\\[\\[\\.\\.\\.path\\]\\]': ['public/doctree-dev.json'], + '/md-exports/\\[\\.\\.\\.path\\]': ['public/doctree-dev.json'], 'sitemap.xml': ['public/doctree-dev.json'], } : { @@ -84,6 +85,7 @@ const outputFileTracingIncludes = process.env.NEXT_PUBLIC_DEVELOPER_DOCS 'docs/changelog.mdx', 'docs/platforms/index.mdx', ], + '/md-exports/\\[\\.\\.\\.path\\]': ['public/doctree.json'], 'sitemap.xml': ['public/doctree.json'], };