@mandujs/core 0.25.2 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Metadata Routes — Runtime Handlers
3
+ *
4
+ * Pure renderers + request dispatchers for the four file-convention
5
+ * metadata routes (`sitemap.ts`, `robots.ts`, `llms.txt.ts`,
6
+ * `manifest.ts`). Each `render*` function takes the validated, typed
7
+ * value and produces the serialized body. The `handleMetadataRoute`
8
+ * dispatcher wires an imported user module to a `Response`, including
9
+ * validation, caching headers, and typed error responses.
10
+ *
11
+ * Design notes
12
+ * ────────────
13
+ * • Renderers are pure and synchronous so tests can hit them directly
14
+ * without mocking Request/Response.
15
+ * • Validation runs AFTER the user function resolves but BEFORE we
16
+ * attempt to render — this lets us surface the exact Zod error
17
+ * (with the failing path) in the 500 response instead of crashing
18
+ * inside `renderSitemap` / `renderManifest` when a required field
19
+ * is missing.
20
+ * • Cache headers are `public, max-age=3600` by default. Callers can
21
+ * opt in to custom values via the `cache` option, and opting out
22
+ * entirely is supported by passing `cache: false`.
23
+ *
24
+ * @module routes/metadata-routes
25
+ */
26
+ import {
27
+ SitemapSchema,
28
+ RobotsSchema,
29
+ WebAppManifestSchema,
30
+ METADATA_ROUTES,
31
+ type MetadataRouteKind,
32
+ type Sitemap,
33
+ type SitemapEntry,
34
+ type Robots,
35
+ type RobotsRule,
36
+ type WebAppManifest,
37
+ } from "./types";
38
+
39
+ // ═══════════════════════════════════════════════════════════════════════════
40
+ // XML / text escape helpers
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+
43
+ /**
44
+ * Escape characters that are illegal in XML text / attribute content.
45
+ * We deliberately avoid bringing in a dependency here — the five
46
+ * predefined entities cover every case we emit.
47
+ */
48
+ function escapeXml(str: string): string {
49
+ return str
50
+ .replace(/&/g, "&")
51
+ .replace(/</g, "&lt;")
52
+ .replace(/>/g, "&gt;")
53
+ .replace(/"/g, "&quot;")
54
+ .replace(/'/g, "&apos;");
55
+ }
56
+
57
+ /**
58
+ * Normalize a Date / date-like string into an ISO-8601 string. We
59
+ * accept strings verbatim to let users pass pre-formatted values
60
+ * (e.g. a DB-returned timestamp) without re-parsing.
61
+ */
62
+ function formatDate(value: string | Date): string {
63
+ return value instanceof Date ? value.toISOString() : value;
64
+ }
65
+
66
+ // ═══════════════════════════════════════════════════════════════════════════
67
+ // Sitemap rendering
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+
70
+ function renderSitemapEntry(entry: SitemapEntry): string {
71
+ const lines: string[] = [" <url>"];
72
+ lines.push(` <loc>${escapeXml(entry.url)}</loc>`);
73
+
74
+ if (entry.lastModified !== undefined) {
75
+ lines.push(` <lastmod>${escapeXml(formatDate(entry.lastModified))}</lastmod>`);
76
+ }
77
+ if (entry.changeFrequency) {
78
+ lines.push(` <changefreq>${entry.changeFrequency}</changefreq>`);
79
+ }
80
+ if (entry.priority !== undefined) {
81
+ lines.push(` <priority>${entry.priority.toFixed(1)}</priority>`);
82
+ }
83
+ if (entry.images?.length) {
84
+ for (const image of entry.images) {
85
+ lines.push(" <image:image>");
86
+ lines.push(` <image:loc>${escapeXml(image)}</image:loc>`);
87
+ lines.push(" </image:image>");
88
+ }
89
+ }
90
+ if (entry.alternates?.languages) {
91
+ for (const [lang, url] of Object.entries(entry.alternates.languages)) {
92
+ lines.push(
93
+ ` <xhtml:link rel="alternate" hreflang="${escapeXml(lang)}" href="${escapeXml(url)}" />`
94
+ );
95
+ }
96
+ }
97
+ lines.push(" </url>");
98
+ return lines.join("\n");
99
+ }
100
+
101
+ /**
102
+ * Render a sitemap entry array to XML 1.0. The `xmlns:image` and
103
+ * `xmlns:xhtml` namespaces are added on demand so a plain sitemap
104
+ * stays as compact as possible.
105
+ */
106
+ export function renderSitemap(entries: Sitemap): string {
107
+ const hasImages = entries.some((e) => e.images && e.images.length > 0);
108
+ const hasAlternates = entries.some((e) => e.alternates?.languages);
109
+
110
+ const namespaces = ['xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'];
111
+ if (hasImages) {
112
+ namespaces.push('xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"');
113
+ }
114
+ if (hasAlternates) {
115
+ namespaces.push('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
116
+ }
117
+
118
+ const lines = [
119
+ '<?xml version="1.0" encoding="UTF-8"?>',
120
+ `<urlset ${namespaces.join(" ")}>`,
121
+ ...entries.map(renderSitemapEntry),
122
+ "</urlset>",
123
+ ];
124
+ return lines.join("\n");
125
+ }
126
+
127
+ // ═══════════════════════════════════════════════════════════════════════════
128
+ // Robots rendering
129
+ // ═══════════════════════════════════════════════════════════════════════════
130
+
131
+ function toArray<T>(value: T | T[] | undefined): T[] {
132
+ if (value === undefined) return [];
133
+ return Array.isArray(value) ? value : [value];
134
+ }
135
+
136
+ function renderRobotsRule(rule: RobotsRule): string {
137
+ const lines: string[] = [];
138
+ const userAgents = toArray(rule.userAgent);
139
+ for (const ua of userAgents) {
140
+ lines.push(`User-agent: ${ua}`);
141
+ }
142
+ for (const path of toArray(rule.allow)) {
143
+ lines.push(`Allow: ${path}`);
144
+ }
145
+ for (const path of toArray(rule.disallow)) {
146
+ lines.push(`Disallow: ${path}`);
147
+ }
148
+ if (rule.crawlDelay !== undefined) {
149
+ lines.push(`Crawl-delay: ${rule.crawlDelay}`);
150
+ }
151
+ return lines.join("\n");
152
+ }
153
+
154
+ /**
155
+ * Render a `Robots` object to a robots.txt text body. Rule groups are
156
+ * separated by blank lines; `sitemap:` and `host:` directives go at
157
+ * the bottom per convention.
158
+ */
159
+ export function renderRobots(robots: Robots): string {
160
+ const sections: string[] = [];
161
+ const rules = toArray(robots.rules);
162
+ for (const rule of rules) {
163
+ sections.push(renderRobotsRule(rule));
164
+ }
165
+ if (robots.host) {
166
+ sections.push(`Host: ${robots.host}`);
167
+ }
168
+ for (const sitemap of toArray(robots.sitemap)) {
169
+ sections.push(`Sitemap: ${sitemap}`);
170
+ }
171
+ return sections.join("\n\n");
172
+ }
173
+
174
+ // ═══════════════════════════════════════════════════════════════════════════
175
+ // Web App Manifest rendering
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+
178
+ /**
179
+ * Serialize a `WebAppManifest` to JSON. Formatting is deterministic
180
+ * (2-space indent) so CDN caches don't generate spurious diffs when
181
+ * the underlying object is logically unchanged.
182
+ */
183
+ export function renderManifest(manifest: WebAppManifest): string {
184
+ return JSON.stringify(manifest, null, 2);
185
+ }
186
+
187
+ // ═══════════════════════════════════════════════════════════════════════════
188
+ // llms.txt passthrough
189
+ // ═══════════════════════════════════════════════════════════════════════════
190
+
191
+ /**
192
+ * Identity-ish passthrough for llms.txt content. Exposed as a function
193
+ * so the dispatcher can treat every route type uniformly — and so
194
+ * future formats (e.g. stripping BOM, normalizing line endings) can be
195
+ * added here without touching call sites.
196
+ */
197
+ export function renderLlmsTxt(content: string): string {
198
+ if (typeof content !== "string") {
199
+ throw new TypeError(
200
+ `[@mandujs/core/routes] llms.txt default export must return a string, got ${typeof content}`
201
+ );
202
+ }
203
+ return content;
204
+ }
205
+
206
+ // ═══════════════════════════════════════════════════════════════════════════
207
+ // Dispatcher
208
+ // ═══════════════════════════════════════════════════════════════════════════
209
+
210
+ /**
211
+ * Options passed to {@link handleMetadataRoute}. `sourceFile` is used
212
+ * purely for error messages — it appears in the 500 body when the user
213
+ * export throws or returns an invalid shape, which dramatically
214
+ * shortens the edit-test loop.
215
+ */
216
+ export interface MetadataRouteHandlerOptions {
217
+ /** Which of the four metadata routes we're serving. */
218
+ kind: MetadataRouteKind;
219
+ /** The imported user module's default export (not yet invoked). */
220
+ userExport: unknown;
221
+ /** The source file path, surfaced in error messages. */
222
+ sourceFile?: string;
223
+ /**
224
+ * Cache-Control header. `true` (default) emits
225
+ * `public, max-age=3600`. `false` omits the header. A string is
226
+ * passed through unchanged.
227
+ */
228
+ cache?: boolean | string;
229
+ }
230
+
231
+ /**
232
+ * Typed error thrown when metadata route validation fails. The message
233
+ * includes the source file + Zod path so the developer can jump
234
+ * directly to the offending line.
235
+ */
236
+ export class MetadataRouteValidationError extends Error {
237
+ readonly kind: MetadataRouteKind;
238
+ readonly issues: { path: string; message: string }[];
239
+ readonly sourceFile?: string;
240
+
241
+ constructor(
242
+ kind: MetadataRouteKind,
243
+ issues: { path: string; message: string }[],
244
+ sourceFile?: string
245
+ ) {
246
+ const header = sourceFile
247
+ ? `[@mandujs/core/routes] Invalid ${kind} value in ${sourceFile}`
248
+ : `[@mandujs/core/routes] Invalid ${kind} value`;
249
+ const body = issues.map((i) => ` • ${i.path || "(root)"}: ${i.message}`).join("\n");
250
+ super(`${header}\n${body}`);
251
+ this.name = "MetadataRouteValidationError";
252
+ this.kind = kind;
253
+ this.issues = issues;
254
+ this.sourceFile = sourceFile;
255
+ }
256
+ }
257
+
258
+ function defaultCacheControl(cache: boolean | string | undefined): string | null {
259
+ if (cache === false) return null;
260
+ if (typeof cache === "string") return cache;
261
+ return "public, max-age=3600";
262
+ }
263
+
264
+ /**
265
+ * Look up the Content-Type + URL pattern for a metadata route kind.
266
+ * Exposed so fs-scanner / manifest builders can reuse the same table
267
+ * without reaching into METADATA_ROUTES directly.
268
+ */
269
+ export function getMetadataRouteMeta(kind: MetadataRouteKind) {
270
+ return METADATA_ROUTES[kind];
271
+ }
272
+
273
+ /**
274
+ * Dispatch a metadata route request.
275
+ *
276
+ * Pipeline:
277
+ * 1. Extract the default-export function from the imported module.
278
+ * 2. Invoke it (await its result).
279
+ * 3. Zod-validate the returned shape.
280
+ * 4. Render the correct body format.
281
+ * 5. Wrap in a Response with the right Content-Type + cache headers.
282
+ *
283
+ * Any failure in steps 1-4 yields a typed 500 Response whose body
284
+ * includes the source file and Zod issue path.
285
+ */
286
+ export async function handleMetadataRoute(
287
+ options: MetadataRouteHandlerOptions
288
+ ): Promise<Response> {
289
+ const { kind, userExport, sourceFile } = options;
290
+ const { contentType } = METADATA_ROUTES[kind];
291
+ const cacheControl = defaultCacheControl(options.cache);
292
+
293
+ const fn = extractDefaultFn(userExport);
294
+ if (!fn) {
295
+ return errorResponse(
296
+ kind,
297
+ sourceFile,
298
+ "Module default export must be a function. " +
299
+ `Expected \`export default function ${friendlyName(kind)}() { ... }\`.`
300
+ );
301
+ }
302
+
303
+ let result: unknown;
304
+ try {
305
+ result = await fn();
306
+ } catch (err) {
307
+ const message = err instanceof Error ? err.message : String(err);
308
+ return errorResponse(kind, sourceFile, `User function threw: ${message}`);
309
+ }
310
+
311
+ try {
312
+ const body = renderValidated(kind, result, sourceFile);
313
+ const headers = new Headers({ "Content-Type": contentType });
314
+ if (cacheControl) headers.set("Cache-Control", cacheControl);
315
+ return new Response(body, { status: 200, headers });
316
+ } catch (err) {
317
+ if (err instanceof MetadataRouteValidationError) {
318
+ return errorResponse(kind, sourceFile, err.message);
319
+ }
320
+ const message = err instanceof Error ? err.message : String(err);
321
+ return errorResponse(kind, sourceFile, `Render failed: ${message}`);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Validate + render a single metadata route value. Shared between the
327
+ * production dispatcher and tests (where we often want to exercise a
328
+ * specific branch without spinning up a Request).
329
+ */
330
+ export function renderValidated(
331
+ kind: MetadataRouteKind,
332
+ value: unknown,
333
+ sourceFile?: string
334
+ ): string {
335
+ switch (kind) {
336
+ case "sitemap": {
337
+ const parsed = SitemapSchema.safeParse(value);
338
+ if (!parsed.success) throw zodToValidationError(kind, parsed.error, sourceFile);
339
+ return renderSitemap(parsed.data);
340
+ }
341
+ case "robots": {
342
+ const parsed = RobotsSchema.safeParse(value);
343
+ if (!parsed.success) throw zodToValidationError(kind, parsed.error, sourceFile);
344
+ return renderRobots(parsed.data);
345
+ }
346
+ case "llms-txt": {
347
+ if (typeof value !== "string") {
348
+ throw new MetadataRouteValidationError(
349
+ kind,
350
+ [{ path: "(root)", message: `expected string, got ${typeof value}` }],
351
+ sourceFile
352
+ );
353
+ }
354
+ return renderLlmsTxt(value);
355
+ }
356
+ case "manifest": {
357
+ const parsed = WebAppManifestSchema.safeParse(value);
358
+ if (!parsed.success) throw zodToValidationError(kind, parsed.error, sourceFile);
359
+ return renderManifest(parsed.data as WebAppManifest);
360
+ }
361
+ }
362
+ }
363
+
364
+ // ═══════════════════════════════════════════════════════════════════════════
365
+ // Helpers
366
+ // ═══════════════════════════════════════════════════════════════════════════
367
+
368
+ /**
369
+ * Accept both a raw function export and a `{ default: fn }` namespace
370
+ * object (the shape returned by `await import(...)`). Returns the
371
+ * callable or null if neither is available.
372
+ */
373
+ function extractDefaultFn(userExport: unknown): ((...args: unknown[]) => unknown) | null {
374
+ if (typeof userExport === "function") {
375
+ return userExport as (...args: unknown[]) => unknown;
376
+ }
377
+ if (userExport && typeof userExport === "object") {
378
+ const maybeDefault = (userExport as { default?: unknown }).default;
379
+ if (typeof maybeDefault === "function") {
380
+ return maybeDefault as (...args: unknown[]) => unknown;
381
+ }
382
+ }
383
+ return null;
384
+ }
385
+
386
+ function friendlyName(kind: MetadataRouteKind): string {
387
+ switch (kind) {
388
+ case "sitemap":
389
+ return "sitemap";
390
+ case "robots":
391
+ return "robots";
392
+ case "llms-txt":
393
+ return "llmsTxt";
394
+ case "manifest":
395
+ return "manifest";
396
+ }
397
+ }
398
+
399
+ function zodToValidationError(
400
+ kind: MetadataRouteKind,
401
+ error: { issues: { path: (string | number)[]; message: string }[] },
402
+ sourceFile?: string
403
+ ): MetadataRouteValidationError {
404
+ const issues = error.issues.map((i) => ({
405
+ path: i.path.join("."),
406
+ message: i.message,
407
+ }));
408
+ return new MetadataRouteValidationError(kind, issues, sourceFile);
409
+ }
410
+
411
+ /**
412
+ * Build a 500 Response with a plain-text body describing the problem.
413
+ * Mirrors how Next.js surfaces metadata errors — the text is read
414
+ * directly from the browser "View Source", no JSON framing.
415
+ */
416
+ function errorResponse(
417
+ kind: MetadataRouteKind,
418
+ sourceFile: string | undefined,
419
+ detail: string
420
+ ): Response {
421
+ const location = sourceFile ? ` (${sourceFile})` : "";
422
+ const body = `# Mandu metadata route error: ${kind}${location}\n${detail}\n`;
423
+ return new Response(body, {
424
+ status: 500,
425
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
426
+ });
427
+ }