@timber-js/app 0.2.0-alpha.146 → 0.2.0-alpha.148
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/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
- package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
- package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
- package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
- package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-DJDdyEae.js} +157 -313
- package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
- package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
- package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
- package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
- package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
- package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
- package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
- package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
- package/dist/_chunks/logger-CM0clugc.js.map +1 -0
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
- package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
- package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
- package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
- package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
- package/dist/_chunks/tree-match-D2l830j2.js +102 -0
- package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
- package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
- package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-DvWDj453.js} +154 -88
- package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +36 -14
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/compress-module.d.ts +5 -4
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +1 -3
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +14 -134
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +3 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +20 -235
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/invalidation-epoch.d.ts +43 -0
- package/dist/cache/invalidation-epoch.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +13 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +17 -9
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli-schema-sync.d.ts +21 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -31
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/hmr.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +13 -4
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/error-reconstituter.d.ts +8 -1
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -17
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +30 -33
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +9 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +0 -11
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +0 -5
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +10 -5
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -4
- package/dist/cookies/index.js.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +1 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
- package/dist/dev-tools/hydration-overlay.d.ts +11 -0
- package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
- package/dist/dev-tools/index.d.ts +1 -2
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/fonts/ast-walk.d.ts +68 -0
- package/dist/fonts/ast-walk.d.ts.map +1 -0
- package/dist/fonts/ast.d.ts +23 -11
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +3 -1
- package/dist/fonts/bundle.d.ts.map +1 -1
- package/dist/fonts/dev-middleware.d.ts +1 -1
- package/dist/fonts/dev-middleware.d.ts.map +1 -1
- package/dist/fonts/fallbacks.d.ts +0 -7
- package/dist/fonts/fallbacks.d.ts.map +1 -1
- package/dist/fonts/google.d.ts +4 -2
- package/dist/fonts/google.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +3 -3
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/fonts/paths.d.ts +43 -0
- package/dist/fonts/paths.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +7 -21
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +1 -18
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +609 -406
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugin-context.d.ts +28 -5
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +13 -5
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +1 -2
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +4 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/file-cache.d.ts +8 -0
- package/dist/routing/file-cache.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception.d.ts +0 -5
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +32 -0
- package/dist/routing/manifest-codegen.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +15 -0
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +7 -3
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/actions.d.ts +0 -14
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +0 -29
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +10 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +27 -2
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +8 -0
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.d.ts +1 -5
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +238 -349
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +0 -11
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/render-utils.d.ts +25 -0
- package/dist/server/render-utils.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +11 -25
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +2 -18
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +6 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/utils/mdx-file.d.ts +15 -0
- package/dist/server/utils/mdx-file.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +3 -3
- package/src/adapters/cloudflare-kv-cache.ts +56 -19
- package/src/adapters/compress-module.ts +27 -118
- package/src/adapters/nitro.ts +3 -41
- package/src/adapters/raw-imports.d.ts +5 -0
- package/src/cache/cache-api.ts +9 -1
- package/src/cache/index.ts +5 -2
- package/src/cache/invalidation-epoch.ts +93 -0
- package/src/cache/redis-handler.ts +33 -2
- package/src/cache/timber-cache.ts +109 -53
- package/src/cli-schema-sync.ts +123 -30
- package/src/cli.ts +33 -37
- package/src/client/browser-entry/hmr.ts +0 -11
- package/src/client/browser-entry/hydrate.ts +39 -5
- package/src/client/browser-entry/post-hydration.ts +6 -15
- package/src/client/browser-entry/router-init.ts +98 -3
- package/src/client/error-boundary.tsx +14 -0
- package/src/client/error-reconstituter.tsx +15 -1
- package/src/client/history.ts +10 -24
- package/src/client/navigation-api.ts +14 -9
- package/src/client/navigation-context.ts +1 -21
- package/src/client/navigation-root.tsx +0 -8
- package/src/client/router.ts +18 -8
- package/src/client/rsc-fetch.ts +7 -2
- package/src/client/use-search-params.ts +10 -5
- package/src/config-validation.ts +33 -25
- package/src/cookies/define-cookie.ts +2 -18
- package/src/dev-tools/dev-page-shell.ts +6 -2
- package/src/dev-tools/hydration-overlay.ts +158 -0
- package/src/dev-tools/index.ts +3 -16
- package/src/fonts/ast-walk.ts +235 -0
- package/src/fonts/ast.ts +91 -249
- package/src/fonts/bundle.ts +16 -9
- package/src/fonts/dev-middleware.ts +16 -5
- package/src/fonts/fallbacks.ts +0 -23
- package/src/fonts/google.ts +7 -3
- package/src/fonts/local.ts +7 -6
- package/src/fonts/paths.ts +58 -0
- package/src/fonts/pipeline.ts +27 -48
- package/src/fonts/transform.ts +27 -0
- package/src/fonts/virtual-modules.ts +1 -57
- package/src/index.ts +5 -11
- package/src/params/resolve-schema.ts +2 -34
- package/src/plugin-context.ts +95 -17
- package/src/plugins/cache.ts +64 -17
- package/src/plugins/dev-server.ts +61 -9
- package/src/plugins/fonts.ts +21 -6
- package/src/plugins/routing.ts +5 -348
- package/src/routing/codegen-types.ts +4 -0
- package/src/routing/codegen.ts +66 -8
- package/src/routing/convention-lint.ts +4 -3
- package/src/routing/file-cache.ts +23 -0
- package/src/routing/interception.ts +2 -21
- package/src/routing/manifest-codegen.ts +290 -0
- package/src/routing/scanner.ts +85 -80
- package/src/routing/schema-validation.ts +2 -2
- package/src/schema-bridge.ts +63 -2
- package/src/search-params/define.ts +34 -2
- package/src/search-params/wrappers.ts +21 -3
- package/src/segment-params/define.ts +2 -62
- package/src/server/access-gate.tsx +8 -0
- package/src/server/actions.ts +8 -31
- package/src/server/build-manifest.ts +0 -56
- package/src/server/canonicalize.ts +6 -3
- package/src/server/deny-boundary.ts +106 -13
- package/src/server/deny-renderer.ts +46 -10
- package/src/server/error-boundary-wrapper.ts +1 -14
- package/src/server/html-injector-core.ts +19 -0
- package/src/server/html-injectors.ts +14 -4
- package/src/server/internal.ts +1 -18
- package/src/server/node-stream-transforms.ts +5 -72
- package/src/server/render-utils.ts +31 -0
- package/src/server/route-element-builder.ts +77 -47
- package/src/server/route-matcher.ts +22 -6
- package/src/server/rsc-entry/error-renderer.ts +2 -12
- package/src/server/rsc-entry/helpers.ts +6 -34
- package/src/server/rsc-entry/index.ts +11 -0
- package/src/server/rsc-entry/render-route.ts +7 -9
- package/src/server/rsc-entry/rsc-payload.ts +2 -1
- package/src/server/rsc-entry/ssr-renderer.ts +2 -2
- package/src/server/ssr-render.ts +1 -11
- package/src/server/status-code-resolver.ts +3 -2
- package/src/server/tree-match.ts +15 -5
- package/src/server/utils/mdx-file.ts +22 -0
- package/src/shared/href-validation.ts +8 -0
- package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DH-M8Poy.js +0 -2
- package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
- package/dist/_chunks/define-DMVhsipD.js.map +0 -1
- package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
- package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
- package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
- package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
- package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
- package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
- package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
- package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
- package/dist/_chunks/warnings-CNPXaypM.js +0 -125
- package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
- package/dist/client/link-pending-store.d.ts +0 -65
- package/dist/client/link-pending-store.d.ts.map +0 -1
- package/dist/client/nav-link-store.d.ts +0 -27
- package/dist/client/nav-link-store.d.ts.map +0 -1
- package/dist/dev-tools/warnings.d.ts +0 -109
- package/dist/dev-tools/warnings.d.ts.map +0 -1
- package/dist/server/flush.d.ts +0 -74
- package/dist/server/flush.d.ts.map +0 -1
- package/src/client/link-pending-store.ts +0 -116
- package/src/client/nav-link-store.ts +0 -37
- package/src/dev-tools/warnings.ts +0 -238
- package/src/server/flush.ts +0 -183
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route manifest codegen — generates virtual module source for the route
|
|
3
|
+
* manifest, search-params registry, and global schema.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from plugins/routing.ts to keep it under the 500-line budget.
|
|
6
|
+
* See design/18-build-system.md §"Virtual Modules".
|
|
7
|
+
*
|
|
8
|
+
* Task: TIM-1071 item 8
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { collectInterceptionRewrites } from './interception.js';
|
|
14
|
+
import type { RouteTree, SegmentNode, RouteFile } from './types.js';
|
|
15
|
+
import { readFileCached } from './file-cache.js';
|
|
16
|
+
|
|
17
|
+
// ── Single-file conventions ─────────────────────────────────────────────
|
|
18
|
+
// Each entry maps to a node property that holds a single RouteFile.
|
|
19
|
+
// Order matches the original serializeNode output for byte-identical diffs.
|
|
20
|
+
const SINGLE_FILE_KEYS = [
|
|
21
|
+
'page',
|
|
22
|
+
'layout',
|
|
23
|
+
'middleware',
|
|
24
|
+
'access',
|
|
25
|
+
'route',
|
|
26
|
+
'error',
|
|
27
|
+
'default',
|
|
28
|
+
'denied',
|
|
29
|
+
'params',
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
// ── Record-shaped conventions ───────────────────────────────────────────
|
|
33
|
+
// Each entry maps to a node property that holds Record<string, RouteFile>.
|
|
34
|
+
const RECORD_FILE_KEYS = [
|
|
35
|
+
'statusFiles',
|
|
36
|
+
'jsonStatusFiles',
|
|
37
|
+
'legacyStatusFiles',
|
|
38
|
+
'metadataRoutes',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
const SCHEMA_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
|
|
42
|
+
|
|
43
|
+
// ── Manifest generation ─────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generate the virtual module source for the route manifest.
|
|
47
|
+
*
|
|
48
|
+
* The output is a default-exported object containing the serialized route tree.
|
|
49
|
+
* All file references use absolute paths (required for virtual modules).
|
|
50
|
+
*/
|
|
51
|
+
export function generateManifestModule(tree: RouteTree, viteRoot: string): string {
|
|
52
|
+
const imports: string[] = [];
|
|
53
|
+
let importIndex = 0;
|
|
54
|
+
|
|
55
|
+
function addImport(file: RouteFile): string {
|
|
56
|
+
const varName = `_route${importIndex++}`;
|
|
57
|
+
imports.push(`const ${varName} = () => import("${file.filePath}");`);
|
|
58
|
+
return varName;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function serializeRouteFile(key: string, file: RouteFile, indent: string): string {
|
|
62
|
+
const v = addImport(file);
|
|
63
|
+
return `${indent}${key}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} },`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function serializeRouteFileRecord(
|
|
67
|
+
key: string,
|
|
68
|
+
record: Record<string, RouteFile>,
|
|
69
|
+
indent: string
|
|
70
|
+
): string | null {
|
|
71
|
+
const entries = Object.entries(record);
|
|
72
|
+
if (entries.length === 0) return null;
|
|
73
|
+
const inner = entries.map(([name, file]) => {
|
|
74
|
+
const v = addImport(file);
|
|
75
|
+
return `${indent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`;
|
|
76
|
+
});
|
|
77
|
+
return `${indent}${key}: {\n${inner.join(',\n')}\n${indent}},`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function serializeNode(node: SegmentNode, indent: string): string {
|
|
81
|
+
const nextIndent = indent + ' ';
|
|
82
|
+
const parts: string[] = [];
|
|
83
|
+
|
|
84
|
+
parts.push(`${nextIndent}segmentName: ${JSON.stringify(node.segmentName)},`);
|
|
85
|
+
parts.push(`${nextIndent}segmentType: ${JSON.stringify(node.segmentType)},`);
|
|
86
|
+
parts.push(`${nextIndent}urlPath: ${JSON.stringify(node.urlPath)},`);
|
|
87
|
+
|
|
88
|
+
if (node.paramName) {
|
|
89
|
+
parts.push(`${nextIndent}paramName: ${JSON.stringify(node.paramName)},`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (node.interceptionMarker) {
|
|
93
|
+
parts.push(`${nextIndent}interceptionMarker: ${JSON.stringify(node.interceptionMarker)},`);
|
|
94
|
+
}
|
|
95
|
+
if (node.interceptedSegmentName) {
|
|
96
|
+
parts.push(
|
|
97
|
+
`${nextIndent}interceptedSegmentName: ${JSON.stringify(node.interceptedSegmentName)},`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Single-file conventions — table-driven to avoid copy-paste drift
|
|
102
|
+
for (const key of SINGLE_FILE_KEYS) {
|
|
103
|
+
const file = node[key];
|
|
104
|
+
if (file) {
|
|
105
|
+
parts.push(serializeRouteFile(key, file, nextIndent));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// searchParams is now a named export from page.tsx, not a separate file.
|
|
110
|
+
// The page module's searchParams export is loaded via the page's lazy import.
|
|
111
|
+
// Runtime registration happens in the route loader using the page module.
|
|
112
|
+
|
|
113
|
+
// Record-shaped conventions — table-driven
|
|
114
|
+
for (const key of RECORD_FILE_KEYS) {
|
|
115
|
+
const record = node[key];
|
|
116
|
+
if (record) {
|
|
117
|
+
const line = serializeRouteFileRecord(key, record, nextIndent);
|
|
118
|
+
if (line) parts.push(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Children
|
|
123
|
+
if (node.children.length > 0) {
|
|
124
|
+
const childNodes = node.children.map((c) => serializeNode(c, nextIndent));
|
|
125
|
+
parts.push(`${nextIndent}children: [\n${childNodes.join(',\n')}\n${nextIndent}],`);
|
|
126
|
+
} else {
|
|
127
|
+
parts.push(`${nextIndent}children: [],`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Parallel slots
|
|
131
|
+
const slotKeys = Object.keys(node.slots);
|
|
132
|
+
if (slotKeys.length > 0) {
|
|
133
|
+
const slotEntries: string[] = [];
|
|
134
|
+
for (const slotName of slotKeys) {
|
|
135
|
+
const slotNode = node.slots[slotName]!;
|
|
136
|
+
slotEntries.push(
|
|
137
|
+
`${nextIndent} ${JSON.stringify(slotName)}: ${serializeNode(slotNode, nextIndent + ' ')}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
parts.push(`${nextIndent}slots: {\n${slotEntries.join(',\n')}\n${nextIndent}},`);
|
|
141
|
+
} else {
|
|
142
|
+
parts.push(`${nextIndent}slots: {},`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return `${indent}{\n${parts.join('\n')}\n${indent}}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rootSerialized = serializeNode(tree.root, ' ');
|
|
149
|
+
|
|
150
|
+
let proxyLine = '';
|
|
151
|
+
if (tree.proxy) {
|
|
152
|
+
const v = addImport(tree.proxy);
|
|
153
|
+
proxyLine = ` proxy: { load: ${v}, filePath: ${JSON.stringify(tree.proxy.filePath)} },`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let globalErrorLine = '';
|
|
157
|
+
if (tree.globalError) {
|
|
158
|
+
const v = addImport(tree.globalError);
|
|
159
|
+
globalErrorLine = ` globalError: { load: ${v}, filePath: ${JSON.stringify(tree.globalError.filePath)} },`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rewrites = collectInterceptionRewrites(tree.root);
|
|
163
|
+
const rewritesLine =
|
|
164
|
+
rewrites.length > 0
|
|
165
|
+
? ` interceptionRewrites: ${JSON.stringify(rewrites.map((r) => ({ interceptedPattern: r.interceptedPattern, interceptingPrefix: r.interceptingPrefix })))},`
|
|
166
|
+
: '';
|
|
167
|
+
|
|
168
|
+
const code = [
|
|
169
|
+
'// Auto-generated route manifest — do not edit.',
|
|
170
|
+
'// Generated by timber-routing plugin.',
|
|
171
|
+
'',
|
|
172
|
+
...imports,
|
|
173
|
+
'',
|
|
174
|
+
'const manifest = {',
|
|
175
|
+
` viteRoot: ${JSON.stringify(viteRoot)},`,
|
|
176
|
+
proxyLine,
|
|
177
|
+
globalErrorLine,
|
|
178
|
+
rewritesLine,
|
|
179
|
+
` root: ${rootSerialized},`,
|
|
180
|
+
'};',
|
|
181
|
+
'',
|
|
182
|
+
'export default manifest;',
|
|
183
|
+
]
|
|
184
|
+
.filter((line) => line !== undefined && line !== '')
|
|
185
|
+
.join('\n');
|
|
186
|
+
|
|
187
|
+
return code;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Search-params registry ──────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Walk the route tree and collect every page that exports a
|
|
194
|
+
* `searchParams` definition, then emit a module that statically imports
|
|
195
|
+
* each export and registers it into the shared search-params registry.
|
|
196
|
+
*/
|
|
197
|
+
export function generateSearchParamsRegistryModule(tree: RouteTree): string {
|
|
198
|
+
const entries: Array<{ urlPath: string; filePath: string }> = [];
|
|
199
|
+
|
|
200
|
+
function walk(node: SegmentNode): void {
|
|
201
|
+
if (node.page) {
|
|
202
|
+
if (node.params && fileHasSearchParamsExport(node.params.filePath)) {
|
|
203
|
+
entries.push({ urlPath: node.urlPath, filePath: node.params.filePath });
|
|
204
|
+
} else if (fileHasSearchParamsExport(node.page.filePath)) {
|
|
205
|
+
entries.push({ urlPath: node.urlPath, filePath: node.page.filePath });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
for (const child of node.children) walk(child);
|
|
209
|
+
for (const slot of Object.values(node.slots)) walk(slot);
|
|
210
|
+
}
|
|
211
|
+
walk(tree.root);
|
|
212
|
+
|
|
213
|
+
entries.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
|
|
214
|
+
|
|
215
|
+
const lines: string[] = [];
|
|
216
|
+
lines.push('// Auto-generated search-params registry — do not edit.');
|
|
217
|
+
lines.push('// Generated by timber-routing plugin (TIM-830).');
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push("import { registerSearchParams } from '@timber-js/app/search-params';");
|
|
220
|
+
lines.push('');
|
|
221
|
+
|
|
222
|
+
entries.forEach((entry, i) => {
|
|
223
|
+
lines.push(`import { searchParams as r${i} } from ${JSON.stringify(entry.filePath)};`);
|
|
224
|
+
});
|
|
225
|
+
lines.push('');
|
|
226
|
+
|
|
227
|
+
entries.forEach((entry, i) => {
|
|
228
|
+
lines.push(`registerSearchParams(${JSON.stringify(entry.urlPath)}, r${i});`);
|
|
229
|
+
});
|
|
230
|
+
lines.push('');
|
|
231
|
+
|
|
232
|
+
lines.push('export {};');
|
|
233
|
+
lines.push('');
|
|
234
|
+
|
|
235
|
+
return lines.join('\n');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fileHasSearchParamsExport(filePath: string): boolean {
|
|
239
|
+
try {
|
|
240
|
+
if (!existsSync(filePath)) return false;
|
|
241
|
+
const content = readFileCached(filePath);
|
|
242
|
+
return (
|
|
243
|
+
/export\s+(const|let|var)\s+searchParams\b/.test(content) ||
|
|
244
|
+
/export\s*\{[^}]*\bsearchParams\b[^}]*\}/.test(content)
|
|
245
|
+
);
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Schema module ───────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate the virtual:timber-schema module.
|
|
255
|
+
*
|
|
256
|
+
* When app/schema.{ts,tsx,js,jsx} exists, generates a module that
|
|
257
|
+
* imports the schema and resolves its segmentParams codecs into a
|
|
258
|
+
* bare-name codec map for runtime coercion.
|
|
259
|
+
*/
|
|
260
|
+
export function generateSchemaModule(appDir: string): string {
|
|
261
|
+
let schemaPath: string | null = null;
|
|
262
|
+
for (const ext of SCHEMA_EXTENSIONS) {
|
|
263
|
+
const candidate = join(appDir, `schema${ext}`);
|
|
264
|
+
if (existsSync(candidate)) {
|
|
265
|
+
schemaPath = candidate;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!schemaPath) {
|
|
271
|
+
return [
|
|
272
|
+
'// Auto-generated schema loader — no app/schema.ts found.',
|
|
273
|
+
'export default null;',
|
|
274
|
+
].join('\n');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return [
|
|
278
|
+
'// Auto-generated schema loader — do not edit.',
|
|
279
|
+
'// Generated by timber-routing plugin (TIM-931).',
|
|
280
|
+
'',
|
|
281
|
+
`import schema from ${JSON.stringify(schemaPath)};`,
|
|
282
|
+
`import { resolveSchemaCodecs } from '@timber-js/app/params';`,
|
|
283
|
+
'',
|
|
284
|
+
'const globalCodecs = schema?.segmentParams',
|
|
285
|
+
' ? resolveSchemaCodecs(schema.segmentParams)',
|
|
286
|
+
' : null;',
|
|
287
|
+
'',
|
|
288
|
+
'export default globalCodecs;',
|
|
289
|
+
].join('\n');
|
|
290
|
+
}
|
package/src/routing/scanner.ts
CHANGED
|
@@ -22,19 +22,7 @@ import { classifySegment } from './segment-classify.js';
|
|
|
22
22
|
import { DEFAULT_PAGE_EXTENSIONS } from './types.js';
|
|
23
23
|
import { classifyMetadataRoute, isDynamicMetadataExtension } from '../server/metadata-routes.js';
|
|
24
24
|
import { swallow } from '../server/logger.js';
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Pattern matching encoded path delimiters that must be rejected during route discovery.
|
|
28
|
-
* %2F / %2f (forward slash) and %5C / %5c (backslash) can cause route collisions
|
|
29
|
-
* when decoded. See design/13-security.md §"Encoded separators rejected".
|
|
30
|
-
*/
|
|
31
|
-
const ENCODED_SEPARATOR_PATTERN = /%(?:2[fF]|5[cC])/;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Pattern matching encoded null bytes (%00) that must be rejected.
|
|
35
|
-
* See design/13-security.md §"Null bytes rejected".
|
|
36
|
-
*/
|
|
37
|
-
const ENCODED_NULL_PATTERN = /%00/;
|
|
25
|
+
import { ENCODED_SEPARATOR_RE, NULL_BYTE_RE } from '../server/canonicalize.js';
|
|
38
26
|
|
|
39
27
|
/**
|
|
40
28
|
* File convention names that use pageExtensions (can be .tsx, .ts, .jsx, .js, .mdx, etc.)
|
|
@@ -177,44 +165,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
177
165
|
|
|
178
166
|
// Page-extension conventions (page, layout, error, default, denied)
|
|
179
167
|
if (PAGE_EXT_CONVENTIONS.has(name) && extSet.has(ext)) {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
break;
|
|
185
|
-
case 'layout':
|
|
186
|
-
node.layout = file;
|
|
187
|
-
break;
|
|
188
|
-
case 'error':
|
|
189
|
-
node.error = file;
|
|
190
|
-
break;
|
|
191
|
-
case 'default':
|
|
192
|
-
node.default = file;
|
|
193
|
-
break;
|
|
194
|
-
case 'denied':
|
|
195
|
-
node.denied = file;
|
|
196
|
-
break;
|
|
168
|
+
const key = name as 'page' | 'layout' | 'error' | 'default' | 'denied';
|
|
169
|
+
const existing = node[key];
|
|
170
|
+
if (existing) {
|
|
171
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
197
172
|
}
|
|
173
|
+
node[key] = { filePath: fullPath, extension: ext };
|
|
198
174
|
continue;
|
|
199
175
|
}
|
|
200
176
|
|
|
201
|
-
// Fixed conventions (middleware, access, route) —
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
break;
|
|
211
|
-
case 'route':
|
|
212
|
-
node.route = file;
|
|
213
|
-
break;
|
|
214
|
-
case 'params':
|
|
215
|
-
node.params = file;
|
|
216
|
-
break;
|
|
177
|
+
// Fixed conventions (middleware, access, route, params) — only
|
|
178
|
+
// .ts/.tsx/.js/.jsx, never pageExtensions like .mdx. The regex is
|
|
179
|
+
// anchored: mts/cts/mjs/cjs (and stray tail matches like `.foots`)
|
|
180
|
+
// are NOT recognized. See design/07-routing.md §"Page Extensions".
|
|
181
|
+
if (FIXED_CONVENTIONS.has(name) && /^[jt]sx?$/.test(ext)) {
|
|
182
|
+
const key = name as 'middleware' | 'access' | 'route' | 'params';
|
|
183
|
+
const existing = node[key];
|
|
184
|
+
if (existing) {
|
|
185
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
217
186
|
}
|
|
187
|
+
node[key] = { filePath: fullPath, extension: ext };
|
|
218
188
|
continue;
|
|
219
189
|
}
|
|
220
190
|
|
|
@@ -233,6 +203,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
233
203
|
if (!node.statusFiles) {
|
|
234
204
|
node.statusFiles = {};
|
|
235
205
|
}
|
|
206
|
+
const existing = node.statusFiles[name];
|
|
207
|
+
if (existing) {
|
|
208
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
209
|
+
}
|
|
236
210
|
node.statusFiles[name] = { filePath: fullPath, extension: ext };
|
|
237
211
|
continue;
|
|
238
212
|
}
|
|
@@ -242,6 +216,10 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
242
216
|
if (!node.legacyStatusFiles) {
|
|
243
217
|
node.legacyStatusFiles = {};
|
|
244
218
|
}
|
|
219
|
+
const existing = node.legacyStatusFiles[name];
|
|
220
|
+
if (existing) {
|
|
221
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
222
|
+
}
|
|
245
223
|
node.legacyStatusFiles[name] = { filePath: fullPath, extension: ext };
|
|
246
224
|
continue;
|
|
247
225
|
}
|
|
@@ -249,6 +227,8 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
249
227
|
// Metadata route files (sitemap.ts, robots.ts, icon.tsx, opengraph-image.tsx, etc.)
|
|
250
228
|
// Both static (.xml, .txt, .png, .ico, etc.) and dynamic (.ts, .tsx) files are recognized.
|
|
251
229
|
// When both exist for the same base name, dynamic takes precedence.
|
|
230
|
+
// Two files of the SAME kind (static+static or dynamic+dynamic) are a
|
|
231
|
+
// build error — picking one by readdir order is platform-dependent.
|
|
252
232
|
// See design/16-metadata.md §"Metadata Routes"
|
|
253
233
|
const metaInfo = classifyMetadataRoute(entry);
|
|
254
234
|
if (metaInfo) {
|
|
@@ -257,11 +237,13 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
257
237
|
}
|
|
258
238
|
const existing = node.metadataRoutes[name];
|
|
259
239
|
if (existing) {
|
|
260
|
-
// Dynamic > static precedence: only overwrite if the new file is dynamic
|
|
261
|
-
// or the existing file is static (dynamic always wins).
|
|
262
240
|
const existingIsDynamic = isDynamicMetadataExtension(name, existing.extension);
|
|
263
241
|
const newIsDynamic = isDynamicMetadataExtension(name, ext);
|
|
264
|
-
if (
|
|
242
|
+
if (existingIsDynamic === newIsDynamic) {
|
|
243
|
+
throwDuplicateConvention(name, existing.filePath, fullPath);
|
|
244
|
+
}
|
|
245
|
+
// Dynamic > static precedence: overwrite only when the new file is dynamic.
|
|
246
|
+
if (newIsDynamic) {
|
|
265
247
|
node.metadataRoutes[name] = { filePath: fullPath, extension: ext };
|
|
266
248
|
}
|
|
267
249
|
} else {
|
|
@@ -281,6 +263,26 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
281
263
|
}
|
|
282
264
|
}
|
|
283
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Throw a build error for two files claiming the same convention slot in
|
|
268
|
+
* one segment (e.g. page.tsx + page.jsx). Which file readdir returns first
|
|
269
|
+
* is platform-dependent (APFS sorts entries, ext4 does not) — silently
|
|
270
|
+
* picking one would let macOS dev and Linux CI serve different
|
|
271
|
+
* implementations of the same route.
|
|
272
|
+
*/
|
|
273
|
+
function throwDuplicateConvention(
|
|
274
|
+
convention: string,
|
|
275
|
+
firstPath: string,
|
|
276
|
+
secondPath: string
|
|
277
|
+
): never {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Build error: duplicate '${convention}' files in the same route segment.\n` +
|
|
280
|
+
` File 1: ${firstPath}\n` +
|
|
281
|
+
` File 2: ${secondPath}\n` +
|
|
282
|
+
`Each segment may define '${convention}' at most once. Remove one of the files.`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
284
286
|
/**
|
|
285
287
|
* Recursively scan child directories and build the segment tree.
|
|
286
288
|
*/
|
|
@@ -305,17 +307,17 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
|
|
|
305
307
|
// Reject directories with encoded path delimiters or null bytes.
|
|
306
308
|
// These can cause route collisions when decoded at the URL boundary.
|
|
307
309
|
// See design/13-security.md §"Encoded separators rejected" and §"Null bytes rejected".
|
|
308
|
-
if (
|
|
310
|
+
if (ENCODED_SEPARATOR_RE.test(entry)) {
|
|
309
311
|
throw new Error(
|
|
310
|
-
`Build error: directory name contains an encoded path delimiter (
|
|
312
|
+
`Build error: directory name contains an encoded path delimiter (%2F or %5C).\n` +
|
|
311
313
|
` Directory: ${fullPath}\n` +
|
|
312
314
|
`Encoded separators in directory names cause route collisions when decoded. ` +
|
|
313
315
|
`Rename the directory to remove the encoded delimiter.`
|
|
314
316
|
);
|
|
315
317
|
}
|
|
316
|
-
if (
|
|
318
|
+
if (NULL_BYTE_RE.test(entry)) {
|
|
317
319
|
throw new Error(
|
|
318
|
-
`Build error: directory name contains an encoded null byte (
|
|
320
|
+
`Build error: directory name contains an encoded null byte (%00).\n` +
|
|
319
321
|
` Directory: ${fullPath}\n` +
|
|
320
322
|
`Encoded null bytes in directory names are not allowed. ` +
|
|
321
323
|
`Rename the directory to remove the null byte encoding.`
|
|
@@ -359,57 +361,60 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
|
|
|
359
361
|
* Two route groups like (auth)/login/page.tsx and (marketing)/login/page.tsx both claim
|
|
360
362
|
* /login — the scanner must detect and reject this at build time.
|
|
361
363
|
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
+
* Each parallel slot is validated as its OWN collision space: a slot's pages
|
|
365
|
+
* intentionally share URL paths with the parent tree (that's the point of
|
|
366
|
+
* parallel routes), but two route groups INSIDE one slot claiming the same
|
|
367
|
+
* URL — e.g. @side/(a)/x/page.tsx + @side/(b)/x/page.tsx — still collide.
|
|
364
368
|
*/
|
|
365
369
|
function validateRouteGroupCollisions(root: SegmentNode): void {
|
|
366
370
|
// Map from urlPath → { filePath, source } for the first page/route seen at that path
|
|
367
|
-
|
|
368
|
-
collectRoutableLeaves(root, seen, '', false);
|
|
371
|
+
collectRoutableLeaves(root, new Map(), '');
|
|
369
372
|
}
|
|
370
373
|
|
|
371
374
|
/**
|
|
372
375
|
* Walk the segment tree and collect all routable leaves (page or route files),
|
|
373
|
-
* throwing on collision.
|
|
374
|
-
*
|
|
376
|
+
* throwing on collision. Each slot recurses with a FRESH collision map —
|
|
377
|
+
* slots are parallel routes that intentionally share URL paths with their
|
|
378
|
+
* parent tree, but collisions within a single slot are real errors.
|
|
375
379
|
*/
|
|
376
380
|
function collectRoutableLeaves(
|
|
377
381
|
node: SegmentNode,
|
|
378
382
|
seen: Map<string, { filePath: string; segmentPath: string }>,
|
|
379
|
-
segmentPath: string
|
|
380
|
-
insideSlot: boolean
|
|
383
|
+
segmentPath: string
|
|
381
384
|
): void {
|
|
385
|
+
// Intercepting routes are resolved by the slot resolver, not URL matching,
|
|
386
|
+
// and their computed urlPaths intentionally mirror other routes (e.g. two
|
|
387
|
+
// interceptors in one slot both produce /gallery/[id]). Skip them entirely.
|
|
388
|
+
if (node.segmentType === 'intercepting') return;
|
|
389
|
+
|
|
382
390
|
const currentPath = segmentPath
|
|
383
391
|
? `${segmentPath}/${node.segmentName}`
|
|
384
392
|
: node.segmentName || '(root)';
|
|
385
393
|
|
|
386
|
-
|
|
387
|
-
if (
|
|
388
|
-
const
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
`
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
`Rename or move one of the conflicting files.`
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
|
|
394
|
+
const routableFile = node.page ?? node.route;
|
|
395
|
+
if (routableFile) {
|
|
396
|
+
const existing = seen.get(node.urlPath);
|
|
397
|
+
if (existing) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`Build error: route collision — multiple route groups produce a page/route at the same URL path.\n` +
|
|
400
|
+
` URL path: ${node.urlPath}\n` +
|
|
401
|
+
` File 1: ${existing.filePath} (via ${existing.segmentPath})\n` +
|
|
402
|
+
` File 2: ${routableFile.filePath} (via ${currentPath})\n` +
|
|
403
|
+
`Each URL path must map to exactly one page or route handler. ` +
|
|
404
|
+
`Rename or move one of the conflicting files.`
|
|
405
|
+
);
|
|
402
406
|
}
|
|
407
|
+
seen.set(node.urlPath, { filePath: routableFile.filePath, segmentPath: currentPath });
|
|
403
408
|
}
|
|
404
409
|
|
|
405
410
|
// Recurse into children
|
|
406
411
|
for (const child of node.children) {
|
|
407
|
-
collectRoutableLeaves(child, seen, currentPath
|
|
412
|
+
collectRoutableLeaves(child, seen, currentPath);
|
|
408
413
|
}
|
|
409
414
|
|
|
410
|
-
// Recurse into slots — each slot
|
|
415
|
+
// Recurse into slots — each slot gets a fresh map (its own collision space)
|
|
411
416
|
for (const slotNode of Object.values(node.slots)) {
|
|
412
|
-
collectRoutableLeaves(slotNode,
|
|
417
|
+
collectRoutableLeaves(slotNode, new Map(), currentPath);
|
|
413
418
|
}
|
|
414
419
|
}
|
|
415
420
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Design doc: design/41-global-params.md
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { readFileCached } from './file-cache.js';
|
|
11
11
|
import type { RouteTree, SegmentNode } from './types.js';
|
|
12
12
|
import { findSchemaFile, parseExistingSchemaKeys } from '../cli-schema-sync.js';
|
|
13
13
|
import { classifyUrlSegment } from './segment-classify.js';
|
|
@@ -66,7 +66,7 @@ export function validateSchemaAgainstRoutes(tree: RouteTree, appDir: string): Sc
|
|
|
66
66
|
const schemaPath = findSchemaFile(appDir);
|
|
67
67
|
if (!schemaPath) return [];
|
|
68
68
|
|
|
69
|
-
const content =
|
|
69
|
+
const content = readFileCached(schemaPath);
|
|
70
70
|
const schemaKeys = parseExistingSchemaKeys(content);
|
|
71
71
|
|
|
72
72
|
if (schemaKeys.length === 0) return [];
|
package/src/schema-bridge.ts
CHANGED
|
@@ -85,6 +85,63 @@ export function isCodec(value: unknown): value is Codec<unknown> {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// fromParamSchema — bridge from Standard Schema to Codec<T> for route params
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Bridge a Standard Schema to a Codec for route params.
|
|
94
|
+
* Parse throws on failure (invalid param → 404). Serialize returns string.
|
|
95
|
+
*/
|
|
96
|
+
export function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
|
|
97
|
+
return {
|
|
98
|
+
parse(value: string | string[] | undefined): T {
|
|
99
|
+
const result = validateSync(schema, value);
|
|
100
|
+
if (!result.issues) {
|
|
101
|
+
return result.value;
|
|
102
|
+
}
|
|
103
|
+
const messages = result.issues.map((i) => i.message).join(', ');
|
|
104
|
+
throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
|
|
105
|
+
},
|
|
106
|
+
serialize(value: T): string | null {
|
|
107
|
+
if (value === null || value === undefined) return null;
|
|
108
|
+
if (Array.isArray(value)) return value.join('/');
|
|
109
|
+
return String(value);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// resolveCodecOrSchema — generic resolver for any codec-or-schema field
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a field value to a Codec. Accepts Codec<T>, StandardSchemaV1<T>,
|
|
120
|
+
* or (for route params) auto-wraps via fromParamSchema.
|
|
121
|
+
*
|
|
122
|
+
* @param fieldName - used in error messages
|
|
123
|
+
* @param value - the codec or schema to resolve
|
|
124
|
+
* @param mode - 'param' uses fromParamSchema (throws on parse failure),
|
|
125
|
+
* 'search' uses fromSchema (falls back to default on failure)
|
|
126
|
+
*/
|
|
127
|
+
export function resolveCodecOrSchema(
|
|
128
|
+
fieldName: string,
|
|
129
|
+
value: unknown,
|
|
130
|
+
mode: 'param' | 'search' = 'search'
|
|
131
|
+
): Codec<unknown> {
|
|
132
|
+
if (isCodec(value)) return value;
|
|
133
|
+
if (isStandardSchema(value)) {
|
|
134
|
+
return mode === 'param'
|
|
135
|
+
? fromParamSchema(fieldName, value)
|
|
136
|
+
: (fromSchema(value) as Codec<unknown>);
|
|
137
|
+
}
|
|
138
|
+
throw new Error(
|
|
139
|
+
`[timber] Field '${fieldName}' is not a valid codec or Standard Schema. ` +
|
|
140
|
+
`Expected an object with { parse, serialize } methods, or a Standard Schema object ` +
|
|
141
|
+
`(Zod, Valibot, ArkType).`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
88
145
|
// ---------------------------------------------------------------------------
|
|
89
146
|
// fromSchema — bridge from Standard Schema to Codec<T>
|
|
90
147
|
// ---------------------------------------------------------------------------
|
|
@@ -119,13 +176,17 @@ export function fromSchema<T>(schema: StandardSchemaV1<T>): Codec<T> {
|
|
|
119
176
|
return result.value;
|
|
120
177
|
}
|
|
121
178
|
|
|
122
|
-
// On failure, try parsing undefined to get the default
|
|
179
|
+
// On failure, try parsing undefined to get the default.
|
|
180
|
+
// Re-validate each time so factory defaults (e.g. .default(() => []))
|
|
181
|
+
// produce fresh values.
|
|
123
182
|
const defaultResult = validateSync(schema, undefined);
|
|
124
183
|
if (!defaultResult.issues) {
|
|
125
184
|
return defaultResult.value;
|
|
126
185
|
}
|
|
127
186
|
|
|
128
|
-
// No default available — return undefined (codec design choice)
|
|
187
|
+
// No default available — return undefined (codec design choice).
|
|
188
|
+
// Callers like defineSearchParams validate this at definition time
|
|
189
|
+
// via validateSchemaDefaults() to catch it early.
|
|
129
190
|
return undefined as T;
|
|
130
191
|
},
|
|
131
192
|
|