/**
* Metadata Routes — Runtime Handlers
*
* Pure renderers + request dispatchers for the four file-convention
* metadata routes (`sitemap.ts`, `robots.ts`, `llms.txt.ts`,
* `manifest.ts`). Each `render*` function takes the validated, typed
* value and produces the serialized body. The `handleMetadataRoute`
* dispatcher wires an imported user module to a `Response`, including
* validation, caching headers, and typed error responses.
*
* Design notes
* ────────────
* • Renderers are pure and synchronous so tests can hit them directly
* without mocking Request/Response.
* • Validation runs AFTER the user function resolves but BEFORE we
* attempt to render — this lets us surface the exact Zod error
* (with the failing path) in the 500 response instead of crashing
* inside `renderSitemap` / `renderManifest` when a required field
* is missing.
* • Cache headers are `public, max-age=3600` by default. Callers can
* opt in to custom values via the `cache` option, and opting out
* entirely is supported by passing `cache: false`.
*
* @module routes/metadata-routes
*/
import {
SitemapSchema,
RobotsSchema,
WebAppManifestSchema,
METADATA_ROUTES,
type MetadataRouteKind,
type Sitemap,
type SitemapEntry,
type Robots,
type RobotsRule,
type WebAppManifest,
} from "./types";
// ═══════════════════════════════════════════════════════════════════════════
// XML / text escape helpers
// ═══════════════════════════════════════════════════════════════════════════
/**
* Escape characters that are illegal in XML text / attribute content.
* We deliberately avoid bringing in a dependency here — the five
* predefined entities cover every case we emit.
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Normalize a Date / date-like string into an ISO-8601 string. We
* accept strings verbatim to let users pass pre-formatted values
* (e.g. a DB-returned timestamp) without re-parsing.
*/
function formatDate(value: string | Date): string {
return value instanceof Date ? value.toISOString() : value;
}
// ═══════════════════════════════════════════════════════════════════════════
// Sitemap rendering
// ═══════════════════════════════════════════════════════════════════════════
function renderSitemapEntry(entry: SitemapEntry): string {
const lines: string[] = [" "];
lines.push(` ${escapeXml(entry.url)}`);
if (entry.lastModified !== undefined) {
lines.push(` ${escapeXml(formatDate(entry.lastModified))}`);
}
if (entry.changeFrequency) {
lines.push(` ${entry.changeFrequency}`);
}
if (entry.priority !== undefined) {
lines.push(` ${entry.priority.toFixed(1)}`);
}
if (entry.images?.length) {
for (const image of entry.images) {
lines.push(" ");
lines.push(` ${escapeXml(image)}`);
lines.push(" ");
}
}
if (entry.alternates?.languages) {
for (const [lang, url] of Object.entries(entry.alternates.languages)) {
lines.push(
` `
);
}
}
lines.push(" ");
return lines.join("\n");
}
/**
* Render a sitemap entry array to XML 1.0. The `xmlns:image` and
* `xmlns:xhtml` namespaces are added on demand so a plain sitemap
* stays as compact as possible.
*/
export function renderSitemap(entries: Sitemap): string {
const hasImages = entries.some((e) => e.images && e.images.length > 0);
const hasAlternates = entries.some((e) => e.alternates?.languages);
const namespaces = ['xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'];
if (hasImages) {
namespaces.push('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"');
}
if (hasAlternates) {
namespaces.push('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
}
const lines = [
'',
``,
...entries.map(renderSitemapEntry),
"",
];
return lines.join("\n");
}
// ═══════════════════════════════════════════════════════════════════════════
// Robots rendering
// ═══════════════════════════════════════════════════════════════════════════
function toArray(value: T | T[] | undefined): T[] {
if (value === undefined) return [];
return Array.isArray(value) ? value : [value];
}
function renderRobotsRule(rule: RobotsRule): string {
const lines: string[] = [];
const userAgents = toArray(rule.userAgent);
for (const ua of userAgents) {
lines.push(`User-agent: ${ua}`);
}
for (const path of toArray(rule.allow)) {
lines.push(`Allow: ${path}`);
}
for (const path of toArray(rule.disallow)) {
lines.push(`Disallow: ${path}`);
}
if (rule.crawlDelay !== undefined) {
lines.push(`Crawl-delay: ${rule.crawlDelay}`);
}
return lines.join("\n");
}
/**
* Render a `Robots` object to a robots.txt text body. Rule groups are
* separated by blank lines; `sitemap:` and `host:` directives go at
* the bottom per convention.
*/
export function renderRobots(robots: Robots): string {
const sections: string[] = [];
const rules = toArray(robots.rules);
for (const rule of rules) {
sections.push(renderRobotsRule(rule));
}
if (robots.host) {
sections.push(`Host: ${robots.host}`);
}
for (const sitemap of toArray(robots.sitemap)) {
sections.push(`Sitemap: ${sitemap}`);
}
return sections.join("\n\n");
}
// ═══════════════════════════════════════════════════════════════════════════
// Web App Manifest rendering
// ═══════════════════════════════════════════════════════════════════════════
/**
* Serialize a `WebAppManifest` to JSON. Formatting is deterministic
* (2-space indent) so CDN caches don't generate spurious diffs when
* the underlying object is logically unchanged.
*/
export function renderManifest(manifest: WebAppManifest): string {
return JSON.stringify(manifest, null, 2);
}
// ═══════════════════════════════════════════════════════════════════════════
// llms.txt passthrough
// ═══════════════════════════════════════════════════════════════════════════
/**
* Identity-ish passthrough for llms.txt content. Exposed as a function
* so the dispatcher can treat every route type uniformly — and so
* future formats (e.g. stripping BOM, normalizing line endings) can be
* added here without touching call sites.
*/
export function renderLlmsTxt(content: string): string {
if (typeof content !== "string") {
throw new TypeError(
`[@mandujs/core/routes] llms.txt default export must return a string, got ${typeof content}`
);
}
return content;
}
// ═══════════════════════════════════════════════════════════════════════════
// Dispatcher
// ═══════════════════════════════════════════════════════════════════════════
/**
* Options passed to {@link handleMetadataRoute}. `sourceFile` is used
* purely for error messages — it appears in the 500 body when the user
* export throws or returns an invalid shape, which dramatically
* shortens the edit-test loop.
*/
export interface MetadataRouteHandlerOptions {
/** Which of the four metadata routes we're serving. */
kind: MetadataRouteKind;
/** The imported user module's default export (not yet invoked). */
userExport: unknown;
/** The source file path, surfaced in error messages. */
sourceFile?: string;
/**
* Cache-Control header. `true` (default) emits
* `public, max-age=3600`. `false` omits the header. A string is
* passed through unchanged.
*/
cache?: boolean | string;
}
/**
* Typed error thrown when metadata route validation fails. The message
* includes the source file + Zod path so the developer can jump
* directly to the offending line.
*/
export class MetadataRouteValidationError extends Error {
readonly kind: MetadataRouteKind;
readonly issues: { path: string; message: string }[];
readonly sourceFile?: string;
constructor(
kind: MetadataRouteKind,
issues: { path: string; message: string }[],
sourceFile?: string
) {
const header = sourceFile
? `[@mandujs/core/routes] Invalid ${kind} value in ${sourceFile}`
: `[@mandujs/core/routes] Invalid ${kind} value`;
const body = issues.map((i) => ` • ${i.path || "(root)"}: ${i.message}`).join("\n");
super(`${header}\n${body}`);
this.name = "MetadataRouteValidationError";
this.kind = kind;
this.issues = issues;
this.sourceFile = sourceFile;
}
}
function defaultCacheControl(cache: boolean | string | undefined): string | null {
if (cache === false) return null;
if (typeof cache === "string") return cache;
return "public, max-age=3600";
}
/**
* Look up the Content-Type + URL pattern for a metadata route kind.
* Exposed so fs-scanner / manifest builders can reuse the same table
* without reaching into METADATA_ROUTES directly.
*/
export function getMetadataRouteMeta(kind: MetadataRouteKind) {
return METADATA_ROUTES[kind];
}
/**
* Dispatch a metadata route request.
*
* Pipeline:
* 1. Extract the default-export function from the imported module.
* 2. Invoke it (await its result).
* 3. Zod-validate the returned shape.
* 4. Render the correct body format.
* 5. Wrap in a Response with the right Content-Type + cache headers.
*
* Any failure in steps 1-4 yields a typed 500 Response whose body
* includes the source file and Zod issue path.
*/
export async function handleMetadataRoute(
options: MetadataRouteHandlerOptions
): Promise {
const { kind, userExport, sourceFile } = options;
const { contentType } = METADATA_ROUTES[kind];
const cacheControl = defaultCacheControl(options.cache);
const fn = extractDefaultFn(userExport);
if (!fn) {
return errorResponse(
kind,
sourceFile,
"Module default export must be a function. " +
`Expected \`export default function ${friendlyName(kind)}() { ... }\`.`
);
}
let result: unknown;
try {
result = await fn();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return errorResponse(kind, sourceFile, `User function threw: ${message}`);
}
try {
const body = renderValidated(kind, result, sourceFile);
const headers = new Headers({ "Content-Type": contentType });
if (cacheControl) headers.set("Cache-Control", cacheControl);
return new Response(body, { status: 200, headers });
} catch (err) {
if (err instanceof MetadataRouteValidationError) {
return errorResponse(kind, sourceFile, err.message);
}
const message = err instanceof Error ? err.message : String(err);
return errorResponse(kind, sourceFile, `Render failed: ${message}`);
}
}
/**
* Validate + render a single metadata route value. Shared between the
* production dispatcher and tests (where we often want to exercise a
* specific branch without spinning up a Request).
*/
export function renderValidated(
kind: MetadataRouteKind,
value: unknown,
sourceFile?: string
): string {
switch (kind) {
case "sitemap": {
const parsed = SitemapSchema.safeParse(value);
if (!parsed.success) throw zodToValidationError(kind, parsed.error, sourceFile);
return renderSitemap(parsed.data);
}
case "robots": {
const parsed = RobotsSchema.safeParse(value);
if (!parsed.success) throw zodToValidationError(kind, parsed.error, sourceFile);
return renderRobots(parsed.data);
}
case "llms-txt": {
if (typeof value !== "string") {
throw new MetadataRouteValidationError(
kind,
[{ path: "(root)", message: `expected string, got ${typeof value}` }],
sourceFile
);
}
return renderLlmsTxt(value);
}
case "manifest": {
const parsed = WebAppManifestSchema.safeParse(value);
if (!parsed.success) throw zodToValidationError(kind, parsed.error, sourceFile);
return renderManifest(parsed.data as WebAppManifest);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
/**
* Accept both a raw function export and a `{ default: fn }` namespace
* object (the shape returned by `await import(...)`). Returns the
* callable or null if neither is available.
*/
function extractDefaultFn(userExport: unknown): ((...args: unknown[]) => unknown) | null {
if (typeof userExport === "function") {
return userExport as (...args: unknown[]) => unknown;
}
if (userExport && typeof userExport === "object") {
const maybeDefault = (userExport as { default?: unknown }).default;
if (typeof maybeDefault === "function") {
return maybeDefault as (...args: unknown[]) => unknown;
}
}
return null;
}
function friendlyName(kind: MetadataRouteKind): string {
switch (kind) {
case "sitemap":
return "sitemap";
case "robots":
return "robots";
case "llms-txt":
return "llmsTxt";
case "manifest":
return "manifest";
}
}
function zodToValidationError(
kind: MetadataRouteKind,
error: { issues: { path: (string | number)[]; message: string }[] },
sourceFile?: string
): MetadataRouteValidationError {
const issues = error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
}));
return new MetadataRouteValidationError(kind, issues, sourceFile);
}
/**
* Build a 500 Response with a plain-text body describing the problem.
* Mirrors how Next.js surfaces metadata errors — the text is read
* directly from the browser "View Source", no JSON framing.
*/
function errorResponse(
kind: MetadataRouteKind,
sourceFile: string | undefined,
detail: string
): Response {
const location = sourceFile ? ` (${sourceFile})` : "";
const body = `# Mandu metadata route error: ${kind}${location}\n${detail}\n`;
return new Response(body, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}