Skip to content

Commit e0d8899

Browse files
committed
Add sitemap.md route for LLM-friendly documentation indexing
1 parent 0362361 commit e0d8899

File tree

1 file changed

+198
-0
lines changed

1 file changed

+198
-0
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import type { NextRequest } from "next/server";
2+
import { source } from "@/lib/geistdocs/source";
3+
4+
export const revalidate = false;
5+
6+
const DOCS_PREFIX_PATTERN = /^\/docs\/?/;
7+
const WHITESPACE_PATTERN = /\s+/;
8+
9+
type PageNode = {
10+
title: string;
11+
description: string;
12+
url: string;
13+
type?: string;
14+
summary?: string;
15+
prerequisites?: string[];
16+
product?: string;
17+
lastmod?: string;
18+
children: PageNode[];
19+
};
20+
21+
function buildTree(
22+
pages: Array<{
23+
url: string;
24+
data: {
25+
title: string;
26+
description?: string;
27+
type?: string;
28+
summary?: string;
29+
prerequisites?: string[];
30+
product?: string;
31+
lastModified?: Date;
32+
};
33+
}>
34+
): PageNode[] {
35+
const root: PageNode[] = [];
36+
const map = new Map<string, PageNode>();
37+
38+
const sorted = [...pages].sort((a, b) => a.url.localeCompare(b.url));
39+
40+
for (const page of sorted) {
41+
const node: PageNode = {
42+
title: page.data.title,
43+
description: page.data.description ?? "",
44+
url: page.url,
45+
type: page.data.type,
46+
summary: page.data.summary,
47+
prerequisites: page.data.prerequisites,
48+
product: page.data.product,
49+
lastmod: page.data.lastModified
50+
? page.data.lastModified.toISOString().split("T")[0]
51+
: undefined,
52+
children: [],
53+
};
54+
map.set(page.url, node);
55+
56+
const segments = page.url.split("/").filter(Boolean);
57+
if (segments.length <= 1) {
58+
root.push(node);
59+
} else {
60+
const parentUrl = `/${segments.slice(0, -1).join("/")}`;
61+
const parent = map.get(parentUrl);
62+
if (parent) {
63+
parent.children.push(node);
64+
} else {
65+
root.push(node);
66+
}
67+
}
68+
}
69+
70+
return root;
71+
}
72+
73+
function inferDocType(url: string, explicitType?: string): string {
74+
if (explicitType) {
75+
return explicitType.charAt(0).toUpperCase() + explicitType.slice(1);
76+
}
77+
if (url.includes("/getting-started")) {
78+
return "Guide";
79+
}
80+
if (url.includes("/reference")) {
81+
return "Reference";
82+
}
83+
if (url.includes("/guides/")) {
84+
return "Guide";
85+
}
86+
return "Conceptual";
87+
}
88+
89+
function extractTopics(url: string, product?: string): string[] {
90+
const topics: string[] = [];
91+
if (product) {
92+
topics.push(product);
93+
}
94+
95+
const segments = url
96+
.replace(DOCS_PREFIX_PATTERN, "")
97+
.split("/")
98+
.filter(Boolean);
99+
100+
for (const segment of segments) {
101+
if (!topics.includes(segment)) {
102+
topics.push(segment);
103+
}
104+
if (topics.length >= 3) {
105+
break;
106+
}
107+
}
108+
109+
return topics.slice(0, 3);
110+
}
111+
112+
function truncateToWords(text: string, maxWords: number): string {
113+
const words = text.split(WHITESPACE_PATTERN);
114+
if (words.length <= maxWords) {
115+
return text;
116+
}
117+
return `${words.slice(0, maxWords).join(" ")}...`;
118+
}
119+
120+
function renderNode(
121+
node: PageNode,
122+
indent: number,
123+
parentTitle?: string
124+
): string {
125+
const prefix = " ".repeat(indent);
126+
const lines: string[] = [];
127+
128+
const segments: string[] = [];
129+
segments.push(`Type: ${inferDocType(node.url, node.type)}`);
130+
131+
if (node.lastmod) {
132+
segments.push(`Lastmod: ${node.lastmod}`);
133+
}
134+
135+
const summary = node.summary || node.description;
136+
if (summary) {
137+
segments.push(`Summary: ${truncateToWords(summary, 100)}`);
138+
}
139+
140+
const prereqs =
141+
node.prerequisites && node.prerequisites.length > 0
142+
? node.prerequisites.join(", ")
143+
: parentTitle;
144+
if (prereqs) {
145+
segments.push(`Prerequisites: ${prereqs}`);
146+
}
147+
148+
const topics = extractTopics(node.url, node.product);
149+
if (topics.length > 0) {
150+
segments.push(`Topics: ${topics.join(", ")}`);
151+
}
152+
153+
lines.push(
154+
`${prefix}- [${node.title}](${node.url}) | ${segments.join(" | ")}`
155+
);
156+
157+
for (const child of node.children) {
158+
lines.push("");
159+
lines.push(renderNode(child, indent + 1, node.title));
160+
}
161+
162+
return lines.join("\n");
163+
}
164+
165+
export const GET = async (
166+
_req: NextRequest,
167+
{ params }: RouteContext<"/[lang]/sitemap.md">
168+
) => {
169+
const { lang } = await params;
170+
const pages = source.getPages(lang);
171+
172+
const tree = buildTree(pages);
173+
174+
const header = `# Documentation Sitemap
175+
176+
## Purpose
177+
178+
This file is a high-level semantic index of the documentation.
179+
It is intended for:
180+
181+
- LLM-assisted navigation (ChatGPT, Claude, etc.)
182+
- Quick orientation for contributors
183+
- Identifying relevant documentation areas during development
184+
185+
It is not intended to replace individual docs.
186+
187+
---
188+
189+
`;
190+
191+
const body = tree.map((node) => renderNode(node, 0)).join("\n\n");
192+
193+
return new Response(header + body, {
194+
headers: {
195+
"Content-Type": "text/markdown",
196+
},
197+
});
198+
};

0 commit comments

Comments
 (0)