@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.
- package/package.json +2 -1
- package/src/bundler/dev.ts +8 -0
- package/src/config/mandu.ts +18 -0
- package/src/config/validate.ts +8 -0
- package/src/content/collection.ts +322 -19
- package/src/content/index.ts +11 -2
- package/src/content/llms-txt.ts +98 -17
- package/src/content/prebuild.test.ts +322 -0
- package/src/content/prebuild.ts +261 -25
- package/src/content/sidebar.ts +446 -28
- package/src/generator/generate.ts +8 -0
- package/src/kitchen/api/routes-api.ts +8 -1
- package/src/router/fs-patterns.ts +26 -1
- package/src/router/fs-routes.ts +21 -1
- package/src/router/fs-scanner.ts +81 -0
- package/src/router/fs-types.ts +52 -1
- package/src/router/index.ts +2 -0
- package/src/routes/index.ts +74 -0
- package/src/routes/metadata-routes.ts +427 -0
- package/src/routes/types.ts +341 -0
- package/src/runtime/server.ts +87 -0
- package/src/spec/schema.ts +51 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Route Types
|
|
3
|
+
*
|
|
4
|
+
* File-convention types for Next.js-style metadata routes. Each type
|
|
5
|
+
* matches the Next.js shape so developers familiar with that ecosystem
|
|
6
|
+
* can drop their existing contract in verbatim.
|
|
7
|
+
*
|
|
8
|
+
* Exposed via `@mandujs/core/routes`. For the legacy free-form SEO
|
|
9
|
+
* metadata types (Metadata, Icons, OpenGraph, ...) see `@mandujs/core`
|
|
10
|
+
* / `packages/core/src/seo/types.ts` — this module is scoped strictly
|
|
11
|
+
* to the file-convention routes (sitemap.ts, robots.ts, llms.txt.ts,
|
|
12
|
+
* manifest.ts) and their runtime handlers.
|
|
13
|
+
*
|
|
14
|
+
* @module routes/types
|
|
15
|
+
*/
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
|
|
18
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
19
|
+
// Sitemap (sitemap.ts → /sitemap.xml)
|
|
20
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Change frequency hint from the Sitemaps XML schema.
|
|
24
|
+
* @see https://www.sitemaps.org/protocol.html#changefreqdef
|
|
25
|
+
*/
|
|
26
|
+
export type ChangeFrequency =
|
|
27
|
+
| "always"
|
|
28
|
+
| "hourly"
|
|
29
|
+
| "daily"
|
|
30
|
+
| "weekly"
|
|
31
|
+
| "monthly"
|
|
32
|
+
| "yearly"
|
|
33
|
+
| "never";
|
|
34
|
+
|
|
35
|
+
/** Alternate language URLs for a sitemap entry (hreflang). */
|
|
36
|
+
export interface SitemapAlternates {
|
|
37
|
+
languages?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Single sitemap entry. Next.js shape — `url` is required, everything
|
|
42
|
+
* else is optional. `lastModified` accepts a `Date` or ISO string so
|
|
43
|
+
* users can return e.g. `fs.statSync(...).mtime` directly.
|
|
44
|
+
*/
|
|
45
|
+
export interface SitemapEntry {
|
|
46
|
+
/** Absolute URL of the page. */
|
|
47
|
+
url: string;
|
|
48
|
+
/** Last modification time of the page. */
|
|
49
|
+
lastModified?: string | Date;
|
|
50
|
+
/** Crawl frequency hint for search engines. */
|
|
51
|
+
changeFrequency?: ChangeFrequency;
|
|
52
|
+
/** Priority hint [0.0, 1.0]. */
|
|
53
|
+
priority?: number;
|
|
54
|
+
/** Alternate URLs for i18n / hreflang. */
|
|
55
|
+
alternates?: SitemapAlternates;
|
|
56
|
+
/** Optional image URLs associated with the page. */
|
|
57
|
+
images?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Full sitemap: an array of SitemapEntry. */
|
|
61
|
+
export type Sitemap = SitemapEntry[];
|
|
62
|
+
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
// Robots (robots.ts → /robots.txt)
|
|
65
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Single rule group in robots.txt. Each group applies to one or more
|
|
69
|
+
* user agents; `allow` / `disallow` accept a single string or array.
|
|
70
|
+
*/
|
|
71
|
+
export interface RobotsRule {
|
|
72
|
+
/** User agent string ("*", "Googlebot", ...). */
|
|
73
|
+
userAgent: string | string[];
|
|
74
|
+
/** Paths explicitly permitted. */
|
|
75
|
+
allow?: string | string[];
|
|
76
|
+
/** Paths explicitly denied. */
|
|
77
|
+
disallow?: string | string[];
|
|
78
|
+
/** Crawl delay in seconds. */
|
|
79
|
+
crawlDelay?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Complete robots.txt definition. Matches Next.js `MetadataRoute.Robots`.
|
|
84
|
+
*/
|
|
85
|
+
export interface Robots {
|
|
86
|
+
/** One or more rule groups. */
|
|
87
|
+
rules: RobotsRule | RobotsRule[];
|
|
88
|
+
/** Sitemap URL(s) to announce. */
|
|
89
|
+
sitemap?: string | string[];
|
|
90
|
+
/** Host directive (Yandex). */
|
|
91
|
+
host?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
95
|
+
// Web App Manifest (manifest.ts → /manifest.webmanifest)
|
|
96
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
97
|
+
|
|
98
|
+
export type DisplayMode = "fullscreen" | "standalone" | "minimal-ui" | "browser";
|
|
99
|
+
export type Orientation =
|
|
100
|
+
| "any"
|
|
101
|
+
| "natural"
|
|
102
|
+
| "landscape"
|
|
103
|
+
| "landscape-primary"
|
|
104
|
+
| "landscape-secondary"
|
|
105
|
+
| "portrait"
|
|
106
|
+
| "portrait-primary"
|
|
107
|
+
| "portrait-secondary";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Icon descriptor in a web app manifest. `src` + `sizes` + `type` is
|
|
111
|
+
* the minimal W3C contract; `purpose` is optional but recommended for
|
|
112
|
+
* maskable icons.
|
|
113
|
+
*/
|
|
114
|
+
export interface WebAppManifestIcon {
|
|
115
|
+
src: string;
|
|
116
|
+
sizes?: string;
|
|
117
|
+
type?: string;
|
|
118
|
+
purpose?: "any" | "maskable" | "monochrome" | string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Shortcut entry (jumplist) in a web app manifest. */
|
|
122
|
+
export interface WebAppManifestShortcut {
|
|
123
|
+
name: string;
|
|
124
|
+
short_name?: string;
|
|
125
|
+
description?: string;
|
|
126
|
+
url: string;
|
|
127
|
+
icons?: WebAppManifestIcon[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Web app manifest (application/manifest+json). We type the common
|
|
132
|
+
* subset defined by the W3C spec; unknown keys pass through via the
|
|
133
|
+
* index signature so callers can add experimental fields.
|
|
134
|
+
*/
|
|
135
|
+
export interface WebAppManifest {
|
|
136
|
+
/** Full name of the application (required per W3C). */
|
|
137
|
+
name: string;
|
|
138
|
+
/** Short name for home-screen / launcher. */
|
|
139
|
+
short_name: string;
|
|
140
|
+
/** Description. */
|
|
141
|
+
description?: string;
|
|
142
|
+
/** Starting URL. */
|
|
143
|
+
start_url?: string;
|
|
144
|
+
/** Application scope (URL prefix). */
|
|
145
|
+
scope?: string;
|
|
146
|
+
/** Display mode. */
|
|
147
|
+
display?: DisplayMode;
|
|
148
|
+
/** Orientation preference. */
|
|
149
|
+
orientation?: Orientation;
|
|
150
|
+
/** Primary theme color (toolbar). */
|
|
151
|
+
theme_color?: string;
|
|
152
|
+
/** Background color (splash). */
|
|
153
|
+
background_color?: string;
|
|
154
|
+
/** Icons — at least one is required by the spec. */
|
|
155
|
+
icons: WebAppManifestIcon[];
|
|
156
|
+
/** Language tag (e.g. "en-US"). */
|
|
157
|
+
lang?: string;
|
|
158
|
+
/** Text direction. */
|
|
159
|
+
dir?: "ltr" | "rtl" | "auto";
|
|
160
|
+
/** Categories (taxonomy). */
|
|
161
|
+
categories?: string[];
|
|
162
|
+
/** Shortcuts shown in the app launcher. */
|
|
163
|
+
shortcuts?: WebAppManifestShortcut[];
|
|
164
|
+
/** Additional W3C fields pass through unchanged. */
|
|
165
|
+
[key: string]: unknown;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
169
|
+
// Default export function signatures
|
|
170
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Default-export contract for `app/sitemap.ts`. Return `Sitemap` or a
|
|
174
|
+
* Promise — Mandu awaits the result during the request.
|
|
175
|
+
*/
|
|
176
|
+
export type SitemapFn = () => Sitemap | Promise<Sitemap>;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Default-export contract for `app/robots.ts`.
|
|
180
|
+
*/
|
|
181
|
+
export type RobotsFn = () => Robots | Promise<Robots>;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Default-export contract for `app/llms.txt.ts`. Return the complete
|
|
185
|
+
* text body; Mandu serves it verbatim with `text/plain` content type.
|
|
186
|
+
*/
|
|
187
|
+
export type LlmsTxtFn = () => string | Promise<string>;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Default-export contract for `app/manifest.ts`.
|
|
191
|
+
*/
|
|
192
|
+
export type ManifestFn = () => WebAppManifest | Promise<WebAppManifest>;
|
|
193
|
+
|
|
194
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
195
|
+
// Metadata route kinds (for manifest / fs-scanner)
|
|
196
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Discriminator used in `RouteSpec.kind === "metadata"` entries and in
|
|
200
|
+
* `ScannedFile.metadataKind` when fs-scanner detects one of the four
|
|
201
|
+
* metadata files.
|
|
202
|
+
*/
|
|
203
|
+
export type MetadataRouteKind = "sitemap" | "robots" | "llms-txt" | "manifest";
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Static table mapping each metadata route kind to the file name the
|
|
207
|
+
* scanner recognizes, the URL pattern that serves the content, and the
|
|
208
|
+
* Content-Type header. Kept deliberately flat so both fs-scanner and
|
|
209
|
+
* the runtime dispatcher can read from the same source of truth.
|
|
210
|
+
*/
|
|
211
|
+
export const METADATA_ROUTES: Record<
|
|
212
|
+
MetadataRouteKind,
|
|
213
|
+
{ fileBase: string; pattern: string; contentType: string }
|
|
214
|
+
> = {
|
|
215
|
+
sitemap: {
|
|
216
|
+
fileBase: "sitemap",
|
|
217
|
+
pattern: "/sitemap.xml",
|
|
218
|
+
contentType: "application/xml; charset=utf-8",
|
|
219
|
+
},
|
|
220
|
+
robots: {
|
|
221
|
+
fileBase: "robots",
|
|
222
|
+
pattern: "/robots.txt",
|
|
223
|
+
contentType: "text/plain; charset=utf-8",
|
|
224
|
+
},
|
|
225
|
+
"llms-txt": {
|
|
226
|
+
// `llms.txt.ts` — filename contains a dot before the extension to
|
|
227
|
+
// mirror the served path (/llms.txt). Detected specifically in
|
|
228
|
+
// `detectMetadataFile()` since it doesn't match the common
|
|
229
|
+
// `<name>.<ext>` shape.
|
|
230
|
+
fileBase: "llms.txt",
|
|
231
|
+
pattern: "/llms.txt",
|
|
232
|
+
contentType: "text/plain; charset=utf-8",
|
|
233
|
+
},
|
|
234
|
+
manifest: {
|
|
235
|
+
fileBase: "manifest",
|
|
236
|
+
pattern: "/manifest.webmanifest",
|
|
237
|
+
contentType: "application/manifest+json; charset=utf-8",
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
242
|
+
// Zod schemas — runtime validation of user-returned values
|
|
243
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Validates a single sitemap entry. `url` must be a URL-like string;
|
|
247
|
+
* we accept both protocol-absolute URLs and site-root-relative paths
|
|
248
|
+
* (`/docs/...`) so users building a sitemap for an internal base can
|
|
249
|
+
* return relative URLs. `lastModified` accepts Date or date-like string.
|
|
250
|
+
*/
|
|
251
|
+
export const SitemapEntrySchema = z.object({
|
|
252
|
+
url: z
|
|
253
|
+
.string()
|
|
254
|
+
.min(1, "SitemapEntry.url must not be empty")
|
|
255
|
+
.refine(
|
|
256
|
+
(v) => /^(https?:\/\/|\/)/.test(v),
|
|
257
|
+
"SitemapEntry.url must be absolute (http(s)://...) or site-root-relative (/...)"
|
|
258
|
+
),
|
|
259
|
+
lastModified: z
|
|
260
|
+
.union([z.date(), z.string().min(1)])
|
|
261
|
+
.optional(),
|
|
262
|
+
changeFrequency: z
|
|
263
|
+
.enum(["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"])
|
|
264
|
+
.optional(),
|
|
265
|
+
priority: z.number().min(0).max(1).optional(),
|
|
266
|
+
alternates: z
|
|
267
|
+
.object({ languages: z.record(z.string()).optional() })
|
|
268
|
+
.optional(),
|
|
269
|
+
images: z.array(z.string().min(1)).optional(),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
export const SitemapSchema = z.array(SitemapEntrySchema);
|
|
273
|
+
|
|
274
|
+
export const RobotsRuleSchema = z.object({
|
|
275
|
+
userAgent: z.union([z.string().min(1), z.array(z.string().min(1))]),
|
|
276
|
+
allow: z.union([z.string(), z.array(z.string())]).optional(),
|
|
277
|
+
disallow: z.union([z.string(), z.array(z.string())]).optional(),
|
|
278
|
+
crawlDelay: z.number().nonnegative().optional(),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
export const RobotsSchema = z.object({
|
|
282
|
+
rules: z.union([RobotsRuleSchema, z.array(RobotsRuleSchema)]),
|
|
283
|
+
sitemap: z.union([z.string().min(1), z.array(z.string().min(1))]).optional(),
|
|
284
|
+
host: z.string().optional(),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
export const WebAppManifestIconSchema = z.object({
|
|
288
|
+
src: z.string().min(1, "WebAppManifest icon.src must not be empty"),
|
|
289
|
+
sizes: z.string().optional(),
|
|
290
|
+
type: z.string().optional(),
|
|
291
|
+
purpose: z.string().optional(),
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Minimum W3C-compliant manifest — `name`, `short_name`, and at least
|
|
296
|
+
* one icon. Unknown keys pass through via `.passthrough()` so users
|
|
297
|
+
* can add experimental manifest fields without fighting the validator.
|
|
298
|
+
*/
|
|
299
|
+
export const WebAppManifestSchema = z
|
|
300
|
+
.object({
|
|
301
|
+
name: z.string().min(1, "WebAppManifest.name is required"),
|
|
302
|
+
short_name: z.string().min(1, "WebAppManifest.short_name is required"),
|
|
303
|
+
description: z.string().optional(),
|
|
304
|
+
start_url: z.string().optional(),
|
|
305
|
+
scope: z.string().optional(),
|
|
306
|
+
display: z
|
|
307
|
+
.enum(["fullscreen", "standalone", "minimal-ui", "browser"])
|
|
308
|
+
.optional(),
|
|
309
|
+
orientation: z
|
|
310
|
+
.enum([
|
|
311
|
+
"any",
|
|
312
|
+
"natural",
|
|
313
|
+
"landscape",
|
|
314
|
+
"landscape-primary",
|
|
315
|
+
"landscape-secondary",
|
|
316
|
+
"portrait",
|
|
317
|
+
"portrait-primary",
|
|
318
|
+
"portrait-secondary",
|
|
319
|
+
])
|
|
320
|
+
.optional(),
|
|
321
|
+
theme_color: z.string().optional(),
|
|
322
|
+
background_color: z.string().optional(),
|
|
323
|
+
icons: z
|
|
324
|
+
.array(WebAppManifestIconSchema)
|
|
325
|
+
.min(1, "WebAppManifest.icons must contain at least one icon"),
|
|
326
|
+
lang: z.string().optional(),
|
|
327
|
+
dir: z.enum(["ltr", "rtl", "auto"]).optional(),
|
|
328
|
+
categories: z.array(z.string()).optional(),
|
|
329
|
+
shortcuts: z
|
|
330
|
+
.array(
|
|
331
|
+
z.object({
|
|
332
|
+
name: z.string().min(1),
|
|
333
|
+
short_name: z.string().optional(),
|
|
334
|
+
description: z.string().optional(),
|
|
335
|
+
url: z.string().min(1),
|
|
336
|
+
icons: z.array(WebAppManifestIconSchema).optional(),
|
|
337
|
+
})
|
|
338
|
+
)
|
|
339
|
+
.optional(),
|
|
340
|
+
})
|
|
341
|
+
.passthrough();
|
package/src/runtime/server.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { extractShellHtml, createPPRResponse } from "./ppr";
|
|
|
69
69
|
import { isRedirectResponse } from "./redirect";
|
|
70
70
|
import { isNotFoundResponse } from "./not-found";
|
|
71
71
|
import { newId } from "../id";
|
|
72
|
+
import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
|
|
72
73
|
|
|
73
74
|
export interface RateLimitOptions {
|
|
74
75
|
windowMs?: number;
|
|
@@ -461,6 +462,15 @@ export interface PageRegistration {
|
|
|
461
462
|
*/
|
|
462
463
|
export type PageHandler = () => Promise<PageRegistration>;
|
|
463
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Issue #206 — Metadata route handler. A thunk that returns the
|
|
467
|
+
* imported user module (or its `default` export) so the dispatcher
|
|
468
|
+
* in `handleMetadataRoute` can invoke the user-supplied function with
|
|
469
|
+
* the correct Content-Type + cache headers. The indirection keeps dev
|
|
470
|
+
* HMR cheap: the handler only imports on the request that needs it.
|
|
471
|
+
*/
|
|
472
|
+
export type MetadataHandler = () => Promise<unknown>;
|
|
473
|
+
|
|
464
474
|
export interface AppContext {
|
|
465
475
|
routeId: string;
|
|
466
476
|
url: string;
|
|
@@ -541,6 +551,15 @@ export class ServerRegistry {
|
|
|
541
551
|
readonly pageHandlers: Map<string, PageHandler> = new Map();
|
|
542
552
|
readonly pageFillings: Map<string, ManduFilling<unknown>> = new Map();
|
|
543
553
|
readonly routeComponents: Map<string, RouteComponent> = new Map();
|
|
554
|
+
/**
|
|
555
|
+
* Issue #206 — metadata-route handlers keyed by `route.id`
|
|
556
|
+
* (`metadata-sitemap` / `metadata-robots` / `metadata-llms-txt` /
|
|
557
|
+
* `metadata-manifest`). Each handler is a thunk that imports the
|
|
558
|
+
* user module lazily so dev HMR can swap it without restarting the
|
|
559
|
+
* server. The dispatcher in `handleMetadataRoute()` extracts the
|
|
560
|
+
* default export and invokes it.
|
|
561
|
+
*/
|
|
562
|
+
readonly metadataHandlers: Map<string, MetadataHandler> = new Map();
|
|
544
563
|
/** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
545
564
|
readonly layoutComponents: Map<string, LayoutComponent> = new Map();
|
|
546
565
|
/** Layout 로더 (모듈 경로 → 로더 함수) */
|
|
@@ -596,6 +615,17 @@ export class ServerRegistry {
|
|
|
596
615
|
this.apiHandlers.set(routeId, handler);
|
|
597
616
|
}
|
|
598
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Issue #206 — register a metadata-route loader. The handler must
|
|
620
|
+
* be a thunk returning either the raw user function or the
|
|
621
|
+
* module namespace (`{ default: fn }`). The dispatcher accepts both
|
|
622
|
+
* shapes so callers don't have to care whether they imported with
|
|
623
|
+
* `import("./sitemap")` or a bundled variant.
|
|
624
|
+
*/
|
|
625
|
+
registerMetadataHandler(routeId: string, handler: MetadataHandler): void {
|
|
626
|
+
this.metadataHandlers.set(routeId, handler);
|
|
627
|
+
}
|
|
628
|
+
|
|
599
629
|
registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
600
630
|
this.pageLoaders.set(routeId, loader);
|
|
601
631
|
}
|
|
@@ -760,6 +790,7 @@ export class ServerRegistry {
|
|
|
760
790
|
this.pageGenerateMetadata.clear();
|
|
761
791
|
this.layoutMetadata.clear();
|
|
762
792
|
this.layoutGenerateMetadata.clear();
|
|
793
|
+
this.metadataHandlers.clear();
|
|
763
794
|
this.createAppFn = null;
|
|
764
795
|
this.rateLimiter = null;
|
|
765
796
|
this.notFoundHandler = null;
|
|
@@ -792,6 +823,17 @@ export function registerApiHandler(routeId: string, handler: ApiHandler): void {
|
|
|
792
823
|
defaultRegistry.registerApiHandler(routeId, handler);
|
|
793
824
|
}
|
|
794
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Issue #206 — register a metadata route handler on the default
|
|
828
|
+
* registry. See {@link ServerRegistry.registerMetadataHandler} for
|
|
829
|
+
* the handler contract. Exposed at module scope so the CLI handlers
|
|
830
|
+
* wiring (`packages/cli/src/util/handlers.ts`) can reach it without
|
|
831
|
+
* threading a registry reference.
|
|
832
|
+
*/
|
|
833
|
+
export function registerMetadataHandler(routeId: string, handler: MetadataHandler): void {
|
|
834
|
+
defaultRegistry.registerMetadataHandler(routeId, handler);
|
|
835
|
+
}
|
|
836
|
+
|
|
795
837
|
export function registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
796
838
|
defaultRegistry.registerPageLoader(routeId, loader);
|
|
797
839
|
}
|
|
@@ -1369,6 +1411,47 @@ async function handleApiRoute(
|
|
|
1369
1411
|
}
|
|
1370
1412
|
}
|
|
1371
1413
|
|
|
1414
|
+
// ---------- Metadata Route Handler (Issue #206) ----------
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Dispatch a metadata route (sitemap / robots / llms.txt / manifest).
|
|
1418
|
+
*
|
|
1419
|
+
* The registry stores a thunk that lazily imports the user module
|
|
1420
|
+
* on the first request. We delegate the actual validation + render
|
|
1421
|
+
* + Response assembly to `dispatchMetadataRoute()` from
|
|
1422
|
+
* `@mandujs/core/routes` so the pipeline stays testable in isolation.
|
|
1423
|
+
*
|
|
1424
|
+
* A missing registration falls through to the framework's
|
|
1425
|
+
* "handler not found" 500 response — typically only reachable if the
|
|
1426
|
+
* scanner detected the file but `registerManifestHandlers` skipped it
|
|
1427
|
+
* (which would be a framework bug).
|
|
1428
|
+
*/
|
|
1429
|
+
async function handleMetadataRouteRequest(
|
|
1430
|
+
route: RouteSpec,
|
|
1431
|
+
registry: ServerRegistry
|
|
1432
|
+
): Promise<Result<Response>> {
|
|
1433
|
+
if (route.kind !== "metadata") {
|
|
1434
|
+
return err(createHandlerNotFoundResponse(route.id, route.pattern));
|
|
1435
|
+
}
|
|
1436
|
+
const handler = registry.metadataHandlers.get(route.id);
|
|
1437
|
+
if (!handler) {
|
|
1438
|
+
return err(createHandlerNotFoundResponse(route.id, route.pattern));
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
try {
|
|
1442
|
+
const userExport = await handler();
|
|
1443
|
+
const response = await dispatchMetadataRoute({
|
|
1444
|
+
kind: route.metadataKind,
|
|
1445
|
+
userExport,
|
|
1446
|
+
sourceFile: route.module,
|
|
1447
|
+
});
|
|
1448
|
+
return ok(response);
|
|
1449
|
+
} catch (errValue) {
|
|
1450
|
+
const error = errValue instanceof Error ? errValue : new Error(String(errValue));
|
|
1451
|
+
return err(createSSRErrorResponse(route.id, route.pattern, error));
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1372
1455
|
// ---------- Page Data Loader ----------
|
|
1373
1456
|
|
|
1374
1457
|
/**
|
|
@@ -2701,6 +2784,10 @@ async function handleRequestInternal(
|
|
|
2701
2784
|
return handlePageRoute(req, url, route, params, registry);
|
|
2702
2785
|
}
|
|
2703
2786
|
|
|
2787
|
+
if (route.kind === "metadata") {
|
|
2788
|
+
return handleMetadataRouteRequest(route, registry);
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2704
2791
|
// 4. 알 수 없는 라우트 종류 — exhaustiveness check
|
|
2705
2792
|
const _exhaustive: never = route;
|
|
2706
2793
|
return err({
|
package/src/spec/schema.ts
CHANGED
|
@@ -53,9 +53,17 @@ export type LoaderConfig = z.infer<typeof LoaderConfig>;
|
|
|
53
53
|
|
|
54
54
|
// ========== Route 설정 ==========
|
|
55
55
|
|
|
56
|
-
export const RouteKind = z.enum(["page", "api"]);
|
|
56
|
+
export const RouteKind = z.enum(["page", "api", "metadata"]);
|
|
57
57
|
export type RouteKind = z.infer<typeof RouteKind>;
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Metadata route kinds — one for each file-convention metadata file
|
|
61
|
+
* detected under `app/`. Kept in sync with
|
|
62
|
+
* `@mandujs/core/routes` → `MetadataRouteKind`.
|
|
63
|
+
*/
|
|
64
|
+
export const MetadataRouteKind = z.enum(["sitemap", "robots", "llms-txt", "manifest"]);
|
|
65
|
+
export type MetadataRouteKind = z.infer<typeof MetadataRouteKind>;
|
|
66
|
+
|
|
59
67
|
export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
60
68
|
export type SpecHttpMethod = z.infer<typeof SpecHttpMethod>;
|
|
61
69
|
|
|
@@ -115,6 +123,27 @@ export const ApiRouteSpec = z.object({
|
|
|
115
123
|
|
|
116
124
|
export type ApiRouteSpec = z.infer<typeof ApiRouteSpec>;
|
|
117
125
|
|
|
126
|
+
// ---- Metadata 라우트 (Issue #206) ----
|
|
127
|
+
// sitemap.ts / robots.ts / llms.txt.ts / manifest.ts — file-convention
|
|
128
|
+
// metadata routes. Dispatched through `@mandujs/core/routes`.
|
|
129
|
+
export const MetadataRouteSpec = z.object({
|
|
130
|
+
...RouteSpecBase,
|
|
131
|
+
kind: z.literal("metadata"),
|
|
132
|
+
/** Which metadata file this entry represents. */
|
|
133
|
+
metadataKind: MetadataRouteKind,
|
|
134
|
+
/** MIME type used on the served response. */
|
|
135
|
+
contentType: z.string().min(1),
|
|
136
|
+
// Re-declare shared optional fields so downstream consumers that
|
|
137
|
+
// switch on kind can still read them without type widening gymnastics.
|
|
138
|
+
componentModule: z.string().optional(),
|
|
139
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
140
|
+
layoutChain: z.array(z.string()).optional(),
|
|
141
|
+
loadingModule: z.string().optional(),
|
|
142
|
+
errorModule: z.string().optional(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export type MetadataRouteSpec = z.infer<typeof MetadataRouteSpec>;
|
|
146
|
+
|
|
118
147
|
// ---- discriminatedUnion ----
|
|
119
148
|
export const RouteSpec = z.discriminatedUnion("kind", [
|
|
120
149
|
// PageRouteSpec에 .refine()이 적용되어 있으므로 내부 shape를 직접 사용
|
|
@@ -136,6 +165,17 @@ export const RouteSpec = z.discriminatedUnion("kind", [
|
|
|
136
165
|
loadingModule: z.string().optional(),
|
|
137
166
|
errorModule: z.string().optional(),
|
|
138
167
|
}),
|
|
168
|
+
z.object({
|
|
169
|
+
...RouteSpecBase,
|
|
170
|
+
kind: z.literal("metadata"),
|
|
171
|
+
metadataKind: MetadataRouteKind,
|
|
172
|
+
contentType: z.string().min(1),
|
|
173
|
+
componentModule: z.string().optional(),
|
|
174
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
175
|
+
layoutChain: z.array(z.string()).optional(),
|
|
176
|
+
loadingModule: z.string().optional(),
|
|
177
|
+
errorModule: z.string().optional(),
|
|
178
|
+
}),
|
|
139
179
|
]);
|
|
140
180
|
|
|
141
181
|
export type RouteSpec = z.infer<typeof RouteSpec>;
|
|
@@ -194,6 +234,16 @@ export function assertApiRoute(route: RouteSpec): asserts route is ApiRouteSpec
|
|
|
194
234
|
}
|
|
195
235
|
}
|
|
196
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Asserts that the given route is a metadata route.
|
|
239
|
+
* After this call, TypeScript narrows the type to MetadataRouteSpec.
|
|
240
|
+
*/
|
|
241
|
+
export function assertMetadataRoute(route: RouteSpec): asserts route is MetadataRouteSpec {
|
|
242
|
+
if (route.kind !== "metadata") {
|
|
243
|
+
throw new Error(`Expected metadata route, got "${route.kind}" (id: ${route.id})`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
197
247
|
// ========== 유틸리티 함수 ==========
|
|
198
248
|
|
|
199
249
|
/**
|