@timber-js/app 0.2.0-alpha.182 → 0.2.0-alpha.184

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.
Files changed (57) hide show
  1. package/dist/_chunks/{build-output-helper-DF5uScqS.js → build-output-helper-DOGYFb_X.js} +47 -9
  2. package/dist/_chunks/build-output-helper-DOGYFb_X.js.map +1 -0
  3. package/dist/_chunks/{cloudflare-AHoWYTYr.js → cloudflare-CnT5Lr7U.js} +21 -3
  4. package/dist/_chunks/{cloudflare-AHoWYTYr.js.map → cloudflare-CnT5Lr7U.js.map} +1 -1
  5. package/dist/_chunks/plugin-context---kTF5v8.js.map +1 -1
  6. package/dist/adapters/build-output-helper.d.ts +6 -0
  7. package/dist/adapters/build-output-helper.d.ts.map +1 -1
  8. package/dist/adapters/cloudflare-dev.js +1 -1
  9. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  10. package/dist/adapters/cloudflare.d.ts +10 -0
  11. package/dist/adapters/cloudflare.d.ts.map +1 -1
  12. package/dist/adapters/cloudflare.js +2 -2
  13. package/dist/adapters/nitro.d.ts +14 -0
  14. package/dist/adapters/nitro.d.ts.map +1 -1
  15. package/dist/adapters/nitro.js +14 -1
  16. package/dist/adapters/nitro.js.map +1 -1
  17. package/dist/adapters/shared.d.ts +1 -1
  18. package/dist/adapters/shared.d.ts.map +1 -1
  19. package/dist/adapters/types.d.ts +14 -0
  20. package/dist/adapters/types.d.ts.map +1 -1
  21. package/dist/client/internal.js +113 -11
  22. package/dist/client/internal.js.map +1 -1
  23. package/dist/client/rsc-fetch.d.ts +9 -0
  24. package/dist/client/rsc-fetch.d.ts.map +1 -1
  25. package/dist/index.js +437 -200
  26. package/dist/index.js.map +1 -1
  27. package/dist/plugin-context.d.ts +12 -0
  28. package/dist/plugin-context.d.ts.map +1 -1
  29. package/dist/plugins/adapter-build.d.ts.map +1 -1
  30. package/dist/plugins/build-report.d.ts +4 -3
  31. package/dist/plugins/build-report.d.ts.map +1 -1
  32. package/dist/plugins/static-build.d.ts +55 -2
  33. package/dist/plugins/static-build.d.ts.map +1 -1
  34. package/dist/server/rsc-entry/index.d.ts +12 -0
  35. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  36. package/dist/server/sitemap-generator.d.ts +1 -1
  37. package/dist/server/sitemap-generator.d.ts.map +1 -1
  38. package/dist/server/static-generator.d.ts +106 -0
  39. package/dist/server/static-generator.d.ts.map +1 -0
  40. package/docs/learn/13-configuration.mdx +5 -1
  41. package/package.json +1 -1
  42. package/src/adapters/build-output-helper.ts +40 -1
  43. package/src/adapters/cloudflare.ts +43 -1
  44. package/src/adapters/nitro.ts +58 -2
  45. package/src/adapters/shared.ts +42 -11
  46. package/src/adapters/types.ts +14 -0
  47. package/src/client/browser-entry/index.ts +7 -1
  48. package/src/client/rsc-fetch.ts +187 -19
  49. package/src/index.ts +1 -1
  50. package/src/plugin-context.ts +12 -0
  51. package/src/plugins/adapter-build.ts +5 -0
  52. package/src/plugins/build-report.ts +6 -12
  53. package/src/plugins/static-build.ts +391 -2
  54. package/src/server/rsc-entry/index.ts +24 -0
  55. package/src/server/sitemap-generator.ts +1 -1
  56. package/src/server/static-generator.ts +692 -0
  57. package/dist/_chunks/build-output-helper-DF5uScqS.js.map +0 -1
@@ -3,13 +3,26 @@
3
3
  *
4
4
  * When `output: 'static'` is set in timber.config.ts, this plugin:
5
5
  * 1. Validates that no dynamic APIs (getCookies(), getHeaders()) are used
6
- * 2. Coordinates build-time rendering of all pages
6
+ * 2. After the build, renders all routes to static HTML + RSC flight files
7
7
  *
