@mandujs/core 0.53.2 → 0.54.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.
- package/README.md +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -380
- package/src/diagnose/checks.ts +832 -720
- package/src/diagnose/index.ts +17 -16
- package/src/diagnose/run.ts +93 -91
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +24 -31
- package/src/runtime/streaming-ssr.ts +32 -37
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,427 +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, "<")
|
|
52
|
-
.replace(/>/g, ">")
|
|
53
|
-
.replace(/"/g, """)
|
|
54
|
-
.replace(/'/g, "'");
|
|
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
|
-
}
|
|
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, "<")
|
|
52
|
+
.replace(/>/g, ">")
|
|
53
|
+
.replace(/"/g, """)
|
|
54
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|