@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.
Files changed (274) hide show
  1. package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
  2. package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
  3. package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
  4. package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
  6. package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
  8. package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
  9. package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
  10. package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
  11. package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
  12. package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
  13. package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
  14. package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
  15. package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
  16. package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
  17. package/dist/_chunks/logger-CM0clugc.js.map +1 -0
  18. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
  19. package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
  20. package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
  21. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
  22. package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
  23. package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
  24. package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
  25. package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
  26. package/dist/_chunks/tree-match-D2l830j2.js +102 -0
  27. package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
  28. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
  29. package/dist/_chunks/{walkers-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
  30. package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
  31. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  32. package/dist/adapters/cloudflare-kv-cache.js +36 -14
  33. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  34. package/dist/adapters/compress-module.d.ts +5 -4
  35. package/dist/adapters/compress-module.d.ts.map +1 -1
  36. package/dist/adapters/nitro.d.ts +1 -3
  37. package/dist/adapters/nitro.d.ts.map +1 -1
  38. package/dist/adapters/nitro.js +14 -134
  39. package/dist/adapters/nitro.js.map +1 -1
  40. package/dist/cache/cache-api.d.ts.map +1 -1
  41. package/dist/cache/index.d.ts +3 -1
  42. package/dist/cache/index.d.ts.map +1 -1
  43. package/dist/cache/index.js +20 -235
  44. package/dist/cache/index.js.map +1 -1
  45. package/dist/cache/invalidation-epoch.d.ts +43 -0
  46. package/dist/cache/invalidation-epoch.d.ts.map +1 -0
  47. package/dist/cache/redis-handler.d.ts +13 -0
  48. package/dist/cache/redis-handler.d.ts.map +1 -1
  49. package/dist/cache/timber-cache.d.ts +17 -9
  50. package/dist/cache/timber-cache.d.ts.map +1 -1
  51. package/dist/cli-schema-sync.d.ts +21 -1
  52. package/dist/cli-schema-sync.d.ts.map +1 -1
  53. package/dist/cli.d.ts +12 -0
  54. package/dist/cli.d.ts.map +1 -1
  55. package/dist/cli.js +26 -31
  56. package/dist/cli.js.map +1 -1
  57. package/dist/client/browser-entry/hmr.d.ts.map +1 -1
  58. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  59. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  60. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  61. package/dist/client/history.d.ts +10 -17
  62. package/dist/client/history.d.ts.map +1 -1
  63. package/dist/client/index.js +1 -1
  64. package/dist/client/index.js.map +1 -1
  65. package/dist/client/internal.js +30 -33
  66. package/dist/client/internal.js.map +1 -1
  67. package/dist/client/navigation-api.d.ts +9 -2
  68. package/dist/client/navigation-api.d.ts.map +1 -1
  69. package/dist/client/navigation-context.d.ts +0 -11
  70. package/dist/client/navigation-context.d.ts.map +1 -1
  71. package/dist/client/navigation-root.d.ts +0 -5
  72. package/dist/client/navigation-root.d.ts.map +1 -1
  73. package/dist/client/router.d.ts.map +1 -1
  74. package/dist/client/rsc-fetch.d.ts.map +1 -1
  75. package/dist/client/use-search-params.d.ts +10 -5
  76. package/dist/client/use-search-params.d.ts.map +1 -1
  77. package/dist/codec.js +1 -1
  78. package/dist/config-validation.d.ts.map +1 -1
  79. package/dist/cookies/define-cookie.d.ts.map +1 -1
  80. package/dist/cookies/index.js +2 -4
  81. package/dist/cookies/index.js.map +1 -1
  82. package/dist/dev-tools/dev-page-shell.d.ts +1 -0
  83. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
  84. package/dist/dev-tools/hydration-overlay.d.ts +11 -0
  85. package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
  86. package/dist/dev-tools/index.d.ts +1 -2
  87. package/dist/dev-tools/index.d.ts.map +1 -1
  88. package/dist/fonts/ast-walk.d.ts +68 -0
  89. package/dist/fonts/ast-walk.d.ts.map +1 -0
  90. package/dist/fonts/ast.d.ts +23 -11
  91. package/dist/fonts/ast.d.ts.map +1 -1
  92. package/dist/fonts/bundle.d.ts +3 -1
  93. package/dist/fonts/bundle.d.ts.map +1 -1
  94. package/dist/fonts/dev-middleware.d.ts +1 -1
  95. package/dist/fonts/dev-middleware.d.ts.map +1 -1
  96. package/dist/fonts/fallbacks.d.ts +0 -7
  97. package/dist/fonts/fallbacks.d.ts.map +1 -1
  98. package/dist/fonts/google.d.ts +4 -2
  99. package/dist/fonts/google.d.ts.map +1 -1
  100. package/dist/fonts/local.d.ts +3 -3
  101. package/dist/fonts/local.d.ts.map +1 -1
  102. package/dist/fonts/paths.d.ts +43 -0
  103. package/dist/fonts/paths.d.ts.map +1 -0
  104. package/dist/fonts/pipeline.d.ts +7 -21
  105. package/dist/fonts/pipeline.d.ts.map +1 -1
  106. package/dist/fonts/transform.d.ts.map +1 -1
  107. package/dist/fonts/virtual-modules.d.ts +1 -18
  108. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +609 -406
  111. package/dist/index.js.map +1 -1
  112. package/dist/params/index.js +1 -1
  113. package/dist/params/resolve-schema.d.ts.map +1 -1
  114. package/dist/plugin-context.d.ts +28 -5
  115. package/dist/plugin-context.d.ts.map +1 -1
  116. package/dist/plugins/cache.d.ts +13 -5
  117. package/dist/plugins/cache.d.ts.map +1 -1
  118. package/dist/plugins/dev-server.d.ts.map +1 -1
  119. package/dist/plugins/fonts.d.ts +1 -2
  120. package/dist/plugins/fonts.d.ts.map +1 -1
  121. package/dist/plugins/routing.d.ts.map +1 -1
  122. package/dist/routing/codegen.d.ts.map +1 -1
  123. package/dist/routing/convention-lint.d.ts.map +1 -1
  124. package/dist/routing/file-cache.d.ts +8 -0
  125. package/dist/routing/file-cache.d.ts.map +1 -0
  126. package/dist/routing/index.js +2 -2
  127. package/dist/routing/interception.d.ts +0 -5
  128. package/dist/routing/interception.d.ts.map +1 -1
  129. package/dist/routing/manifest-codegen.d.ts +32 -0
  130. package/dist/routing/manifest-codegen.d.ts.map +1 -0
  131. package/dist/routing/scanner.d.ts.map +1 -1
  132. package/dist/schema-bridge.d.ts +15 -0
  133. package/dist/schema-bridge.d.ts.map +1 -1
  134. package/dist/search-params/define.d.ts.map +1 -1
  135. package/dist/search-params/index.js +7 -3
  136. package/dist/search-params/index.js.map +1 -1
  137. package/dist/search-params/wrappers.d.ts.map +1 -1
  138. package/dist/segment-params/define.d.ts.map +1 -1
  139. package/dist/segment-params/index.js +1 -1
  140. package/dist/server/access-gate.d.ts.map +1 -1
  141. package/dist/server/actions.d.ts +0 -14
  142. package/dist/server/actions.d.ts.map +1 -1
  143. package/dist/server/build-manifest.d.ts +0 -29
  144. package/dist/server/build-manifest.d.ts.map +1 -1
  145. package/dist/server/canonicalize.d.ts +10 -0
  146. package/dist/server/canonicalize.d.ts.map +1 -1
  147. package/dist/server/deny-renderer.d.ts +1 -1
  148. package/dist/server/deny-renderer.d.ts.map +1 -1
  149. package/dist/server/html-injector-core.d.ts +8 -0
  150. package/dist/server/html-injector-core.d.ts.map +1 -1
  151. package/dist/server/html-injectors.d.ts.map +1 -1
  152. package/dist/server/index.js +2 -2
  153. package/dist/server/internal.d.ts +1 -5
  154. package/dist/server/internal.d.ts.map +1 -1
  155. package/dist/server/internal.js +12 -171
  156. package/dist/server/internal.js.map +1 -1
  157. package/dist/server/node-stream-transforms.d.ts +0 -11
  158. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  159. package/dist/server/render-utils.d.ts +25 -0
  160. package/dist/server/render-utils.d.ts.map +1 -0
  161. package/dist/server/route-element-builder.d.ts +11 -25
  162. package/dist/server/route-element-builder.d.ts.map +1 -1
  163. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  164. package/dist/server/rsc-entry/helpers.d.ts +2 -18
  165. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  166. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  167. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  168. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  169. package/dist/server/ssr-render.d.ts.map +1 -1
  170. package/dist/shared/href-validation.d.ts.map +1 -1
  171. package/dist/shims/image.d.ts +15 -15
  172. package/package.json +2 -2
  173. package/src/adapters/cloudflare-kv-cache.ts +56 -19
  174. package/src/adapters/compress-module.ts +27 -118
  175. package/src/adapters/nitro.ts +3 -41
  176. package/src/adapters/raw-imports.d.ts +5 -0
  177. package/src/cache/cache-api.ts +9 -1
  178. package/src/cache/index.ts +5 -2
  179. package/src/cache/invalidation-epoch.ts +93 -0
  180. package/src/cache/redis-handler.ts +33 -2
  181. package/src/cache/timber-cache.ts +109 -53
  182. package/src/cli-schema-sync.ts +123 -30
  183. package/src/cli.ts +33 -37
  184. package/src/client/browser-entry/hmr.ts +0 -11
  185. package/src/client/browser-entry/hydrate.ts +39 -5
  186. package/src/client/browser-entry/post-hydration.ts +6 -15
  187. package/src/client/browser-entry/router-init.ts +98 -3
  188. package/src/client/history.ts +10 -24
  189. package/src/client/navigation-api.ts +14 -9
  190. package/src/client/navigation-context.ts +1 -21
  191. package/src/client/navigation-root.tsx +0 -8
  192. package/src/client/router.ts +18 -8
  193. package/src/client/rsc-fetch.ts +7 -2
  194. package/src/client/use-search-params.ts +10 -5
  195. package/src/config-validation.ts +33 -25
  196. package/src/cookies/define-cookie.ts +2 -18
  197. package/src/dev-tools/dev-page-shell.ts +6 -2
  198. package/src/dev-tools/hydration-overlay.ts +158 -0
  199. package/src/dev-tools/index.ts +3 -16
  200. package/src/fonts/ast-walk.ts +235 -0
  201. package/src/fonts/ast.ts +91 -249
  202. package/src/fonts/bundle.ts +16 -9
  203. package/src/fonts/dev-middleware.ts +16 -5
  204. package/src/fonts/fallbacks.ts +0 -23
  205. package/src/fonts/google.ts +7 -3
  206. package/src/fonts/local.ts +7 -6
  207. package/src/fonts/paths.ts +58 -0
  208. package/src/fonts/pipeline.ts +27 -48
  209. package/src/fonts/transform.ts +27 -0
  210. package/src/fonts/virtual-modules.ts +1 -57
  211. package/src/index.ts +5 -11
  212. package/src/params/resolve-schema.ts +2 -34
  213. package/src/plugin-context.ts +95 -17
  214. package/src/plugins/cache.ts +64 -17
  215. package/src/plugins/dev-server.ts +61 -9
  216. package/src/plugins/fonts.ts +21 -6
  217. package/src/plugins/routing.ts +5 -348
  218. package/src/routing/codegen.ts +3 -2
  219. package/src/routing/convention-lint.ts +4 -3
  220. package/src/routing/file-cache.ts +23 -0
  221. package/src/routing/interception.ts +2 -21
  222. package/src/routing/manifest-codegen.ts +290 -0
  223. package/src/routing/scanner.ts +5 -17
  224. package/src/routing/schema-validation.ts +2 -2
  225. package/src/schema-bridge.ts +63 -2
  226. package/src/search-params/define.ts +34 -2
  227. package/src/search-params/wrappers.ts +21 -3
  228. package/src/segment-params/define.ts +2 -62
  229. package/src/server/access-gate.tsx +8 -0
  230. package/src/server/actions.ts +8 -31
  231. package/src/server/build-manifest.ts +0 -56
  232. package/src/server/canonicalize.ts +6 -3
  233. package/src/server/deny-renderer.ts +6 -0
  234. package/src/server/html-injector-core.ts +19 -0
  235. package/src/server/html-injectors.ts +14 -4
  236. package/src/server/internal.ts +1 -18
  237. package/src/server/node-stream-transforms.ts +5 -72
  238. package/src/server/render-utils.ts +31 -0
  239. package/src/server/route-element-builder.ts +77 -47
  240. package/src/server/rsc-entry/error-renderer.ts +1 -0
  241. package/src/server/rsc-entry/helpers.ts +6 -34
  242. package/src/server/rsc-entry/index.ts +11 -0
  243. package/src/server/rsc-entry/render-route.ts +7 -9
  244. package/src/server/rsc-entry/rsc-payload.ts +2 -1
  245. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  246. package/src/server/ssr-render.ts +1 -11
  247. package/src/shared/href-validation.ts +8 -0
  248. package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
  249. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
  250. package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
  251. package/dist/_chunks/define-DMVhsipD.js.map +0 -1
  252. package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
  253. package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
  254. package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
  255. package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
  256. package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
  257. package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
  258. package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
  259. package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
  260. package/dist/_chunks/walkers-Ck4Y45XN.js.map +0 -1
  261. package/dist/_chunks/warnings-CNPXaypM.js +0 -125
  262. package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
  263. package/dist/client/link-pending-store.d.ts +0 -65
  264. package/dist/client/link-pending-store.d.ts.map +0 -1
  265. package/dist/client/nav-link-store.d.ts +0 -27
  266. package/dist/client/nav-link-store.d.ts.map +0 -1
  267. package/dist/dev-tools/warnings.d.ts +0 -109
  268. package/dist/dev-tools/warnings.d.ts.map +0 -1
  269. package/dist/server/flush.d.ts +0 -74
  270. package/dist/server/flush.d.ts.map +0 -1
  271. package/src/client/link-pending-store.ts +0 -116
  272. package/src/client/nav-link-store.ts +0 -37
  273. package/src/dev-tools/warnings.ts +0 -238
  274. 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
+ }
@@ -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 (ENCODED_SEPARATOR_PATTERN.test(entry)) {
310
+ if (ENCODED_SEPARATOR_RE.test(entry)) {
323
311
  throw new Error(
324
- `Build error: directory name contains an encoded path delimiter (%%2F or %%5C).\n` +
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 (ENCODED_NULL_PATTERN.test(entry)) {
318
+ if (NULL_BYTE_RE.test(entry)) {
331
319
  throw new Error(
332
- `Build error: directory name contains an encoded null byte (%%00).\n` +
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 { readFileSync } from 'node:fs';
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 = readFileSync(schemaPath, 'utf-8');
69
+ const content = readFileCached(schemaPath);
70
70
  const schemaKeys = parseExistingSchemaKeys(content);
71
71
 
72
72
  if (schemaKeys.length === 0) return [];
@@ -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
- ? fromSchema(codecOrSchema)
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] = resolveField(key, value as ParamField);
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