8
- * Design doc: design/15-future-prerendering.md
8
+ * Design doc: design/11-platform.md §"static output mode"
9
9
  */
10
10
 
11
11
  import type { Plugin } from 'vite';
12
+ import { existsSync } from 'node:fs';
13
+ import { mkdir, readdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
14
+ import { createHash } from 'node:crypto';
15
+ import { dirname, extname, join, resolve, relative } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
12
17
  import type { PluginContext } from '../plugin-context.js';
18
+ import type {
19
+ StaticGenerationOptions,
20
+ StaticGenerationSummary,
21
+ StaticPageEntry,
22
+ StaticRedirectEntry,
23
+ } from '../server/static-generator.js';
24
+ import { mergeRscAssetsManifest } from './adapter-build.js';
25
+ import { htmlEscapeJsonString } from '../server/flight-scripts.js';
13
26
 
14
27
  // ---------------------------------------------------------------------------
15
28
  // Types
@@ -74,6 +87,362 @@ export function detectDynamicApis(code: string, fileId: string): StaticValidatio
74
87
  return errors;
75
88
  }
76
89
 
90
+ // ---------------------------------------------------------------------------
91
+ // Redirect rules
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Generate a `_redirects` file from collected redirect entries.
96
+ * Uses the Cloudflare Pages / Netlify format: `/from /to statusCode`
97
+ *
98
+ * @internal Exported for testing.
99
+ */
100
+ export function generateRedirectsFile(redirects: StaticRedirectEntry[]): string {
101
+ const lines = redirects.map((r) => `${r.from} ${r.to} ${r.status}`);
102
+ return lines.join('\n') + '\n';
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // File output helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** Write a file atomically: temp sibling + rename. */
110
+ async function writeFileAtomic(path: string, data: Uint8Array | string): Promise<void> {
111
+ const tmp = `${path}.tmp`;
112
+ await writeFile(tmp, data);
113
+ await rename(tmp, path);
114
+ }
115
+
116
+ /**
117
+ * Determine the output file path for a static page entry.
118
+ *
119
+ * Pages: /about → about/index.html, / → index.html
120
+ * RSC: /about → _rsc/about.rsc, / → _rsc/index.rsc
121
+ * API: /feed.xml → feed.xml, /api/data → api/data
122
+ */
123
+ export function staticOutputPath(entry: StaticPageEntry): string {
124
+ const urlPath = entry.urlPath === '/' ? '' : entry.urlPath;
125
+
126
+ if (entry.kind === 'rsc') {
127
+ const rscPath = urlPath || '/index';
128
+ return `_rsc${rscPath}.rsc`;
129
+ }
130
+
131
+ if (entry.kind === 'api') {
132
+ // API routes use their URL path directly. If no extension, fall
133
+ // back to the path as-is (static host serves based on content type).
134
+ return urlPath.startsWith('/') ? urlPath.slice(1) : urlPath || 'index';
135
+ }
136
+
137
+ // HTML pages
138
+ if (!urlPath) return 'index.html';
139
+ // Synthetic 404 entry from static generation — must be 404.html at the
140
+ // root so static hosts (GitHub Pages, Cloudflare Pages, Netlify) serve
141
+ // it automatically for unmatched URLs. Uses an internal urlPath to avoid
142
+ // colliding with a real /404 page route.
143
+ if (urlPath === '/_timber_not_found') return '404.html';
144
+ return `${urlPath.startsWith('/') ? urlPath.slice(1) : urlPath}/index.html`;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Static generation orchestration (runs after Vite build)
149
+ // ---------------------------------------------------------------------------
150
+
151
+ interface StaticGenerateModule {
152
+ __timber_generateStaticSite?: (
153
+ options: StaticGenerationOptions
154
+ ) => Promise<StaticGenerationSummary>;
155
+ }
156
+
157
+ /**
158
+ * Load the built RSC entry and run the static generation pass.
159
+ *
160
+ * Unlike prebuilt capture, this needs the FULL pipeline handler, so
161
+ * __TIMBER_CAPTURE_MODE__ must be false.
162
+ */
163
+ async function runStaticGeneration(ctx: PluginContext): Promise<StaticGenerationSummary> {
164
+ const entryPath = join(ctx.buildDir, 'rsc', 'index.js');
165
+ if (!existsSync(entryPath)) {
166
+ throw new Error(
167
+ `[timber] Static generation: built RSC entry not found at ${entryPath} — ` +
168
+ `the build output is missing`
169
+ );
170
+ }
171
+
172
+ const g = globalThis as Record<string, unknown>;
173
+
174
+ // Set up the build manifest (same as adapter-build and prebuilt-capture).
175
+ if (ctx.buildManifest) g.__TIMBER_BUILD_MANIFEST__ ??= ctx.buildManifest;
176
+ if (ctx.deploymentId) g.__TIMBER_DEPLOYMENT_ID__ ??= ctx.deploymentId;
177
+
178
+ // Ensure capture mode is off — we need the full handler.
179
+ g.__TIMBER_CAPTURE_MODE__ = false;
180
+
181
+ // Merge per-route CSS and modulepreload entries from the RSC assets
182
+ // manifest into the build manifest BEFORE rendering, so rendered HTML
183
+ // includes all <link> tags. This same merge normally runs in
184
+ // adapter-build, but static generation runs first.
185
+ if (ctx.buildManifest) {
186
+ await mergeRscAssetsManifest(ctx.buildDir, ctx.root, ctx.buildManifest, ctx.routeClientRefKeys);
187
+ }
188
+
189
+ console.log('[timber] Static generation: building site…');
190
+
191
+ let mod: StaticGenerateModule;
192
+ try {
193
+ const entryUrl = `${pathToFileURL(entryPath).href}?t=${Date.now()}`;
194
+ mod = (await import(/* @vite-ignore */ entryUrl)) as StaticGenerateModule;
195
+ } catch (error) {
196
+ throw new Error(
197
+ `[timber] Static generation: failed to load the built RSC entry (${entryPath}). ` +
198
+ `The full request handler must initialize for static rendering.`,
199
+ { cause: error }
200
+ );
201
+ }
202
+
203
+ const generate = mod.__timber_generateStaticSite;
204
+ if (typeof generate !== 'function') {
205
+ throw new Error(
206
+ `[timber] Static generation: built RSC entry does not export ` +
207
+ `__timber_generateStaticSite — rebuild from a clean build directory`
208
+ );
209
+ }
210
+
211
+ const staticDir = join(ctx.buildDir, 'static-pages');
212
+ // Clean stale RSC artifacts from previous builds before generating
213
+ // new ones. Without this, already-hashed files from a prior build
214
+ // would get re-hashed and accumulate in the output.
215
+ await rm(join(staticDir, '_rsc'), { recursive: true, force: true }).catch(() => {});
216
+ await mkdir(staticDir, { recursive: true });
217
+
218
+ const sitemapConfig = ctx.config.sitemap;
219
+ const autoSitemapEnabled = Boolean(sitemapConfig?.enabled && sitemapConfig?.baseUrl);
220
+
221
+ const contentTypes: Record<string, string> = {};
222
+
223
+ const summary = await generate({
224
+ autoSitemapEnabled,
225
+ renderTimeoutMs: ctx.config.renderTimeoutMs,
226
+ onEntry: async (entry: StaticPageEntry) => {
227
+ const relPath = staticOutputPath(entry);
228
+ const fullPath = resolve(staticDir, relPath);
229
+
230
+ // Reject path traversal: generated params with "../" could escape
231
+ // the output directory and overwrite build artifacts.
232
+ const rel = relative(staticDir, fullPath);
233
+ if (rel.startsWith('..') || resolve(staticDir, rel) !== fullPath) {
234
+ throw new Error(
235
+ `[timber] Static generation: output path "${relPath}" escapes the ` +
236
+ `static output directory — check generateStaticSegmentParams for ` +
237
+ `path traversal characters in ${entry.urlPath}`
238
+ );
239
+ }
240
+
241
+ await mkdir(dirname(fullPath), { recursive: true });
242
+ await writeFileAtomic(fullPath, entry.body);
243
+
244
+ // Write params sidecar for RSC entries with route params.
245
+ // Static .rsc files don't carry HTTP headers, so the client
246
+ // fetches this sidecar to populate useSegmentParams(). TIM-1246.
247
+ if (entry.kind === 'rsc' && entry.headers['x-timber-params']) {
248
+ const paramsPath = fullPath.replace(/\.rsc$/, '.params.json');
249
+ await writeFileAtomic(paramsPath, entry.headers['x-timber-params']);
250
+ }
251
+
252
+ // Track Content-Type for extensionless API routes so adapters
253
+ // can write per-path Content-Type rules in _headers. TIM-1241.
254
+ if (entry.kind === 'api' && !extname(relPath) && entry.contentType) {
255
+ contentTypes[entry.urlPath] = entry.contentType;
256
+ }
257
+ },
258
+ });
259
+
260
+ const totalUrls = summary.rendered + summary.errors.length;
261
+ console.log(
262
+ `[timber] Static generation: rendered ${summary.rendered}/${totalUrls} entries.` +
263
+ (summary.errors.length > 0 ? ` ${summary.errors.length} failed.` : '') +
264
+ (summary.redirects.length > 0 ? ` ${summary.redirects.length} redirect(s).` : '')
265
+ );
266
+
267
+ if (summary.errors.length > 0) {
268
+ const details = summary.errors.map((e) => ` - ${e.urlPath}: ${e.message}`).join('\n');
269
+ throw new Error(`[timber] Static generation failed:\n${details}`);
270
+ }
271
+
272
+ // Store content-type mappings on the plugin context so adapters
273
+ // can include them in the _headers file. TIM-1241.
274
+ if (Object.keys(contentTypes).length > 0) {
275
+ ctx.staticContentTypes = contentTypes;
276
+ }
277
+
278
+ // Hash RSC flight payloads for immutable caching (TIM-1254).
279
+ // Renames _rsc/*.rsc → _rsc/*-<hash>.rsc and injects a manifest
280
+ // into each HTML file so the client can resolve hashed URLs.
281
+ // Skip when client JS is disabled — no SPA navigation, no RSC fetches,
282
+ // and injecting a <script> tag would violate the no-JS contract.
283
+ if (!ctx.clientJavascript.disabled) {
284
+ const rscManifest = await hashRscPayloads(staticDir);
285
+ if (Object.keys(rscManifest).length > 0) {
286
+ await injectRscManifestIntoHtml(staticDir, rscManifest);
287
+ ctx.rscManifest = rscManifest;
288
+ console.log(
289
+ `[timber] Static generation: hashed ${Object.keys(rscManifest).length} RSC flight payloads`
290
+ );
291
+ }
292
+ }
293
+
294
+ // Write _redirects file for platform redirect rules (Cloudflare Pages,
295
+ // Netlify, etc.). Format: /from /to status. See TIM-1245.
296
+ if (summary.redirects.length > 0) {
297
+ const generated = generateRedirectsFile(summary.redirects);
298
+ const redirectsPath = join(staticDir, '_redirects');
299
+
300
+ // Merge with user-authored _redirects if present. TIM-1249.
301
+ const userRedirects = await readUserRedirects(ctx.root);
302
+ if (userRedirects !== null) {
303
+ await writeFileAtomic(redirectsPath, generated + '\n' + userRedirects);
304
+ console.log(
305
+ `[timber] Static generation: wrote _redirects with ${summary.redirects.length} ` +
306
+ `generated rule(s), merged with user-authored _redirects`
307
+ );
308
+ } else {
309
+ await writeFileAtomic(redirectsPath, generated);
310
+ console.log(
311
+ `[timber] Static generation: wrote _redirects with ${summary.redirects.length} rule(s)`
312
+ );
313
+ }
314
+ }
315
+
316
+ return summary;
317
+ }
318
+
319
+ // ---------------------------------------------------------------------------
320
+ // RSC flight payload hashing (TIM-1254)
321
+ // ---------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Compute a short content hash for immutable caching.
325
+ * Uses 8 chars of base64url-encoded SHA-256, matching Vite's asset hash style.
326
+ */
327
+ export function contentHash(data: Buffer | Uint8Array): string {
328
+ return createHash('sha256').update(data).digest('base64url').slice(0, 8);
329
+ }
330
+
331
+ /**
332
+ * Walk a directory recursively and return all file paths.
333
+ */
334
+ async function walkDir(dir: string): Promise<string[]> {
335
+ const results: string[] = [];
336
+ let entries;
337
+ try {
338
+ entries = await readdir(dir, { withFileTypes: true });
339
+ } catch {
340
+ return results;
341
+ }
342
+ for (const entry of entries) {
343
+ const full = join(dir, entry.name);
344
+ if (entry.isDirectory()) {
345
+ results.push(...(await walkDir(full)));
346
+ } else {
347
+ results.push(full);
348
+ }
349
+ }
350
+ return results;
351
+ }
352
+
353
+ /**
354
+ * Hash all RSC flight payloads and params sidecars in `_rsc/`, rename
355
+ * them with content hashes, and inject a manifest into each HTML file.
356
+ *
357
+ * Before: `_rsc/index.rsc`, `_rsc/about.rsc`
358
+ * After: `_rsc/index-B7YxEKdN.rsc`, `_rsc/about-C8ZzFLfO.rsc`
359
+ *
360
+ * The manifest maps the original `_rsc/` URL to the hashed filename:
361
+ * `{"/_rsc/index.rsc":"/_rsc/index-B7YxEKdN.rsc", ...}`
362
+ *
363
+ * Injected into HTML as:
364
+ * `<script>window.__TIMBER_RSC_MANIFEST__={...}</script>`
365
+ *
366
+ * @internal Exported for testing.
367
+ */
368
+ export async function hashRscPayloads(staticDir: string): Promise<Record<string, string>> {
369
+ const rscDir = join(staticDir, '_rsc');
370
+ const files = await walkDir(rscDir);
371
+ const manifest: Record<string, string> = {};
372
+
373
+ for (const filePath of files) {
374
+ if (!filePath.endsWith('.rsc') && !filePath.endsWith('.params.json')) continue;
375
+
376
+ const content = await readFile(filePath);
377
+ const hash = contentHash(content);
378
+ const rel = relative(staticDir, filePath).replace(/\\/g, '/');
379
+
380
+ // Compute hashed filename: index.rsc → index-B7YxEKdN.rsc
381
+ const ext = filePath.endsWith('.params.json') ? '.params.json' : '.rsc';
382
+ const base = filePath.slice(0, -ext.length);
383
+ const hashedPath = `${base}-${hash}${ext}`;
384
+
385
+ await rename(filePath, hashedPath);
386
+
387
+ const hashedRel = relative(staticDir, hashedPath).replace(/\\/g, '/');
388
+ manifest[`/${rel}`] = `/${hashedRel}`;
389
+ }
390
+
391
+ return manifest;
392
+ }
393
+
394
+ /**
395
+ * Inject the RSC manifest into all HTML files as a `<script>` tag
396
+ * before `</head>`. This lets the client resolve hashed RSC URLs
397
+ * without an extra network request.
398
+ *
399
+ * @internal Exported for testing.
400
+ */
401
+ export async function injectRscManifestIntoHtml(
402
+ staticDir: string,
403
+ manifest: Record<string, string>
404
+ ): Promise<void> {
405
+ const allFiles = await walkDir(staticDir);
406
+ const htmlFiles = allFiles.filter((f) => f.endsWith('.html'));
407
+ const escapedJson = htmlEscapeJsonString(JSON.stringify(manifest));
408
+ const scriptTag = `<script>window.__TIMBER_RSC_MANIFEST__=${escapedJson}</script>`;
409
+
410
+ for (const htmlFile of htmlFiles) {
411
+ let html = await readFile(htmlFile, 'utf-8');
412
+ // Insert before </head> so it's available before any client JS runs
413
+ const headClose = html.indexOf('</head>');
414
+ if (headClose !== -1) {
415
+ html = html.slice(0, headClose) + scriptTag + html.slice(headClose);
416
+ } else {
417
+ // Fallback: prepend to body
418
+ html = scriptTag + html;
419
+ }
420
+ await writeFile(htmlFile, html);
421
+ }
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // User-authored _redirects merge (TIM-1249)
426
+ // ---------------------------------------------------------------------------
427
+
428
+ /**
429
+ * Read a user-authored `_redirects` file from the project's `public/`
430
+ * directory. Returns `null` if no file exists.
431
+ *
432
+ * @internal Exported for testing.
433
+ */
434
+ export async function readUserRedirects(root: string): Promise<string | null> {
435
+ const candidates = [join(root, 'public', '_redirects')];
436
+ for (const path of candidates) {
437
+ try {
438
+ return await readFile(path, 'utf-8');
439
+ } catch {
440
+ // File doesn't exist — try next
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+
77
446
  // ---------------------------------------------------------------------------
78
447
  // Vite Plugin
79
448
  // ---------------------------------------------------------------------------
@@ -85,6 +454,7 @@ export function detectDynamicApis(code: string, fileId: string): StaticValidatio
85
454
  *
86
455
  * Hooks:
87
456
  * - transform: Validates source files for static mode violations
457
+ * - buildApp (post): Renders all routes to static HTML after the build
88
458
  */
89
459
  export function timberStaticBuild(ctx: PluginContext): Plugin {
90
460
  return {
@@ -128,5 +498,24 @@ export function timberStaticBuild(ctx: PluginContext): Plugin {
128
498
 
129
499
  return null;
130
500
  },
501
+
502
+ /**
503
+ * After all environments are built, render every route to static HTML.
504
+ *
505
+ * Runs with order: 'post' so the RSC plugin's buildApp completes first,
506
+ * producing the built entry we import. Must run BEFORE the adapter
507
+ * build copies output to its platform-specific directory.
508
+ */
509
+ buildApp: {
510
+ order: 'post' as const,
511
+ async handler() {
512
+ if (ctx.dev) return;
513
+
514
+ const isStatic = ctx.config.output === 'static';
515
+ if (!isStatic) return;
516
+
517
+ await runStaticGeneration(ctx);
518
+ },
519
+ },
131
520
  };
132
521
  }
@@ -69,6 +69,7 @@ import { capturePrebuiltPayloads } from '../prebuilt-builder.js';
69
69
  import { setPrebuiltPayloadSource } from '../prebuilt/payload-source.js';
70
70
  import { setRscFunctions, setComponentRefreshTimeout } from '../prebuilt-runtime.js';
71
71
  import { createAutoSitemapHandler } from '../sitemap-handler.js';
72
+ import { generateStaticSite } from '../static-generator.js';
72
73
 
73
74
  /**
74
75
  * Resolve the Server-Timing mode from timber.config.ts.
@@ -461,6 +462,29 @@ export function __timber_capturePrebuilt(
461
462
  return capturePrebuiltPayloads(routeManifest, options);
462
463
  }
463
464
 
465
+ /**
466
+ * Post-build static site generation entry point (TIM-1238). Renders every
467
+ * route in the manifest to HTML + RSC flight payloads. Must run inside the
468
+ * built RSC bundle — the handler, route manifest, and page module loaders
469
+ * are module instances in THIS bundle's graph.
470
+ *
471
+ * Unlike capture mode, this needs the full pipeline handler, so it must
472
+ * NOT run when __TIMBER_CAPTURE_MODE__ is set.
473
+ *
474
+ * See design/11-platform.md §"static output mode".
475
+ */
476
+ export function __timber_generateStaticSite(
477
+ options: import('../static-generator.js').StaticGenerationOptions
478
+ ): Promise<import('../static-generator.js').StaticGenerationSummary> {
479
+ if (!_handler) {
480
+ throw new Error(
481
+ '[timber] __timber_generateStaticSite requires the full request handler. ' +
482
+ 'Do not call in capture mode (__TIMBER_CAPTURE_MODE__).'
483
+ );
484
+ }
485
+ return generateStaticSite(routeManifest, _handler, options);
486
+ }
487
+
464
488
  // Schema codecs are needed by both the capture path (param coercion) and
465
489
  // the request handler, so register them unconditionally.
466
490
  setGlobalSchemaCodecs(globalSchemaCodecs);
@@ -339,7 +339,7 @@ export async function generateSitemap(
339
339
  * When a user-authored sitemap exists at the root, auto-generation is disabled
340
340
  * to avoid conflicts — the user takes full control.
341
341
  */
342
- export function hasUserSitemap(root: SegmentNode): boolean {
342
+ export function hasUserSitemap(root: SegmentNode<unknown>): boolean {
343
343
  if (!root.metadataRoutes) return false;
344
344
  return 'sitemap' in root.metadataRoutes;
345
345
  }