From e8f96ca6c0480a137752b26af75097590c09c283 Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Fri, 27 Mar 2026 12:31:21 -0400 Subject: [PATCH 1/2] feat(site): add sitemap --- apps/site/src/app/sitemap-site.xml/route.ts | 11 ++ apps/site/src/app/sitemap.ts | 16 --- apps/site/src/app/sitemap.xml/route.ts | 11 ++ apps/site/src/lib/sitemap.ts | 138 ++++++++++++++++++++ 4 files changed, 160 insertions(+), 16 deletions(-) create mode 100644 apps/site/src/app/sitemap-site.xml/route.ts delete mode 100644 apps/site/src/app/sitemap.ts create mode 100644 apps/site/src/app/sitemap.xml/route.ts create mode 100644 apps/site/src/lib/sitemap.ts diff --git a/apps/site/src/app/sitemap-site.xml/route.ts b/apps/site/src/app/sitemap-site.xml/route.ts new file mode 100644 index 0000000000..2896840dcd --- /dev/null +++ b/apps/site/src/app/sitemap-site.xml/route.ts @@ -0,0 +1,11 @@ +import { getSiteSitemapEntries, renderSitemapXml } from "@/lib/sitemap"; + +export async function GET(): Promise { + const xml = renderSitemapXml(await getSiteSitemapEntries()); + + return new Response(xml, { + headers: { + "Content-Type": "application/xml; charset=utf-8", + }, + }); +} diff --git a/apps/site/src/app/sitemap.ts b/apps/site/src/app/sitemap.ts deleted file mode 100644 index 9e93526e38..0000000000 --- a/apps/site/src/app/sitemap.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { MetadataRoute } from "next"; -import { getBaseUrl } from "@/lib/url"; - -export const revalidate = false; - -export default function sitemap(): MetadataRoute.Sitemap { - const baseUrl = getBaseUrl(); - - return [ - { - url: new URL("/", baseUrl).toString(), - changeFrequency: "daily", - priority: 1, - }, - ]; -} diff --git a/apps/site/src/app/sitemap.xml/route.ts b/apps/site/src/app/sitemap.xml/route.ts new file mode 100644 index 0000000000..5c8a9aa27a --- /dev/null +++ b/apps/site/src/app/sitemap.xml/route.ts @@ -0,0 +1,11 @@ +import { getHostSitemapUrls, renderSitemapIndexXml } from "@/lib/sitemap"; + +export function GET(): Response { + const xml = renderSitemapIndexXml(getHostSitemapUrls()); + + return new Response(xml, { + headers: { + "Content-Type": "application/xml; charset=utf-8", + }, + }); +} diff --git a/apps/site/src/lib/sitemap.ts b/apps/site/src/lib/sitemap.ts new file mode 100644 index 0000000000..7dbff4e74a --- /dev/null +++ b/apps/site/src/lib/sitemap.ts @@ -0,0 +1,138 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; +import { getBaseUrl } from "@/lib/url"; + +type SitemapEntry = { + url: string; + changeFrequency?: "daily" | "weekly" | "monthly"; + priority?: number; +}; + +const HOST_SITEMAPS = ["/sitemap-site.xml", "/docs/sitemap.xml", "/blog/sitemap.xml"]; +const APP_DIRECTORY = path.join(process.cwd(), "src/app"); + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export function getHostSitemapUrls(baseUrl = getBaseUrl()): string[] { + return HOST_SITEMAPS.map((pathname) => new URL(pathname, baseUrl).toString()); +} + +function toRouteSegment(segment: string): string | null { + if (segment.startsWith("(") && segment.endsWith(")")) { + return null; + } + + if (segment.startsWith("_") || (segment.startsWith("[") && segment.endsWith("]"))) { + return null; + } + + return segment; +} + +function getEntryMetadata(pathname: string): Omit { + if (pathname === "/") { + return { + changeFrequency: "daily", + priority: 1, + }; + } + + return { + changeFrequency: "weekly", + priority: 0.8, + }; +} + +async function collectPageRoutes(directory: string, segments: string[] = []): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const routes = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return collectPageRoutes(entryPath, [...segments, entry.name]); + } + + if (!entry.isFile() || entry.name !== "page.tsx") { + return []; + } + + const routeSegments = segments + .map(toRouteSegment) + .filter((segment): segment is string => Boolean(segment)); + + const hasUnsupportedSegment = segments.some( + (segment) => + segment.startsWith("_") || + (segment.startsWith("[") && segment.endsWith("]")), + ); + + if (hasUnsupportedSegment) { + return []; + } + + return [routeSegments.length === 0 ? "/" : `/${routeSegments.join("/")}`]; + }), + ); + + return routes.flat(); +} + +export async function getSiteSitemapEntries(baseUrl = getBaseUrl()): Promise { + const pathnames = await collectPageRoutes(APP_DIRECTORY); + + return pathnames + .sort((left, right) => left.localeCompare(right)) + .map((pathname) => ({ + url: new URL(pathname, baseUrl).toString(), + ...getEntryMetadata(pathname), + })); +} + +export function renderSitemapIndexXml(urls: string[]): string { + const items = urls + .map( + (url) => ` + ${escapeXml(url)} + `, + ) + .join("\n"); + + return ` + +${items} +`; +} + +export function renderSitemapXml(entries: SitemapEntry[]): string { + const items = entries + .map(({ url, changeFrequency, priority }) => { + const metadata = [ + changeFrequency + ? ` ${escapeXml(changeFrequency)}` + : null, + typeof priority === "number" + ? ` ${priority.toFixed(1)}` + : null, + ] + .filter(Boolean) + .join("\n"); + + return ` + ${escapeXml(url)}${metadata ? `\n${metadata}` : ""} + `; + }) + .join("\n"); + + return ` + +${items} +`; +} From d4fe2b5261c311e87f27ae6fd0803b15ed40139e Mon Sep 17 00:00:00 2001 From: Mike Hartington Date: Fri, 27 Mar 2026 15:26:03 -0400 Subject: [PATCH 2/2] fix(site): harden sitemap generation Cache the sitemap routes statically and make route discovery skip unsupported segments and filesystem read failures. Made-with: Cursor --- apps/site/src/app/sitemap-site.xml/route.ts | 3 ++ apps/site/src/app/sitemap.xml/route.ts | 3 ++ apps/site/src/lib/sitemap.ts | 50 ++++++++++++++++++--- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/apps/site/src/app/sitemap-site.xml/route.ts b/apps/site/src/app/sitemap-site.xml/route.ts index 2896840dcd..90dac910f2 100644 --- a/apps/site/src/app/sitemap-site.xml/route.ts +++ b/apps/site/src/app/sitemap-site.xml/route.ts @@ -1,5 +1,8 @@ import { getSiteSitemapEntries, renderSitemapXml } from "@/lib/sitemap"; +export const dynamic = "force-static"; + +/** Render the app sitemap as static XML. */ export async function GET(): Promise { const xml = renderSitemapXml(await getSiteSitemapEntries()); diff --git a/apps/site/src/app/sitemap.xml/route.ts b/apps/site/src/app/sitemap.xml/route.ts index 5c8a9aa27a..9388da2171 100644 --- a/apps/site/src/app/sitemap.xml/route.ts +++ b/apps/site/src/app/sitemap.xml/route.ts @@ -1,5 +1,8 @@ import { getHostSitemapUrls, renderSitemapIndexXml } from "@/lib/sitemap"; +export const dynamic = "force-static"; + +/** Render the sitemap index as static XML. */ export function GET(): Response { const xml = renderSitemapIndexXml(getHostSitemapUrls()); diff --git a/apps/site/src/lib/sitemap.ts b/apps/site/src/lib/sitemap.ts index 7dbff4e74a..0592d1131f 100644 --- a/apps/site/src/lib/sitemap.ts +++ b/apps/site/src/lib/sitemap.ts @@ -1,3 +1,4 @@ +import type { Dirent } from "node:fs"; import { readdir } from "node:fs/promises"; import path from "node:path"; import { getBaseUrl } from "@/lib/url"; @@ -11,6 +12,7 @@ type SitemapEntry = { const HOST_SITEMAPS = ["/sitemap-site.xml", "/docs/sitemap.xml", "/blog/sitemap.xml"]; const APP_DIRECTORY = path.join(process.cwd(), "src/app"); +/** Escape XML-sensitive characters before writing values into sitemap markup. */ function escapeXml(value: string): string { return value .replaceAll("&", "&") @@ -20,22 +22,46 @@ function escapeXml(value: string): string { .replaceAll("'", "'"); } +/** Build absolute URLs for the top-level sitemap index. */ export function getHostSitemapUrls(baseUrl = getBaseUrl()): string[] { return HOST_SITEMAPS.map((pathname) => new URL(pathname, baseUrl).toString()); } -function toRouteSegment(segment: string): string | null { +type SegmentDisposition = "include" | "omit" | "exclude"; + +const INTERCEPTING_ROUTE_PREFIXES = ["(.)", "(..)", "(...)", "(..)(..)"] as const; + +/** Classify app segments for sitemap generation. */ +function getSegmentDisposition(segment: string): SegmentDisposition { + if (segment.startsWith("_") || segment.startsWith("@")) { + return "exclude"; + } + + if (segment.startsWith("[") && segment.endsWith("]")) { + return "exclude"; + } + + if (INTERCEPTING_ROUTE_PREFIXES.some((prefix) => segment.startsWith(prefix))) { + return "exclude"; + } + if (segment.startsWith("(") && segment.endsWith(")")) { - return null; + return "omit"; } - if (segment.startsWith("_") || (segment.startsWith("[") && segment.endsWith("]"))) { + return "include"; +} + +/** Convert an app directory segment into its public URL segment. */ +function toRouteSegment(segment: string): string | null { + if (getSegmentDisposition(segment) !== "include") { return null; } return segment; } +/** Assign default sitemap metadata for a public pathname. */ function getEntryMetadata(pathname: string): Omit { if (pathname === "/") { return { @@ -50,8 +76,17 @@ function getEntryMetadata(pathname: string): Omit { }; } +/** Recursively collect public page routes from the App Router tree. */ async function collectPageRoutes(directory: string, segments: string[] = []): Promise { - const entries = await readdir(directory, { withFileTypes: true }); + let entries: Dirent[]; + + try { + entries = await readdir(directory, { encoding: "utf8", withFileTypes: true }); + } catch (error) { + console.error(`Failed to read sitemap routes from ${directory}`, error); + return []; + } + const routes = await Promise.all( entries.map(async (entry) => { const entryPath = path.join(directory, entry.name); @@ -69,9 +104,7 @@ async function collectPageRoutes(directory: string, segments: string[] = []): Pr .filter((segment): segment is string => Boolean(segment)); const hasUnsupportedSegment = segments.some( - (segment) => - segment.startsWith("_") || - (segment.startsWith("[") && segment.endsWith("]")), + (segment) => getSegmentDisposition(segment) === "exclude", ); if (hasUnsupportedSegment) { @@ -85,6 +118,7 @@ async function collectPageRoutes(directory: string, segments: string[] = []): Pr return routes.flat(); } +/** Generate sitemap entries for all public pages in the site app. */ export async function getSiteSitemapEntries(baseUrl = getBaseUrl()): Promise { const pathnames = await collectPageRoutes(APP_DIRECTORY); @@ -96,6 +130,7 @@ export async function getSiteSitemapEntries(baseUrl = getBaseUrl()): Promise`; } +/** Render a URL sitemap document. */ export function renderSitemapXml(entries: SitemapEntry[]): string { const items = entries .map(({ url, changeFrequency, priority }) => {