@timber-js/app 0.2.0-alpha.147 → 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-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
- 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-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
- 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/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.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-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.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 +12 -171
- 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/shared/href-validation.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +2 -2
- 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/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.ts +3 -2
- 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 +5 -17
- 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-renderer.ts +6 -0
- 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/rsc-entry/error-renderer.ts +1 -0
- 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/shared/href-validation.ts +8 -0
- package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
- package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
- 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-Ck4Y45XN.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.)
|
|
@@ -319,17 +307,17 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
|
|
|
319
307
|
// Reject directories with encoded path delimiters or null bytes.
|
|
320
308
|
// These can cause route collisions when decoded at the URL boundary.
|
|
321
309
|
// See design/13-security.md §"Encoded separators rejected" and §"Null bytes rejected".
|
|
322
|
-
if (
|
|
310
|
+
if (ENCODED_SEPARATOR_RE.test(entry)) {
|
|
323
311
|
throw new Error(
|
|
324
|
-
`Build error: directory name contains an encoded path delimiter (
|
|
312
|
+
`Build error: directory name contains an encoded path delimiter (%2F or %5C).\n` +
|
|
325
313
|
` Directory: ${fullPath}\n` +
|
|
326
314
|
`Encoded separators in directory names cause route collisions when decoded. ` +
|
|
327
315
|
`Rename the directory to remove the encoded delimiter.`
|
|
328
316
|
);
|
|
329
317
|
}
|
|
330
|
-
if (
|
|
318
|
+
if (NULL_BYTE_RE.test(entry)) {
|
|
331
319
|
throw new Error(
|
|
332
|
-
`Build error: directory name contains an encoded null byte (
|
|
320
|
+
`Build error: directory name contains an encoded null byte (%00).\n` +
|
|
333
321
|
` Directory: ${fullPath}\n` +
|
|
334
322
|
`Encoded null bytes in directory names are not allowed. ` +
|
|
335
323
|
`Rename the directory to remove the null byte encoding.`
|
|
@@ -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
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';
|
|
14
|
-
import { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';
|
|
14
|
+
import { fromSchema, isStandardSchema, isCodec, validateSync } from '../schema-bridge.js';
|
|
15
15
|
import type { StandardSchemaV1 } from '../schema-bridge.js';
|
|
16
16
|
import type { Codec } from '../codec.js';
|
|
17
17
|
|
|
@@ -184,7 +184,7 @@ function normalizeRaw(
|
|
|
184
184
|
raw: URLSearchParams | Record<string, string | string[] | undefined>
|
|
185
185
|
): Record<string, string | string[] | undefined> {
|
|
186
186
|
if (raw instanceof URLSearchParams) {
|
|
187
|
-
const result: Record<string, string | string[] | undefined> =
|
|
187
|
+
const result: Record<string, string | string[] | undefined> = Object.create(null);
|
|
188
188
|
for (const key of new Set(raw.keys())) {
|
|
189
189
|
const values = raw.getAll(key);
|
|
190
190
|
result[key] = values.length === 1 ? values[0] : values;
|
|
@@ -220,6 +220,20 @@ function resolveField(
|
|
|
220
220
|
|
|
221
221
|
// Auto-detect Standard Schema
|
|
222
222
|
if (isStandardSchema(value)) {
|
|
223
|
+
// Validate that the schema handles absent params (undefined input).
|
|
224
|
+
// Search params are optional — the URL might not contain the param.
|
|
225
|
+
// If the schema rejects undefined and has no default, the codec would
|
|
226
|
+
// return `undefined as T`, making the type lie. Catch this at definition
|
|
227
|
+
// time instead. design/23-search-params.md §"Default Validation"
|
|
228
|
+
const absentCheck = validateSync(value, undefined);
|
|
229
|
+
if (absentCheck.issues) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`[timber] defineSearchParams: field '${fieldName}' has no default value and rejects undefined input.\n` +
|
|
232
|
+
` Search params are optional — the URL might not contain ?${fieldName}=anything.\n` +
|
|
233
|
+
` Add .default() to your schema: z.coerce.number().int().min(1).default(1)\n` +
|
|
234
|
+
` Or use .optional() if the field is truly optional.`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
223
237
|
return { codec: fromSchema(value) };
|
|
224
238
|
}
|
|
225
239
|
|
|
@@ -470,6 +484,11 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
470
484
|
// Merge URL keys: base keys + new codec urlKeys from withUrlKey
|
|
471
485
|
const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };
|
|
472
486
|
|
|
487
|
+
// Same definition-time guard as defineSearchParams — a throw-on-absent
|
|
488
|
+
// codec added via extend() gets the friendly guidance error, not a
|
|
489
|
+
// crash on first parse (TIM-1066).
|
|
490
|
+
validateDefaults(combinedCodecs as Record<string, SearchParamCodec<unknown>>);
|
|
491
|
+
|
|
473
492
|
return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);
|
|
474
493
|
}
|
|
475
494
|
|
|
@@ -479,12 +498,25 @@ function buildDefinition<T extends Record<string, unknown>>(
|
|
|
479
498
|
const pickedUrlKeys: Record<string, string> = {};
|
|
480
499
|
|
|
481
500
|
for (const key of keys) {
|
|
501
|
+
// Explicit guard — TypeScript prevents this, but JS callers and
|
|
502
|
+
// casts reach here, and "Cannot read properties of undefined
|
|
503
|
+
// (reading 'serialize')" is no help (TIM-1066).
|
|
504
|
+
if (!(key in codecMap)) {
|
|
505
|
+
throw new Error(
|
|
506
|
+
`[timber] pick('${key}'): unknown key. ` +
|
|
507
|
+
`Available keys: ${Object.keys(codecMap)
|
|
508
|
+
.map((k) => `'${k}'`)
|
|
509
|
+
.join(', ')}.`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
482
512
|
pickedCodecs[key] = codecMap[key] as SearchParamCodec<unknown>;
|
|
483
513
|
if (key in urlKeys) {
|
|
484
514
|
pickedUrlKeys[key] = urlKeys[key];
|
|
485
515
|
}
|
|
486
516
|
}
|
|
487
517
|
|
|
518
|
+
validateDefaults(pickedCodecs);
|
|
519
|
+
|
|
488
520
|
return buildDefinition<Pick<T, K>>(
|
|
489
521
|
pickedCodecs as unknown as CodecMap<Pick<T, K>>,
|
|
490
522
|
pickedUrlKeys
|
|
@@ -9,7 +9,23 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { SearchParamCodec, SearchParamCodecWithUrlKey, SearchParamField } from './define.js';
|
|
12
|
-
import { isCodec, isStandardSchema, fromSchema } from '../schema-bridge.js';
|
|
12
|
+
import { isCodec, isStandardSchema, fromSchema, validateSync } from '../schema-bridge.js';
|
|
13
|
+
import type { StandardSchemaV1 } from '../schema-bridge.js';
|
|
14
|
+
|
|
15
|
+
function validateSchemaAbsentParam<T>(
|
|
16
|
+
schema: StandardSchemaV1<T>,
|
|
17
|
+
fieldHint: string
|
|
18
|
+
): SearchParamCodec<T> {
|
|
19
|
+
const absentCheck = validateSync(schema, undefined);
|
|
20
|
+
if (absentCheck.issues) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`[timber] withUrlKey: schema for '${fieldHint}' has no default value and rejects undefined input.\n` +
|
|
23
|
+
` Search params are optional — the URL might not contain ?${fieldHint}=anything.\n` +
|
|
24
|
+
` Add .default() to your schema or use .optional().`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
return fromSchema(schema);
|
|
28
|
+
}
|
|
13
29
|
|
|
14
30
|
// ---------------------------------------------------------------------------
|
|
15
31
|
// withDefault
|
|
@@ -78,11 +94,13 @@ export function withUrlKey<T>(
|
|
|
78
94
|
codecOrSchema: SearchParamField<T>,
|
|
79
95
|
urlKey: string
|
|
80
96
|
): SearchParamCodecWithUrlKey<T> {
|
|
81
|
-
// Auto-detect Standard Schema (Zod, Valibot, ArkType) and wrap
|
|
97
|
+
// Auto-detect Standard Schema (Zod, Valibot, ArkType) and wrap.
|
|
98
|
+
// Validate absent-param handling here since withUrlKey converts to a codec
|
|
99
|
+
// before resolveField sees it. design/23-search-params.md §"Default Validation"
|
|
82
100
|
const codec: SearchParamCodec<T> = isCodec(codecOrSchema)
|
|
83
101
|
? codecOrSchema
|
|
84
102
|
: isStandardSchema(codecOrSchema)
|
|
85
|
-
?
|
|
103
|
+
? validateSchemaAbsentParam(codecOrSchema, urlKey)
|
|
86
104
|
: (codecOrSchema as SearchParamCodec<T>);
|
|
87
105
|
return {
|
|
88
106
|
parse: codec.parse.bind(codec),
|
|
@@ -14,12 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { Codec } from '../codec.js';
|
|
17
|
-
import {
|
|
18
|
-
type StandardSchemaV1,
|
|
19
|
-
validateSync,
|
|
20
|
-
isStandardSchema,
|
|
21
|
-
isCodec,
|
|
22
|
-
} from '../schema-bridge.js';
|
|
17
|
+
import { type StandardSchemaV1, resolveCodecOrSchema } from '../schema-bridge.js';
|
|
23
18
|
import { useSegmentParams as clientUseSegmentParams } from '../client/use-segment-params.js';
|
|
24
19
|
|
|
25
20
|
// ---------------------------------------------------------------------------
|
|
@@ -114,61 +109,6 @@ export interface ParamsDefinition<T extends Record<string, unknown>> {
|
|
|
114
109
|
// Internal helpers
|
|
115
110
|
// ---------------------------------------------------------------------------
|
|
116
111
|
|
|
117
|
-
/**
|
|
118
|
-
* Wrap a Standard Schema into a Codec for route params.
|
|
119
|
-
*
|
|
120
|
-
* Unlike fromSchema for search params:
|
|
121
|
-
* - Parse throws on failure (no fallback to default)
|
|
122
|
-
* - Serialize returns string (not null)
|
|
123
|
-
*/
|
|
124
|
-
function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
|
|
125
|
-
return {
|
|
126
|
-
parse(value: string | string[] | undefined): T {
|
|
127
|
-
// Route params are always strings (single segment) or string[] (catch-all)
|
|
128
|
-
const input = Array.isArray(value) ? value : value;
|
|
129
|
-
|
|
130
|
-
const result = validateSync(schema, input);
|
|
131
|
-
if (!result.issues) {
|
|
132
|
-
return result.value;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// For route params, parse failure means the param is invalid → throw
|
|
136
|
-
const messages = result.issues.map((i) => i.message).join(', ');
|
|
137
|
-
throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
|
|
138
|
-
},
|
|
139
|
-
|
|
140
|
-
serialize(value: T): string | null {
|
|
141
|
-
if (value === null || value === undefined) {
|
|
142
|
-
return null;
|
|
143
|
-
}
|
|
144
|
-
// Catch-all segments produce arrays — join with '/' for path reconstruction
|
|
145
|
-
if (Array.isArray(value)) {
|
|
146
|
-
return value.join('/');
|
|
147
|
-
}
|
|
148
|
-
return String(value);
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Resolve a field value to a Codec. Auto-detects Standard Schema objects.
|
|
155
|
-
*/
|
|
156
|
-
function resolveField(fieldName: string, value: ParamField): Codec<unknown> {
|
|
157
|
-
if (isCodec(value)) {
|
|
158
|
-
return value;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (isStandardSchema(value)) {
|
|
162
|
-
return fromParamSchema(fieldName, value);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
throw new Error(
|
|
166
|
-
`[timber] defineSegmentParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +
|
|
167
|
-
`Expected an object with { parse, serialize } methods, or a Standard Schema object ` +
|
|
168
|
-
`(Zod, Valibot, ArkType).`
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
112
|
/**
|
|
173
113
|
* Validate that no codec's serialize returns null.
|
|
174
114
|
* Route params are structural — they must produce a valid path segment.
|
|
@@ -224,7 +164,7 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
|
|
|
224
164
|
const resolvedCodecs: Record<string, Codec<unknown>> = {};
|
|
225
165
|
|
|
226
166
|
for (const [key, value] of Object.entries(codecs)) {
|
|
227
|
-
resolvedCodecs[key] =
|
|
167
|
+
resolvedCodecs[key] = resolveCodecOrSchema(key, value, 'param');
|
|
228
168
|
}
|
|
229
169
|
|
|
230
170
|
// Validate that serialize doesn't return null
|
|
@@ -45,6 +45,14 @@ export function AccessGate(props: AccessGateProps): ReactNode | Promise<ReactNod
|
|
|
45
45
|
if (verdict === 'pass') {
|
|
46
46
|
return children;
|
|
47
47
|
}
|
|
48
|
+
// Render deny page in-tree when possible (same as the fallback path).
|
|
49
|
+
if (verdict instanceof DenySignal && denyPages) {
|
|
50
|
+
const denyElement = renderMatchingDenyPage(denyPages, verdict.status, verdict.data);
|
|
51
|
+
if (denyElement) {
|
|
52
|
+
setDenyStatus(verdict.status);
|
|
53
|
+
return denyElement;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
48
56
|
throw verdict;
|
|
49
57
|
}
|
|
50
58
|
|