Skip to content

Commit c5e9d28

Browse files
authored
feat(site): add sitemap (#7723)
* feat(site): add sitemap * fix(site): harden sitemap generation Cache the sitemap routes statically and make route discovery skip unsupported segments and filesystem read failures. Made-with: Cursor
1 parent 64ecba8 commit c5e9d28

4 files changed

Lines changed: 202 additions & 16 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getSiteSitemapEntries, renderSitemapXml } from "@/lib/sitemap";
2+
3+
export const dynamic = "force-static";
4+
5+
/** Render the app sitemap as static XML. */
6+
export async function GET(): Promise<Response> {
7+
const xml = renderSitemapXml(await getSiteSitemapEntries());
8+
9+
return new Response(xml, {
10+
headers: {
11+
"Content-Type": "application/xml; charset=utf-8",
12+
},
13+
});
14+
}

apps/site/src/app/sitemap.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { getHostSitemapUrls, renderSitemapIndexXml } from "@/lib/sitemap";
2+
3+
export const dynamic = "force-static";
4+
5+
/** Render the sitemap index as static XML. */
6+
export function GET(): Response {
7+
const xml = renderSitemapIndexXml(getHostSitemapUrls());
8+
9+
return new Response(xml, {
10+
headers: {
11+
"Content-Type": "application/xml; charset=utf-8",
12+
},
13+
});
14+
}

apps/site/src/lib/sitemap.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import type { Dirent } from "node:fs";
2+
import { readdir } from "node:fs/promises";
3+
import path from "node:path";
4+
import { getBaseUrl } from "@/lib/url";
5+
6+
type SitemapEntry = {
7+
url: string;
8+
changeFrequency?: "daily" | "weekly" | "monthly";
9+
priority?: number;
10+
};
11+
12+
const HOST_SITEMAPS = ["/sitemap-site.xml", "/docs/sitemap.xml", "/blog/sitemap.xml"];
13+
const APP_DIRECTORY = path.join(process.cwd(), "src/app");
14+
15+
/** Escape XML-sensitive characters before writing values into sitemap markup. */
16+
function escapeXml(value: string): string {
17+
return value
18+
.replaceAll("&", "&amp;")
19+
.replaceAll("<", "&lt;")
20+
.replaceAll(">", "&gt;")
21+
.replaceAll('"', "&quot;")
22+
.replaceAll("'", "&apos;");
23+
}
24+
25+
/** Build absolute URLs for the top-level sitemap index. */
26+
export function getHostSitemapUrls(baseUrl = getBaseUrl()): string[] {
27+
return HOST_SITEMAPS.map((pathname) => new URL(pathname, baseUrl).toString());
28+
}
29+
30+
type SegmentDisposition = "include" | "omit" | "exclude";
31+
32+
const INTERCEPTING_ROUTE_PREFIXES = ["(.)", "(..)", "(...)", "(..)(..)"] as const;
33+
34+
/** Classify app segments for sitemap generation. */
35+
function getSegmentDisposition(segment: string): SegmentDisposition {
36+
if (segment.startsWith("_") || segment.startsWith("@")) {
37+
return "exclude";
38+
}
39+
40+
if (segment.startsWith("[") && segment.endsWith("]")) {
41+
return "exclude";
42+
}
43+
44+
if (INTERCEPTING_ROUTE_PREFIXES.some((prefix) => segment.startsWith(prefix))) {
45+
return "exclude";
46+
}
47+
48+
if (segment.startsWith("(") && segment.endsWith(")")) {
49+
return "omit";
50+
}
51+
52+
return "include";
53+
}
54+
55+
/** Convert an app directory segment into its public URL segment. */
56+
function toRouteSegment(segment: string): string | null {
57+
if (getSegmentDisposition(segment) !== "include") {
58+
return null;
59+
}
60+
61+
return segment;
62+
}
63+
64+
/** Assign default sitemap metadata for a public pathname. */
65+
function getEntryMetadata(pathname: string): Omit<SitemapEntry, "url"> {
66+
if (pathname === "/") {
67+
return {
68+
changeFrequency: "daily",
69+
priority: 1,
70+
};
71+
}
72+
73+
return {
74+
changeFrequency: "weekly",
75+
priority: 0.8,
76+
};
77+
}
78+
79+
/** Recursively collect public page routes from the App Router tree. */
80+
async function collectPageRoutes(directory: string, segments: string[] = []): Promise<string[]> {
81+
let entries: Dirent<string>[];
82+
83+
try {
84+
entries = await readdir(directory, { encoding: "utf8", withFileTypes: true });
85+
} catch (error) {
86+
console.error(`Failed to read sitemap routes from ${directory}`, error);
87+
return [];
88+
}
89+
90+
const routes = await Promise.all(
91+
entries.map(async (entry) => {
92+
const entryPath = path.join(directory, entry.name);
93+
94+
if (entry.isDirectory()) {
95+
return collectPageRoutes(entryPath, [...segments, entry.name]);
96+
}
97+
98+
if (!entry.isFile() || entry.name !== "page.tsx") {
99+
return [];
100+
}
101+
102+
const routeSegments = segments
103+
.map(toRouteSegment)
104+
.filter((segment): segment is string => Boolean(segment));
105+
106+
const hasUnsupportedSegment = segments.some(
107+
(segment) => getSegmentDisposition(segment) === "exclude",
108+
);
109+
110+
if (hasUnsupportedSegment) {
111+
return [];
112+
}
113+
114+
return [routeSegments.length === 0 ? "/" : `/${routeSegments.join("/")}`];
115+
}),
116+
);
117+
118+
return routes.flat();
119+
}
120+
121+
/** Generate sitemap entries for all public pages in the site app. */
122+
export async function getSiteSitemapEntries(baseUrl = getBaseUrl()): Promise<SitemapEntry[]> {
123+
const pathnames = await collectPageRoutes(APP_DIRECTORY);
124+
125+
return pathnames
126+
.sort((left, right) => left.localeCompare(right))
127+
.map((pathname) => ({
128+
url: new URL(pathname, baseUrl).toString(),
129+
...getEntryMetadata(pathname),
130+
}));
131+
}
132+
133+
/** Render a sitemap index document. */
134+
export function renderSitemapIndexXml(urls: string[]): string {
135+
const items = urls
136+
.map(
137+
(url) => ` <sitemap>
138+
<loc>${escapeXml(url)}</loc>
139+
</sitemap>`,
140+
)
141+
.join("\n");
142+
143+
return `<?xml version="1.0" encoding="UTF-8"?>
144+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
145+
${items}
146+
</sitemapindex>`;
147+
}
148+
149+
/** Render a URL sitemap document. */
150+
export function renderSitemapXml(entries: SitemapEntry[]): string {
151+
const items = entries
152+
.map(({ url, changeFrequency, priority }) => {
153+
const metadata = [
154+
changeFrequency
155+
? ` <changefreq>${escapeXml(changeFrequency)}</changefreq>`
156+
: null,
157+
typeof priority === "number"
158+
? ` <priority>${priority.toFixed(1)}</priority>`
159+
: null,
160+
]
161+
.filter(Boolean)
162+
.join("\n");
163+
164+
return ` <url>
165+
<loc>${escapeXml(url)}</loc>${metadata ? `\n${metadata}` : ""}
166+
</url>`;
167+
})
168+
.join("\n");
169+
170+
return `<?xml version="1.0" encoding="UTF-8"?>
171+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
172+
${items}
173+
</urlset>`;
174+
}

0 commit comments

Comments
 (0)