@mandujs/core 0.25.3 → 0.27.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/client/spa-nav-helper.ts +82 -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/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 +113 -0
- package/src/runtime/ssr.ts +12 -0
- package/src/runtime/streaming-ssr.ts +16 -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;
|
|
@@ -375,6 +376,17 @@ export interface ServerOptions {
|
|
|
375
376
|
* also opt out via `data-no-prefetch`.
|
|
376
377
|
*/
|
|
377
378
|
prefetch?: boolean;
|
|
379
|
+
/**
|
|
380
|
+
* Issue #193 / #208 — enable opt-out SPA navigation (default `true`).
|
|
381
|
+
* When `true`, every SSR response gets (a) the `window.__MANDU_SPA__`
|
|
382
|
+
* global elided (the router's default) and (b) the inline SPA-nav
|
|
383
|
+
* IIFE (~1.6 KB) that intercepts internal `<a>` clicks with pushState
|
|
384
|
+
* + fetch + View-Transitions DOM-swap, so `hydration: "none"` projects
|
|
385
|
+
* still feel like a SPA. `false` reverts to legacy full-reload (and
|
|
386
|
+
* the full router's opt-in `data-mandu-link` requirement). Wired from
|
|
387
|
+
* `ManduConfig.spa`; per-link opt-out lives on `data-no-spa`.
|
|
388
|
+
*/
|
|
389
|
+
spa?: boolean;
|
|
378
390
|
/**
|
|
379
391
|
* Issue #191 — override dev-mode `_devtools.js` injection.
|
|
380
392
|
* Wired from `ManduConfig.dev.devtools`.
|
|
@@ -461,6 +473,15 @@ export interface PageRegistration {
|
|
|
461
473
|
*/
|
|
462
474
|
export type PageHandler = () => Promise<PageRegistration>;
|
|
463
475
|
|
|
476
|
+
/**
|
|
477
|
+
* Issue #206 — Metadata route handler. A thunk that returns the
|
|
478
|
+
* imported user module (or its `default` export) so the dispatcher
|
|
479
|
+
* in `handleMetadataRoute` can invoke the user-supplied function with
|
|
480
|
+
* the correct Content-Type + cache headers. The indirection keeps dev
|
|
481
|
+
* HMR cheap: the handler only imports on the request that needs it.
|
|
482
|
+
*/
|
|
483
|
+
export type MetadataHandler = () => Promise<unknown>;
|
|
484
|
+
|
|
464
485
|
export interface AppContext {
|
|
465
486
|
routeId: string;
|
|
466
487
|
url: string;
|
|
@@ -517,6 +538,14 @@ export interface ServerRegistrySettings {
|
|
|
517
538
|
* default); `false` suppresses the hover prefetch `<script>` injection.
|
|
518
539
|
*/
|
|
519
540
|
prefetch?: boolean;
|
|
541
|
+
/**
|
|
542
|
+
* Issue #193 / #208 — threaded from `ServerOptions.spa`.
|
|
543
|
+
* `undefined` is treated as `true` at the SSR call-site (default SPA
|
|
544
|
+
* nav on, helper injected). `false` both disables the full client
|
|
545
|
+
* router (opt-in via `data-mandu-link` only) AND omits the inline
|
|
546
|
+
* SPA-nav IIFE from `<head>`.
|
|
547
|
+
*/
|
|
548
|
+
spa?: boolean;
|
|
520
549
|
/**
|
|
521
550
|
* Issue #191 — threaded from `ServerOptions.devtools`. `undefined`
|
|
522
551
|
* means "use default (islands → inject)"; `true` / `false` force the
|
|
@@ -541,6 +570,15 @@ export class ServerRegistry {
|
|
|
541
570
|
readonly pageHandlers: Map<string, PageHandler> = new Map();
|
|
542
571
|
readonly pageFillings: Map<string, ManduFilling<unknown>> = new Map();
|
|
543
572
|
readonly routeComponents: Map<string, RouteComponent> = new Map();
|
|
573
|
+
/**
|
|
574
|
+
* Issue #206 — metadata-route handlers keyed by `route.id`
|
|
575
|
+
* (`metadata-sitemap` / `metadata-robots` / `metadata-llms-txt` /
|
|
576
|
+
* `metadata-manifest`). Each handler is a thunk that imports the
|
|
577
|
+
* user module lazily so dev HMR can swap it without restarting the
|
|
578
|
+
* server. The dispatcher in `handleMetadataRoute()` extracts the
|
|
579
|
+
* default export and invokes it.
|
|
580
|
+
*/
|
|
581
|
+
readonly metadataHandlers: Map<string, MetadataHandler> = new Map();
|
|
544
582
|
/** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
|
|
545
583
|
readonly layoutComponents: Map<string, LayoutComponent> = new Map();
|
|
546
584
|
/** Layout 로더 (모듈 경로 → 로더 함수) */
|
|
@@ -596,6 +634,17 @@ export class ServerRegistry {
|
|
|
596
634
|
this.apiHandlers.set(routeId, handler);
|
|
597
635
|
}
|
|
598
636
|
|
|
637
|
+
/**
|
|
638
|
+
* Issue #206 — register a metadata-route loader. The handler must
|
|
639
|
+
* be a thunk returning either the raw user function or the
|
|
640
|
+
* module namespace (`{ default: fn }`). The dispatcher accepts both
|
|
641
|
+
* shapes so callers don't have to care whether they imported with
|
|
642
|
+
* `import("./sitemap")` or a bundled variant.
|
|
643
|
+
*/
|
|
644
|
+
registerMetadataHandler(routeId: string, handler: MetadataHandler): void {
|
|
645
|
+
this.metadataHandlers.set(routeId, handler);
|
|
646
|
+
}
|
|
647
|
+
|
|
599
648
|
registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
600
649
|
this.pageLoaders.set(routeId, loader);
|
|
601
650
|
}
|
|
@@ -760,6 +809,7 @@ export class ServerRegistry {
|
|
|
760
809
|
this.pageGenerateMetadata.clear();
|
|
761
810
|
this.layoutMetadata.clear();
|
|
762
811
|
this.layoutGenerateMetadata.clear();
|
|
812
|
+
this.metadataHandlers.clear();
|
|
763
813
|
this.createAppFn = null;
|
|
764
814
|
this.rateLimiter = null;
|
|
765
815
|
this.notFoundHandler = null;
|
|
@@ -792,6 +842,17 @@ export function registerApiHandler(routeId: string, handler: ApiHandler): void {
|
|
|
792
842
|
defaultRegistry.registerApiHandler(routeId, handler);
|
|
793
843
|
}
|
|
794
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Issue #206 — register a metadata route handler on the default
|
|
847
|
+
* registry. See {@link ServerRegistry.registerMetadataHandler} for
|
|
848
|
+
* the handler contract. Exposed at module scope so the CLI handlers
|
|
849
|
+
* wiring (`packages/cli/src/util/handlers.ts`) can reach it without
|
|
850
|
+
* threading a registry reference.
|
|
851
|
+
*/
|
|
852
|
+
export function registerMetadataHandler(routeId: string, handler: MetadataHandler): void {
|
|
853
|
+
defaultRegistry.registerMetadataHandler(routeId, handler);
|
|
854
|
+
}
|
|
855
|
+
|
|
795
856
|
export function registerPageLoader(routeId: string, loader: PageLoader): void {
|
|
796
857
|
defaultRegistry.registerPageLoader(routeId, loader);
|
|
797
858
|
}
|
|
@@ -1369,6 +1430,47 @@ async function handleApiRoute(
|
|
|
1369
1430
|
}
|
|
1370
1431
|
}
|
|
1371
1432
|
|
|
1433
|
+
// ---------- Metadata Route Handler (Issue #206) ----------
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Dispatch a metadata route (sitemap / robots / llms.txt / manifest).
|
|
1437
|
+
*
|
|
1438
|
+
* The registry stores a thunk that lazily imports the user module
|
|
1439
|
+
* on the first request. We delegate the actual validation + render
|
|
1440
|
+
* + Response assembly to `dispatchMetadataRoute()` from
|
|
1441
|
+
* `@mandujs/core/routes` so the pipeline stays testable in isolation.
|
|
1442
|
+
*
|
|
1443
|
+
* A missing registration falls through to the framework's
|
|
1444
|
+
* "handler not found" 500 response — typically only reachable if the
|
|
1445
|
+
* scanner detected the file but `registerManifestHandlers` skipped it
|
|
1446
|
+
* (which would be a framework bug).
|
|
1447
|
+
*/
|
|
1448
|
+
async function handleMetadataRouteRequest(
|
|
1449
|
+
route: RouteSpec,
|
|
1450
|
+
registry: ServerRegistry
|
|
1451
|
+
): Promise<Result<Response>> {
|
|
1452
|
+
if (route.kind !== "metadata") {
|
|
1453
|
+
return err(createHandlerNotFoundResponse(route.id, route.pattern));
|
|
1454
|
+
}
|
|
1455
|
+
const handler = registry.metadataHandlers.get(route.id);
|
|
1456
|
+
if (!handler) {
|
|
1457
|
+
return err(createHandlerNotFoundResponse(route.id, route.pattern));
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
try {
|
|
1461
|
+
const userExport = await handler();
|
|
1462
|
+
const response = await dispatchMetadataRoute({
|
|
1463
|
+
kind: route.metadataKind,
|
|
1464
|
+
userExport,
|
|
1465
|
+
sourceFile: route.module,
|
|
1466
|
+
});
|
|
1467
|
+
return ok(response);
|
|
1468
|
+
} catch (errValue) {
|
|
1469
|
+
const error = errValue instanceof Error ? errValue : new Error(String(errValue));
|
|
1470
|
+
return err(createSSRErrorResponse(route.id, route.pattern, error));
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1372
1474
|
// ---------- Page Data Loader ----------
|
|
1373
1475
|
|
|
1374
1476
|
/**
|
|
@@ -1998,6 +2100,7 @@ async function renderPageSSR(
|
|
|
1998
2100
|
cssPath: settings.cssPath,
|
|
1999
2101
|
transitions: settings.transitions,
|
|
2000
2102
|
prefetch: settings.prefetch,
|
|
2103
|
+
spa: settings.spa,
|
|
2001
2104
|
devtools: settings.devtools,
|
|
2002
2105
|
onShellReady: () => {
|
|
2003
2106
|
if (settings.isDev) {
|
|
@@ -2036,6 +2139,7 @@ async function renderPageSSR(
|
|
|
2036
2139
|
islandPreWrapped: !!needsIslandWrap,
|
|
2037
2140
|
transitions: settings.transitions,
|
|
2038
2141
|
prefetch: settings.prefetch,
|
|
2142
|
+
spa: settings.spa,
|
|
2039
2143
|
devtools: settings.devtools,
|
|
2040
2144
|
});
|
|
2041
2145
|
return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
|
|
@@ -2079,6 +2183,7 @@ async function renderPageSSR(
|
|
|
2079
2183
|
cssPath: settings.cssPath,
|
|
2080
2184
|
transitions: settings.transitions,
|
|
2081
2185
|
prefetch: settings.prefetch,
|
|
2186
|
+
spa: settings.spa,
|
|
2082
2187
|
devtools: settings.devtools,
|
|
2083
2188
|
});
|
|
2084
2189
|
return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
|
|
@@ -2185,6 +2290,7 @@ async function renderNotFoundPage(
|
|
|
2185
2290
|
cssPath: settings.cssPath,
|
|
2186
2291
|
transitions: settings.transitions,
|
|
2187
2292
|
prefetch: settings.prefetch,
|
|
2293
|
+
spa: settings.spa,
|
|
2188
2294
|
devtools: settings.devtools,
|
|
2189
2295
|
});
|
|
2190
2296
|
|
|
@@ -2666,6 +2772,7 @@ async function handleRequestInternal(
|
|
|
2666
2772
|
cssPath: settings.cssPath,
|
|
2667
2773
|
transitions: settings.transitions,
|
|
2668
2774
|
prefetch: settings.prefetch,
|
|
2775
|
+
spa: settings.spa,
|
|
2669
2776
|
devtools: settings.devtools,
|
|
2670
2777
|
});
|
|
2671
2778
|
const headers = new Headers(html.headers);
|
|
@@ -2701,6 +2808,10 @@ async function handleRequestInternal(
|
|
|
2701
2808
|
return handlePageRoute(req, url, route, params, registry);
|
|
2702
2809
|
}
|
|
2703
2810
|
|
|
2811
|
+
if (route.kind === "metadata") {
|
|
2812
|
+
return handleMetadataRouteRequest(route, registry);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2704
2815
|
// 4. 알 수 없는 라우트 종류 — exhaustiveness check
|
|
2705
2816
|
const _exhaustive: never = route;
|
|
2706
2817
|
return err({
|
|
@@ -2820,6 +2931,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2820
2931
|
managementToken,
|
|
2821
2932
|
transitions,
|
|
2822
2933
|
prefetch,
|
|
2934
|
+
spa,
|
|
2823
2935
|
devtools,
|
|
2824
2936
|
observability: observabilityOption,
|
|
2825
2937
|
} = options;
|
|
@@ -2859,6 +2971,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
2859
2971
|
managementToken,
|
|
2860
2972
|
transitions,
|
|
2861
2973
|
prefetch,
|
|
2974
|
+
spa,
|
|
2862
2975
|
devtools,
|
|
2863
2976
|
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
2864
2977
|
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./esc
|
|
|
10
10
|
import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
|
|
11
11
|
import { generateFastRefreshPreamble } from "../bundler/dev";
|
|
12
12
|
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
13
|
+
import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Issue #192 — `@view-transition` at-rule block.
|
|
@@ -579,6 +580,16 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
579
580
|
// or cancel with a later inline style. False disables each independently.
|
|
580
581
|
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
581
582
|
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
583
|
+
// Issue #208 — Inline SPA-nav IIFE. Pre-#208 Issue #193 wired the SPA
|
|
584
|
+
// click handler into the client bundle (`router.ts::handleLinkClick`),
|
|
585
|
+
// which never ships to `hydration: "none"` projects (docs / marketing
|
|
586
|
+
// sites). The inline helper closes that gap: ~1.6 KB of JS that runs
|
|
587
|
+
// parse-time on every SSR response and intercepts internal anchor
|
|
588
|
+
// clicks using the same 10 exclusion cases as the full router.
|
|
589
|
+
// Coexists safely with the full router via a `__MANDU_ROUTER_STATE__`
|
|
590
|
+
// early-exit. Emit when `spa !== false`; skip entirely when the user
|
|
591
|
+
// opts out via `ssr.spa: false` (same flag the big-router reads).
|
|
592
|
+
const spaNavHelperTag = spa !== false ? SPA_NAV_HELPER_SCRIPT : "";
|
|
582
593
|
|
|
583
594
|
// useHead/useSeoMeta SSR 수집
|
|
584
595
|
let collectedHeadTags = "";
|
|
@@ -709,6 +720,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
709
720
|
${cssLinkTag}
|
|
710
721
|
${viewTransitionTag}
|
|
711
722
|
${prefetchScriptTag}
|
|
723
|
+
${spaNavHelperTag}
|
|
712
724
|
${hoistedLinkTags}
|
|
713
725
|
${headTags}
|
|
714
726
|
${collectedHeadTags}
|
|
@@ -25,6 +25,7 @@ import { getRenderToString } from "./react-renderer";
|
|
|
25
25
|
import { mark, measure } from "../perf";
|
|
26
26
|
import { generateFastRefreshPreamble } from "../bundler/dev";
|
|
27
27
|
import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
|
|
28
|
+
import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Issue #192 — `@view-transition` at-rule, mirror of the constant in
|
|
@@ -176,6 +177,14 @@ export interface StreamingSSROptions {
|
|
|
176
177
|
* Default: `true`.
|
|
177
178
|
*/
|
|
178
179
|
prefetch?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Issue #208 — emit the inline SPA-nav helper `<script>` (~1.6 KB)
|
|
182
|
+
* into the streaming shell `<head>`. Mirrors `SSROptions.spa`:
|
|
183
|
+
* `true` (default) injects so zero-JS / `hydration: "none"` projects
|
|
184
|
+
* still get pushState navigations + View Transitions API; `false`
|
|
185
|
+
* omits the block entirely, matching the legacy full-reload default.
|
|
186
|
+
*/
|
|
187
|
+
spa?: boolean;
|
|
179
188
|
/**
|
|
180
189
|
* Issue #191 — control dev-mode injection of the `_devtools.js`
|
|
181
190
|
* bundle. Mirrors `SSROptions.devtools`:
|
|
@@ -533,6 +542,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
533
542
|
isDev = false,
|
|
534
543
|
transitions = true,
|
|
535
544
|
prefetch = true,
|
|
545
|
+
spa = true,
|
|
536
546
|
} = options;
|
|
537
547
|
|
|
538
548
|
// CSS 링크 태그 생성
|
|
@@ -549,6 +559,11 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
549
559
|
// an inline style later in the document order.
|
|
550
560
|
const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
|
|
551
561
|
const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
|
|
562
|
+
// Issue #208 — Inline SPA-nav IIFE, mirror of `ssr.ts::renderToHTML`.
|
|
563
|
+
// See that call-site for the full rationale. Streaming SSR follows
|
|
564
|
+
// the same opt-out contract: `spa !== false` injects, `spa: false`
|
|
565
|
+
// omits the `<script>` block entirely.
|
|
566
|
+
const spaNavHelperTag = spa !== false ? SPA_NAV_HELPER_SCRIPT : "";
|
|
552
567
|
|
|
553
568
|
// Island wrapper (hydration이 필요한 경우)
|
|
554
569
|
const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
|
|
@@ -630,6 +645,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
630
645
|
${cssLinkTag}
|
|
631
646
|
${viewTransitionTag}
|
|
632
647
|
${prefetchScriptTag}
|
|
648
|
+
${spaNavHelperTag}
|
|
633
649
|
${loadingStyles}
|
|
634
650
|
${importMapScript}
|
|
635
651
|
${headTags}
|