Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions app/md-exports/[...path]/route.test.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
});
136 changes: 136 additions & 0 deletions app/md-exports/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -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<DocTreeNode> {
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',
},
});
}
2 changes: 2 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
}
: {
Expand All @@ -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'],
};

Expand Down
Loading