@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.
@@ -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();
@@ -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,
@@ -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}