@timber-js/app 0.2.0-alpha.147 → 0.2.0-alpha.149

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 (277) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
  3. package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
  4. package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
  5. package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
  6. package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
  7. package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
  8. package/dist/_chunks/{cli-schema-sync-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
  9. package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
  10. package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
  11. package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
  12. package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
  13. package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
  14. package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
  15. package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
  16. package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
  17. package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
  18. package/dist/_chunks/logger-CM0clugc.js.map +1 -0
  19. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
  20. package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
  21. package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
  22. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
  23. package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
  24. package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
  25. package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
  26. package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
  27. package/dist/_chunks/tree-match-D2l830j2.js +102 -0
  28. package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
  29. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
  30. package/dist/_chunks/{walkers-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
  31. package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
  32. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  33. package/dist/adapters/cloudflare-kv-cache.js +36 -14
  34. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  35. package/dist/adapters/compress-module.d.ts +5 -4
  36. package/dist/adapters/compress-module.d.ts.map +1 -1
  37. package/dist/adapters/nitro.d.ts +1 -3
  38. package/dist/adapters/nitro.d.ts.map +1 -1
  39. package/dist/adapters/nitro.js +14 -134
  40. package/dist/adapters/nitro.js.map +1 -1
  41. package/dist/cache/cache-api.d.ts.map +1 -1
  42. package/dist/cache/index.d.ts +3 -1
  43. package/dist/cache/index.d.ts.map +1 -1
  44. package/dist/cache/index.js +20 -235
  45. package/dist/cache/index.js.map +1 -1
  46. package/dist/cache/invalidation-epoch.d.ts +43 -0
  47. package/dist/cache/invalidation-epoch.d.ts.map +1 -0
  48. package/dist/cache/redis-handler.d.ts +13 -0
  49. package/dist/cache/redis-handler.d.ts.map +1 -1
  50. package/dist/cache/timber-cache.d.ts +17 -9
  51. package/dist/cache/timber-cache.d.ts.map +1 -1
  52. package/dist/cli-schema-sync.d.ts +21 -1
  53. package/dist/cli-schema-sync.d.ts.map +1 -1
  54. package/dist/cli.d.ts +12 -0
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +26 -31
  57. package/dist/cli.js.map +1 -1
  58. package/dist/client/browser-entry/hmr.d.ts.map +1 -1
  59. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  60. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  61. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  62. package/dist/client/history.d.ts +10 -17
  63. package/dist/client/history.d.ts.map +1 -1
  64. package/dist/client/index.js +1 -1
  65. package/dist/client/index.js.map +1 -1
  66. package/dist/client/internal.js +30 -33
  67. package/dist/client/internal.js.map +1 -1
  68. package/dist/client/navigation-api.d.ts +9 -2
  69. package/dist/client/navigation-api.d.ts.map +1 -1
  70. package/dist/client/navigation-context.d.ts +0 -11
  71. package/dist/client/navigation-context.d.ts.map +1 -1
  72. package/dist/client/navigation-root.d.ts +0 -5
  73. package/dist/client/navigation-root.d.ts.map +1 -1
  74. package/dist/client/router.d.ts.map +1 -1
  75. package/dist/client/rsc-fetch.d.ts.map +1 -1
  76. package/dist/client/use-search-params.d.ts +10 -5
  77. package/dist/client/use-search-params.d.ts.map +1 -1
  78. package/dist/codec.js +1 -1
  79. package/dist/config-validation.d.ts.map +1 -1
  80. package/dist/cookies/define-cookie.d.ts.map +1 -1
  81. package/dist/cookies/index.js +2 -4
  82. package/dist/cookies/index.js.map +1 -1
  83. package/dist/dev-tools/dev-page-shell.d.ts +1 -0
  84. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
  85. package/dist/dev-tools/hydration-overlay.d.ts +11 -0
  86. package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
  87. package/dist/dev-tools/index.d.ts +1 -2
  88. package/dist/dev-tools/index.d.ts.map +1 -1
  89. package/dist/fonts/ast-walk.d.ts +68 -0
  90. package/dist/fonts/ast-walk.d.ts.map +1 -0
  91. package/dist/fonts/ast.d.ts +23 -11
  92. package/dist/fonts/ast.d.ts.map +1 -1
  93. package/dist/fonts/bundle.d.ts +3 -1
  94. package/dist/fonts/bundle.d.ts.map +1 -1
  95. package/dist/fonts/dev-middleware.d.ts +1 -1
  96. package/dist/fonts/dev-middleware.d.ts.map +1 -1
  97. package/dist/fonts/fallbacks.d.ts +0 -7
  98. package/dist/fonts/fallbacks.d.ts.map +1 -1
  99. package/dist/fonts/google.d.ts +4 -2
  100. package/dist/fonts/google.d.ts.map +1 -1
  101. package/dist/fonts/local.d.ts +3 -3
  102. package/dist/fonts/local.d.ts.map +1 -1
  103. package/dist/fonts/paths.d.ts +43 -0
  104. package/dist/fonts/paths.d.ts.map +1 -0
  105. package/dist/fonts/pipeline.d.ts +7 -21
  106. package/dist/fonts/pipeline.d.ts.map +1 -1
  107. package/dist/fonts/transform.d.ts.map +1 -1
  108. package/dist/fonts/virtual-modules.d.ts +1 -18
  109. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +610 -406
  112. package/dist/index.js.map +1 -1
  113. package/dist/params/index.js +1 -1
  114. package/dist/params/resolve-schema.d.ts.map +1 -1
  115. package/dist/plugin-context.d.ts +28 -5
  116. package/dist/plugin-context.d.ts.map +1 -1
  117. package/dist/plugins/cache.d.ts +13 -5
  118. package/dist/plugins/cache.d.ts.map +1 -1
  119. package/dist/plugins/dev-server.d.ts.map +1 -1
  120. package/dist/plugins/fonts.d.ts +1 -2
  121. package/dist/plugins/fonts.d.ts.map +1 -1
  122. package/dist/plugins/routing.d.ts.map +1 -1
  123. package/dist/plugins/server-bundle.d.ts.map +1 -1
  124. package/dist/routing/codegen.d.ts.map +1 -1
  125. package/dist/routing/convention-lint.d.ts.map +1 -1
  126. package/dist/routing/file-cache.d.ts +8 -0
  127. package/dist/routing/file-cache.d.ts.map +1 -0
  128. package/dist/routing/index.js +2 -2
  129. package/dist/routing/interception.d.ts +0 -5
  130. package/dist/routing/interception.d.ts.map +1 -1
  131. package/dist/routing/manifest-codegen.d.ts +32 -0
  132. package/dist/routing/manifest-codegen.d.ts.map +1 -0
  133. package/dist/routing/scanner.d.ts.map +1 -1
  134. package/dist/schema-bridge.d.ts +15 -0
  135. package/dist/schema-bridge.d.ts.map +1 -1
  136. package/dist/search-params/define.d.ts.map +1 -1
  137. package/dist/search-params/index.js +7 -3
  138. package/dist/search-params/index.js.map +1 -1
  139. package/dist/search-params/wrappers.d.ts.map +1 -1
  140. package/dist/segment-params/define.d.ts.map +1 -1
  141. package/dist/segment-params/index.js +1 -1
  142. package/dist/server/access-gate.d.ts.map +1 -1
  143. package/dist/server/actions.d.ts +0 -14
  144. package/dist/server/actions.d.ts.map +1 -1
  145. package/dist/server/build-manifest.d.ts +0 -29
  146. package/dist/server/build-manifest.d.ts.map +1 -1
  147. package/dist/server/canonicalize.d.ts +10 -0
  148. package/dist/server/canonicalize.d.ts.map +1 -1
  149. package/dist/server/deny-renderer.d.ts +1 -1
  150. package/dist/server/deny-renderer.d.ts.map +1 -1
  151. package/dist/server/html-injector-core.d.ts +8 -0
  152. package/dist/server/html-injector-core.d.ts.map +1 -1
  153. package/dist/server/html-injectors.d.ts.map +1 -1
  154. package/dist/server/index.js +2 -2
  155. package/dist/server/internal.d.ts +1 -5
  156. package/dist/server/internal.d.ts.map +1 -1
  157. package/dist/server/internal.js +12 -171
  158. package/dist/server/internal.js.map +1 -1
  159. package/dist/server/node-stream-transforms.d.ts +0 -11
  160. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  161. package/dist/server/render-utils.d.ts +25 -0
  162. package/dist/server/render-utils.d.ts.map +1 -0
  163. package/dist/server/route-element-builder.d.ts +11 -25
  164. package/dist/server/route-element-builder.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  166. package/dist/server/rsc-entry/helpers.d.ts +2 -18
  167. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  168. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  169. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  170. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  171. package/dist/server/ssr-render.d.ts.map +1 -1
  172. package/dist/shared/href-validation.d.ts.map +1 -1
  173. package/dist/shims/image.d.ts +15 -15
  174. package/package.json +7 -8
  175. package/src/adapters/cloudflare-kv-cache.ts +56 -19
  176. package/src/adapters/compress-module.ts +27 -118
  177. package/src/adapters/nitro.ts +3 -41
  178. package/src/adapters/raw-imports.d.ts +5 -0
  179. package/src/cache/cache-api.ts +9 -1
  180. package/src/cache/index.ts +5 -2
  181. package/src/cache/invalidation-epoch.ts +93 -0
  182. package/src/cache/redis-handler.ts +33 -2
  183. package/src/cache/timber-cache.ts +109 -53
  184. package/src/cli-schema-sync.ts +123 -30
  185. package/src/cli.ts +33 -37
  186. package/src/client/browser-entry/hmr.ts +0 -11
  187. package/src/client/browser-entry/hydrate.ts +39 -5
  188. package/src/client/browser-entry/post-hydration.ts +6 -15
  189. package/src/client/browser-entry/router-init.ts +98 -3
  190. package/src/client/history.ts +10 -24
  191. package/src/client/navigation-api.ts +14 -9
  192. package/src/client/navigation-context.ts +1 -21
  193. package/src/client/navigation-root.tsx +0 -8
  194. package/src/client/router.ts +18 -8
  195. package/src/client/rsc-fetch.ts +7 -2
  196. package/src/client/use-search-params.ts +10 -5
  197. package/src/config-validation.ts +33 -25
  198. package/src/cookies/define-cookie.ts +2 -18
  199. package/src/dev-tools/dev-page-shell.ts +6 -2
  200. package/src/dev-tools/hydration-overlay.ts +158 -0
  201. package/src/dev-tools/index.ts +3 -16
  202. package/src/fonts/ast-walk.ts +235 -0
  203. package/src/fonts/ast.ts +91 -249
  204. package/src/fonts/bundle.ts +16 -9
  205. package/src/fonts/dev-middleware.ts +16 -5
  206. package/src/fonts/fallbacks.ts +0 -23
  207. package/src/fonts/google.ts +7 -3
  208. package/src/fonts/local.ts +7 -6
  209. package/src/fonts/paths.ts +58 -0
  210. package/src/fonts/pipeline.ts +27 -48
  211. package/src/fonts/transform.ts +27 -0
  212. package/src/fonts/virtual-modules.ts +1 -57
  213. package/src/index.ts +5 -11
  214. package/src/params/resolve-schema.ts +2 -34
  215. package/src/plugin-context.ts +95 -17
  216. package/src/plugins/cache.ts +64 -17
  217. package/src/plugins/dev-server.ts +61 -9
  218. package/src/plugins/fonts.ts +21 -6
  219. package/src/plugins/routing.ts +5 -348
  220. package/src/plugins/server-bundle.ts +1 -0
  221. package/src/routing/codegen.ts +3 -2
  222. package/src/routing/convention-lint.ts +4 -3
  223. package/src/routing/file-cache.ts +23 -0
  224. package/src/routing/interception.ts +2 -21
  225. package/src/routing/manifest-codegen.ts +290 -0
  226. package/src/routing/scanner.ts +5 -17
  227. package/src/routing/schema-validation.ts +2 -2
  228. package/src/schema-bridge.ts +63 -2
  229. package/src/search-params/define.ts +34 -2
  230. package/src/search-params/wrappers.ts +21 -3
  231. package/src/segment-params/define.ts +2 -62
  232. package/src/server/access-gate.tsx +8 -0
  233. package/src/server/actions.ts +8 -31
  234. package/src/server/build-manifest.ts +0 -56
  235. package/src/server/canonicalize.ts +6 -3
  236. package/src/server/deny-renderer.ts +6 -0
  237. package/src/server/html-injector-core.ts +19 -0
  238. package/src/server/html-injectors.ts +14 -4
  239. package/src/server/internal.ts +1 -18
  240. package/src/server/node-stream-transforms.ts +5 -72
  241. package/src/server/render-utils.ts +31 -0
  242. package/src/server/route-element-builder.ts +77 -47
  243. package/src/server/rsc-entry/error-renderer.ts +1 -0
  244. package/src/server/rsc-entry/helpers.ts +6 -34
  245. package/src/server/rsc-entry/index.ts +11 -0
  246. package/src/server/rsc-entry/render-route.ts +7 -9
  247. package/src/server/rsc-entry/rsc-payload.ts +2 -1
  248. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  249. package/src/server/ssr-render.ts +1 -11
  250. package/src/shared/href-validation.ts +8 -0
  251. package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
  252. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
  253. package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
  254. package/dist/_chunks/define-DMVhsipD.js.map +0 -1
  255. package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
  256. package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
  257. package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
  258. package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
  259. package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
  260. package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
  261. package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
  262. package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
  263. package/dist/_chunks/walkers-Ck4Y45XN.js.map +0 -1
  264. package/dist/_chunks/warnings-CNPXaypM.js +0 -125
  265. package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
  266. package/dist/client/link-pending-store.d.ts +0 -65
  267. package/dist/client/link-pending-store.d.ts.map +0 -1
  268. package/dist/client/nav-link-store.d.ts +0 -27
  269. package/dist/client/nav-link-store.d.ts.map +0 -1
  270. package/dist/dev-tools/warnings.d.ts +0 -109
  271. package/dist/dev-tools/warnings.d.ts.map +0 -1
  272. package/dist/server/flush.d.ts +0 -74
  273. package/dist/server/flush.d.ts.map +0 -1
  274. package/src/client/link-pending-store.ts +0 -116
  275. package/src/client/nav-link-store.ts +0 -37
  276. package/src/dev-tools/warnings.ts +0 -238
  277. package/src/server/flush.ts +0 -183
@@ -36,16 +36,113 @@ function walkSegments(node: SegmentNode, segments: Set<string>): void {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Replace comment bodies (and optionally string contents) with spaces,
41
+ * preserving length and newlines so every index maps 1:1 to the original.
42
+ * Lets the structural scanning below ignore `segmentParams` mentions and
43
+ * braces inside comments/strings without a TS parser (fine pre-alpha,
44
+ * see TIM-1066).
45
+ *
46
+ * With `maskStrings`, string contents are blanked too — except literal
47
+ * 'segmentParams' strings, kept so a quoted property key stays findable.
48
+ */
49
+ function maskSource(content: string, maskStrings: boolean): string {
50
+ const out = content.split('');
51
+ const n = content.length;
52
+ let i = 0;
53
+
54
+ const blank = (from: number, to: number): void => {
55
+ for (let j = from; j < to; j++) {
56
+ if (content[j] !== '\n') out[j] = ' ';
57
+ }
58
+ };
59
+
60
+ while (i < n) {
61
+ const ch = content[i];
62
+ const next = content[i + 1];
63
+ if (ch === '/' && next === '/') {
64
+ const start = i;
65
+ while (i < n && content[i] !== '\n') i++;
66
+ blank(start, i);
67
+ } else if (ch === '/' && next === '*') {
68
+ const start = i;
69
+ i += 2;
70
+ while (i < n && !(content[i] === '*' && content[i + 1] === '/')) i++;
71
+ i = Math.min(i + 2, n);
72
+ blank(start, i);
73
+ } else if (ch === '"' || ch === "'" || ch === '`') {
74
+ const start = i;
75
+ i++;
76
+ while (i < n && content[i] !== ch) {
77
+ if (content[i] === '\\') i++;
78
+ i++;
79
+ }
80
+ const end = i; // at closing quote (or n if unterminated)
81
+ if (maskStrings && content.slice(start + 1, end) !== 'segmentParams') {
82
+ blank(start + 1, end);
83
+ }
84
+ i++;
85
+ } else {
86
+ i++;
87
+ }
88
+ }
89
+ return out.join('');
90
+ }
91
+
92
+ /**
93
+ * Locate the `segmentParams: { ... }` block in a schema file.
94
+ * Returns the indices of the opening and matching closing brace, or null
95
+ * if no segmentParams property is found. Comments and strings are masked
96
+ * first, so a JSDoc example mentioning `segmentParams: { ... }` above the
97
+ * real defineSchema call is not mistaken for the block, and braces inside
98
+ * comments/strings don't skew the depth count. The search is anchored to
99
+ * the `defineSchema(` call when present, so helper objects with their own
100
+ * segmentParams property earlier in the file are skipped.
101
+ */
102
+ export function findSegmentParamsBlock(
103
+ content: string
104
+ ): { openBrace: number; closeBrace: number } | null {
105
+ const masked = maskSource(content, true);
106
+
107
+ const defineCall = /\bdefineSchema\s*\(/.exec(masked);
108
+ const keyRe = /(['"]?)segmentParams\1\s*:\s*\{/g;
109
+ keyRe.lastIndex = defineCall ? defineCall.index + defineCall[0].length : 0;
110
+ const keyMatch = keyRe.exec(masked);
111
+ if (!keyMatch) return null;
112
+
113
+ const openBrace = keyMatch.index + keyMatch[0].length - 1;
114
+ let depth = 1;
115
+ let i = openBrace + 1;
116
+ while (i < masked.length && depth > 0) {
117
+ if (masked[i] === '{') depth++;
118
+ else if (masked[i] === '}') depth--;
119
+ i++;
120
+ }
121
+ if (depth > 0) return null;
122
+ // i now points to just after the closing brace
123
+ return { openBrace, closeBrace: i - 1 };
124
+ }
125
+
39
126
  /**
40
127
  * Parse existing bracket keys from an app/schema.ts file.
41
- * Uses regex to find keys in segmentParams: { '[key]': ... } blocks.
128
+ * Scoped to the segmentParams: { ... } block — bracket keys elsewhere in
129
+ * the file (unrelated objects, comments above defineSchema) are ignored.
130
+ * Comments inside the block are masked, so a commented-out entry does not
131
+ * count as registered.
132
+ *
133
+ * The key charset matches the route classifier (segment-classify.ts):
134
+ * any character except `]`, so hyphenated params like '[my-param]' parse.
42
135
  */
43
136
  export function parseExistingSchemaKeys(content: string): string[] {
137
+ const block = findSegmentParamsBlock(content);
138
+ if (!block) return [];
139
+
140
+ const blockContent = maskSource(content, false).slice(block.openBrace, block.closeBrace);
44
141
  const keys: string[] = [];
45
142
  // Match quoted bracket keys like '[artistSlug]', '[...slug]', '[[...topic]]'
46
- const re = /['"](\[{1,2}(?:\.{3})?\w+\]{1,2})['"]\s*:/g;
143
+ const re = /['"](\[{1,2}(?:\.{3})?[^\]]+\]{1,2})['"]\s*:/g;
47
144
  let m: RegExpExecArray | null;
48
- while ((m = re.exec(content)) !== null) {
145
+ while ((m = re.exec(blockContent)) !== null) {
49
146
  keys.push(m[1]);
50
147
  }
51
148
  return keys;
@@ -121,31 +218,14 @@ function ensureCodecImport(content: string): string {
121
218
  function appendToSchema(content: string, newSegments: string[]): string {
122
219
  content = ensureCodecImport(content);
123
220
 
124
- // Find "segmentParams" key position
125
- const spIndex = content.indexOf('segmentParams');
126
- if (spIndex === -1) {
221
+ const block = findSegmentParamsBlock(content);
222
+ if (!block) {
127
223
  throw new Error(
128
224
  '[timber] Could not find segmentParams in schema file. ' +
129
225
  'Expected a defineSchema({ segmentParams: { ... } }) call.'
130
226
  );
131
227
  }
132
-
133
- // Find the opening brace after segmentParams
134
- const openBrace = content.indexOf('{', spIndex + 'segmentParams'.length);
135
- if (openBrace === -1) {
136
- throw new Error('[timber] Could not find opening brace for segmentParams.');
137
- }
138
-
139
- // Count braces to find the matching close
140
- let depth = 1;
141
- let i = openBrace + 1;
142
- while (i < content.length && depth > 0) {
143
- if (content[i] === '{') depth++;
144
- else if (content[i] === '}') depth--;
145
- i++;
146
- }
147
- // i now points to just after the closing brace
148
- const closeBrace = i - 1;
228
+ const { openBrace, closeBrace } = block;
149
229
 
150
230
  // Detect indentation from existing entries
151
231
  const existingLine = content.slice(openBrace, closeBrace).match(/^(\s+)['"]\[/m);
@@ -168,21 +248,32 @@ function appendToSchema(content: string, newSegments: string[]): string {
168
248
 
169
249
  /**
170
250
  * Remove entries from the segmentParams block whose keys match `staleSegments`.
171
- * Removes the entire line including trailing comma/whitespace.
251
+ * Removes the entire entry including trailing comma/whitespace. Scoped to
252
+ * the segmentParams block — identical keys in unrelated objects elsewhere
253
+ * in the file are left untouched.
172
254
  */
173
255
  function removeFromSchema(content: string, staleSegments: string[]): string {
256
+ const block = findSegmentParamsBlock(content);
257
+ if (!block) return content;
258
+
259
+ let blockContent = content.slice(block.openBrace, block.closeBrace);
260
+ // Key matching and the entry walk run on a comment-masked copy (same
261
+ // length, indices align) so commented-out entries are never removed.
262
+ // Both copies are spliced in sync to keep indices valid.
263
+ let blockMasked = maskSource(blockContent, false);
264
+
174
265
  for (const seg of staleSegments) {
175
266
  const escaped = seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
176
267
  const keyRe = new RegExp(`^[ \t]*['"]${escaped}['"][ \t]*:`, 'gm');
177
268
  let m: RegExpExecArray | null;
178
- while ((m = keyRe.exec(content)) !== null) {
269
+ while ((m = keyRe.exec(blockMasked)) !== null) {
179
270
  const start = m.index;
180
271
  let i = m.index + m[0].length;
181
272
  let depth = 0;
182
273
  let inString: string | null = null;
183
274
 
184
- while (i < content.length) {
185
- const ch = content[i];
275
+ while (i < blockMasked.length) {
276
+ const ch = blockMasked[i];
186
277
  if (inString) {
187
278
  if (ch === '\\') {
188
279
  i++;
@@ -205,13 +296,15 @@ function removeFromSchema(content: string, staleSegments: string[]): string {
205
296
  }
206
297
 
207
298
  // Consume trailing newline
208
- if (i < content.length && content[i] === '\n') i++;
299
+ if (i < blockMasked.length && blockMasked[i] === '\n') i++;
209
300
 
210
- content = content.slice(0, start) + content.slice(i);
301
+ blockContent = blockContent.slice(0, start) + blockContent.slice(i);
302
+ blockMasked = blockMasked.slice(0, start) + blockMasked.slice(i);
211
303
  keyRe.lastIndex = start;
212
304
  }
213
305
  }
214
- return content;
306
+
307
+ return content.slice(0, block.openBrace) + blockContent + content.slice(block.closeBrace);
215
308
  }
216
309
 
217
310
  export interface SchemaSyncResult {
package/src/cli.ts CHANGED
@@ -17,6 +17,8 @@ type Command = (typeof COMMANDS)[number];
17
17
  export interface ParsedArgs {
18
18
  command: Command;
19
19
  config: string | undefined;
20
+ /** Positional arguments after the command (used by `timber schema sync`). */
21
+ rest: string[];
20
22
  }
21
23
 
22
24
  export interface CommandOptions {
@@ -26,6 +28,11 @@ export interface CommandOptions {
26
28
  /**
27
29
  * Parse CLI arguments into a structured command + options.
28
30
  * Accepts: timber <command> [--config|-c <path>]
31
+ *
32
+ * Unknown flags are an error, not silently ignored — `timber dev --port 4000`
33
+ * starting on the default port would contradict fail-loudly. The CLI surface
34
+ * is intentionally --config-only (design/11-platform.md §CLI); port selection
35
+ * goes through the PORT env var or server.port in vite.config.ts.
29
36
  */
30
37
  export function parseArgs(args: string[]): ParsedArgs {
31
38
  if (args.length === 0) {
@@ -40,16 +47,31 @@ export function parseArgs(args: string[]): ParsedArgs {
40
47
  }
41
48
 
42
49
  let config: string | undefined;
50
+ const rest: string[] = [];
43
51
  for (let i = 1; i < args.length; i++) {
44
- if (args[i] === '--config' || args[i] === '-c') {
52
+ const arg = args[i];
53
+ if (arg === '--config' || arg === '-c') {
45
54
  config = args[++i];
46
55
  if (!config) {
47
56
  throw new Error('--config requires a path argument');
48
57
  }
58
+ } else if (arg.startsWith('-')) {
59
+ const portHint =
60
+ arg === '--port' || arg === '-p'
61
+ ? ' To change the port, set the PORT environment variable (PORT=4000 timber dev) ' +
62
+ 'or server.port in vite.config.ts.'
63
+ : '';
64
+ throw new Error(
65
+ `Unknown option: ${arg}. The timber CLI only accepts --config <path>.${portHint}`
66
+ );
67
+ } else if (command === 'schema') {
68
+ rest.push(arg);
69
+ } else {
70
+ throw new Error(`Unexpected argument: ${arg}. Usage: timber ${command} [--config <path>]`);
49
71
  }
50
72
  }
51
73
 
52
- return { command: command as Command, config };
74
+ return { command: command as Command, config, rest };
53
75
  }
54
76
 
55
77
  // ─── Command Implementations ─────────────────────────────────────────────────
@@ -105,51 +127,25 @@ export function resolvePreviewStrategy(
105
127
  return 'vite';
106
128
  }
107
129
 
108
- /**
109
- * Load timber.config.ts from the project root.
110
- * Returns the config object with adapter, output, etc.
111
- * Returns null if no config file is found.
112
- */
113
- async function loadTimberConfig(root: string): Promise<{
114
- adapter?: import('./adapters/types').TimberPlatformAdapter;
115
- output?: string;
116
- buildDir?: string;
117
- } | null> {
118
- const { existsSync } = await import('node:fs');
119
- const { join } = await import('node:path');
120
- const { pathToFileURL } = await import('node:url');
121
-
122
- const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
123
-
124
- for (const name of configNames) {
125
- const configPath = join(root, name);
126
- if (existsSync(configPath)) {
127
- // Use Vite's built-in config loading to handle TypeScript
128
- const mod = await import(pathToFileURL(configPath).href);
129
- return mod.default ?? mod;
130
- }
131
- }
132
- return null;
133
- }
134
-
135
130
  /**
136
131
  * Serve the production build for local testing.
137
132
  * If the adapter provides a preview() method, it takes priority.
138
133
  * Otherwise falls back to Vite's built-in preview server.
134
+ *
135
+ * Uses the same loadTimberConfigFile loader as dev/build — one loader,
136
+ * one set of module semantics. A config file that exists but fails to
137
+ * load throws (naming the file) instead of silently degrading to plain
138
+ * Vite preview. See TIM-1066.
139
139
  */
140
140
  export async function runPreview(options: CommandOptions, _deps?: ViteDeps): Promise<void> {
141
- const { join, resolve } = await import('node:path');
141
+ const { loadTimberConfigFile, resolveBuildDir } = await import('./plugin-context.js');
142
142
 
143
- // Try to load timber config for adapter-specific preview
144
143
  const root = process.cwd();
145
- const config = await loadTimberConfig(root).catch(() => null);
144
+ const config = loadTimberConfigFile(root);
146
145
  const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;
147
146
 
148
147
  if (resolvePreviewStrategy(adapter) === 'adapter') {
149
- // Resolve build directory: timber config > default .timber/dist
150
- const buildDir = config?.buildDir
151
- ? resolve(root, config.buildDir)
152
- : join(root, '.timber', 'dist');
148
+ const buildDir = resolveBuildDir(root, config?.buildDir);
153
149
  const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };
154
150
  await adapter!.preview!(timberConfig, buildDir);
155
151
  return;
@@ -259,7 +255,7 @@ async function main(): Promise<void> {
259
255
  await runCheck(options);
260
256
  break;
261
257
  case 'schema':
262
- await runSchema(process.argv.slice(3));
258
+ await runSchema(parsed.rest);
263
259
  break;
264
260
  }
265
261
  }
@@ -57,17 +57,6 @@ export function setupHmr(router: RouterInstance): void {
57
57
  });
58
58
  });
59
59
 
60
- // Listen for dev warnings forwarded from the server via WebSocket.
61
- // See dev-warnings.ts — emitOnce() sends these via server.hot.send().
62
- hot.on('timber:dev-warning', (data: unknown) => {
63
- const warning = data as { level: string; message: string };
64
- if (warning.level === 'error') {
65
- console.error(warning.message);
66
- } else {
67
- console.warn(warning.message);
68
- }
69
- });
70
-
71
60
  // Listen for server console logs forwarded via WebSocket.
72
61
  // Replays them in the browser console with a [SERVER] prefix
73
62
  // so developers can see server output without switching to the terminal.
@@ -33,6 +33,44 @@ import type { TopLoaderConfig } from '../top-loader.js';
33
33
 
34
34
  import type { RscStreamResult } from './rsc-stream.js';
35
35
 
36
+ let showHydrationError: ((error: unknown) => void) | null = null;
37
+ let isHydrationError: ((error: unknown) => boolean) | null = null;
38
+ let pendingHydrationErrors: unknown[] = [];
39
+
40
+ // React 19 emits attribute-only hydration mismatches via console.error
41
+ // with format substitution: console.error(fmt, link, diff). We must
42
+ // intercept before hydrateRoot() to catch errors during initial hydration.
43
+ const ATTR_MISMATCH_RE =
44
+ /A tree hydrated but some attributes of the server rendered HTML didn't match/;
45
+ if (process.env.NODE_ENV !== 'production') {
46
+ const origConsoleError = console.error;
47
+ console.error = (...args: unknown[]) => {
48
+ origConsoleError.apply(console, args);
49
+ if (typeof args[0] === 'string' && ATTR_MISMATCH_RE.test(args[0])) {
50
+ const message = args
51
+ .slice(1)
52
+ .reduce<string>((s, arg) => s.replace('%s', String(arg)), args[0] as string);
53
+ queueOrShowHydrationError(new Error(message));
54
+ }
55
+ };
56
+ import('../../dev-tools/hydration-overlay.js').then((m) => {
57
+ showHydrationError = m.showHydrationError;
58
+ isHydrationError = m.isHydrationError;
59
+ for (const err of pendingHydrationErrors) {
60
+ if (m.isHydrationError(err)) m.showHydrationError(err);
61
+ }
62
+ pendingHydrationErrors = [];
63
+ });
64
+ }
65
+
66
+ function queueOrShowHydrationError(error: unknown): void {
67
+ if (showHydrationError && isHydrationError) {
68
+ if (isHydrationError(error)) showHydrationError(error);
69
+ } else {
70
+ pendingHydrationErrors.push(error);
71
+ }
72
+ }
73
+
36
74
  interface HydrateOptions {
37
75
  /** RSC stream result (null if no inlined payload) */
38
76
  rscResult: RscStreamResult | null;
@@ -116,13 +154,9 @@ export function hydrateApp({ rscResult, config }: HydrateOptions): void {
116
154
  // the shell is flushed). React replays the error during hydration
117
155
  // but the server HTML is already correct — no recovery needed.
118
156
  onRecoverableError(error: unknown) {
119
- // Suppress errors during page unload (refresh/navigate away).
120
- // The aborted stream causes incomplete HTML which React flags
121
- // as a recoverable error — but the page is being replaced.
122
157
  if (isPageUnloading()) return;
123
- // Only log in dev — in production these are expected for
124
- // deny() inside Suspense and streaming error boundaries.
125
158
  if (process.env.NODE_ENV === 'development') {
159
+ queueOrShowHydrationError(error);
126
160
  console.debug('[timber] Hydration recoverable error:', error);
127
161
  }
128
162
  },
@@ -11,9 +11,8 @@
11
11
  * See design/19-client-navigation.md §"History Stack"
12
12
  */
13
13
 
14
- import { setCurrentParams } from '#client-internal';
15
14
  import type { RouterInstance } from '#client-internal';
16
- import { setNavigationState } from '../navigation-context.js';
15
+ import { getNavigationState } from '../navigation-context.js';
17
16
  import type { NavigationApiController } from '../navigation-api.js';
18
17
  import { getScrollY } from './scroll.js';
19
18
 
@@ -35,9 +34,14 @@ export function setupPostHydration({
35
34
  // Store the initial page in the history stack so back-button works
36
35
  // after the first navigation. We store the decoded RSC element so
37
36
  // back navigation can replay it instantly without a server fetch.
37
+ // Params were captured from the server-embedded __timber_params by
38
+ // runPreHydration() — without them on the entry, the cached replay on
39
+ // back-navigation would normalize params to {} and wipe every
40
+ // useSegmentParams() consumer on the initial page (TIM-1037).
38
41
  router.historyStack.push(window.location.pathname + window.location.search, {
39
42
  payload: initialElement,
40
43
  headElements: null, // SSR already set the correct head
44
+ params: getNavigationState().params,
41
45
  });
42
46
 
43
47
  // Initialize scroll state for the initial entry.
@@ -58,19 +62,6 @@ export function setupPostHydration({
58
62
  delete (self as unknown as Record<string, unknown>).__timber_segments;
59
63
  }
60
64
 
61
- // If the hydration path was skipped (no RSC payload), populate the
62
- // fallback params from server embed here.
63
- const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
64
- if (lateTimberParams && typeof lateTimberParams === 'object') {
65
- setCurrentParams(lateTimberParams as Record<string, string | string[]>);
66
- setNavigationState({
67
- params: lateTimberParams as Record<string, string | string[]>,
68
- pathname: window.location.pathname,
69
- search: window.location.search,
70
- });
71
- delete (self as unknown as Record<string, unknown>).__timber_params;
72
- }
73
-
74
65
  // Register popstate handler for back/forward navigation.
75
66
  // When Navigation API is active, the navigate event covers traversals —
76
67
  // popstate is a no-op. When unavailable, popstate handles back/forward.
@@ -14,7 +14,11 @@ import type { RouterDeps, RouterInstance } from '#client-internal';
14
14
  import type { NavigationState } from '#client-internal';
15
15
  import { applyHeadElements } from '../head.js';
16
16
  import { TimberNuqsAdapter } from '../nuqs-adapter.js';
17
- import { NavigationProvider } from '../navigation-context.js';
17
+ import {
18
+ NavigationProvider,
19
+ getNavigationState,
20
+ setNavigationState,
21
+ } from '../navigation-context.js';
18
22
  import { transitionRender, navigateTransition } from '../navigation-root.js';
19
23
  import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.js';
20
24
  import {
@@ -54,10 +58,96 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
54
58
  // very first client navigation (before any wrapPayload call).
55
59
  let currentPayload: unknown = options?.initialElement ?? null;
56
60
 
61
+ // ─── History API Patch ──────────────────────────────────────────
62
+ // Patch pushState/replaceState to detect external URL changes (nuqs
63
+ // shallow updates, replaceUrl, third-party libraries). When the router
64
+ // itself calls pushState/replaceState, it sets a flag so the patch
65
+ // skips the sync — the router already updates NavigationContext.
66
+ //
67
+ // On Navigation API browsers, the navigate event catches most URL
68
+ // changes. The patch is defense-in-depth and the primary mechanism
69
+ // for History API-only browsers (older Safari/Firefox).
70
+ const ROUTER_HISTORY_FLAG = Symbol.for('__timber_router_history_update');
71
+ const gFlags = globalThis as Record<symbol, boolean>;
72
+
73
+ const originalPushState = window.history.pushState.bind(window.history);
74
+ const originalReplaceState = window.history.replaceState.bind(window.history);
75
+
76
+ /**
77
+ * Sync NavigationContext.search after a shallow URL update. Called by
78
+ * the Navigation API shallow handler and the History API patch.
79
+ *
80
+ * @param search — the new search string. The Navigation API path passes
81
+ * this from event.destination.url (window.location hasn't committed
82
+ * yet when the navigate listener fires). The History API patch omits
83
+ * it, reading window.location.search (already committed by the time
84
+ * the patched pushState/replaceState returns).
85
+ *
86
+ * Bails when search is unchanged (scroll saves, no-ops, dual-fire).
87
+ * Re-wraps currentPayload with updated NavigationProvider and commits
88
+ * via transitionRender (startTransition — non-blocking).
89
+ */
90
+ function syncShallowSearch(search?: string): void {
91
+ const currentSearch = search ?? window.location.search;
92
+ const navState = getNavigationState();
93
+ if (currentSearch === navState.search) return;
94
+ if (!currentPayload) return;
95
+
96
+ const newNavState: NavigationState = {
97
+ params: navState.params,
98
+ pathname: navState.pathname,
99
+ search: currentSearch,
100
+ };
101
+ setNavigationState(newNavState);
102
+
103
+ const withNav = createElement(
104
+ NavigationProvider,
105
+ { value: newNavState },
106
+ currentPayload as React.ReactNode
107
+ );
108
+ const withUpdates = createElement(
109
+ SegmentUpdateContext.Provider,
110
+ { value: EMPTY_SEGMENT_UPDATES },
111
+ withNav
112
+ );
113
+ const wrapped = createElement(TimberNuqsAdapter, null, withUpdates);
114
+ transitionRender(wrapped);
115
+ }
116
+
117
+ window.history.pushState = function (
118
+ data: unknown,
119
+ unused: string,
120
+ url?: string | URL | null
121
+ ): void {
122
+ originalPushState(data, unused, url);
123
+ if (!gFlags[ROUTER_HISTORY_FLAG]) {
124
+ syncShallowSearch();
125
+ }
126
+ };
127
+
128
+ window.history.replaceState = function (
129
+ data: unknown,
130
+ unused: string,
131
+ url?: string | URL | null
132
+ ): void {
133
+ originalReplaceState(data, unused, url);
134
+ if (!gFlags[ROUTER_HISTORY_FLAG]) {
135
+ syncShallowSearch();
136
+ }
137
+ };
138
+
57
139
  const deps: RouterDeps = {
58
140
  fetch: (url, init) => window.fetch(url, init),
59
- pushState: (data, unused, url) => window.history.pushState(data, unused, url),
60
- replaceState: (data, unused, url) => window.history.replaceState(data, unused, url),
141
+ pushState: (data, unused, url) => {
142
+ gFlags[ROUTER_HISTORY_FLAG] = true;
143
+ window.history.pushState(data, unused, url);
144
+ gFlags[ROUTER_HISTORY_FLAG] = false;
145
+ },
146
+ replaceState: (data, unused, url) => {
147
+ gFlags[ROUTER_HISTORY_FLAG] = true;
148
+ window.history.replaceState(data, unused, url);
149
+ gFlags[ROUTER_HISTORY_FLAG] = false;
150
+ },
61
151
  navigationApiActive: useNavApi,
62
152
  scrollTo: (x, y) => {
63
153
  // Scroll the document viewport.
@@ -94,6 +184,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
94
184
  // navState is passed explicitly by the router — no temporal coupling
95
185
  // with getNavigationState().
96
186
  renderRoot: (element: unknown, navState: NavigationState) => {
187
+ currentPayload = element;
97
188
  const withNav = createElement(
98
189
  NavigationProvider,
99
190
  { value: navState },
@@ -183,6 +274,10 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
183
274
  // Back/forward — delegate to the router's popstate handler.
184
275
  await router.handlePopState(url, scrollY, signal);
185
276
  },
277
+ onShallowNavigate: (destinationUrl: string) => {
278
+ const search = new URL(destinationUrl).search;
279
+ syncShallowSearch(search);
280
+ },
186
281
  });
187
282
 
188
283
  // Wire the router-navigating flag into RouterDeps.
@@ -10,7 +10,12 @@ export interface HistoryEntry {
10
10
  payload: unknown;
11
11
  /** Resolved head elements for this page (title, meta tags). Null for SSR'd initial page. */
12
12
  headElements?: HeadElement[] | null;
13
- /** Route params for this page (for useParams). Null for SSR'd initial page. */
13
+ /**
14
+ * Route params for this page (for useSegmentParams). Every entry that can
15
+ * be replayed must carry them — the initial SSR entry gets them from the
16
+ * server-embedded __timber_params, navigation entries from X-Timber-Params,
17
+ * and revalidation overwrites preserve the current state (TIM-1037).
18
+ */
14
19
  params?: Record<string, string | string[]> | null;
15
20
  }
16
21
 
@@ -23,13 +28,8 @@ export interface HistoryEntry {
23
28
  * On forward navigation, the new page's payload is pushed onto the stack.
24
29
  * On popstate, the cached payload is replayed instantly.
25
30
  *
26
- * Supports two keying modes:
27
- * - **URL-keyed** (default): entries keyed by pathname + search.
28
- * Used with the History API fallback.
29
- * - **Entry-key + URL**: when the Navigation API is available,
30
- * entries can also be stored by Navigation entry key for
31
- * disambiguation of duplicate URLs in the history stack.
32
- * Falls back to URL lookup when entry key is not found.
31
+ * Entries are keyed by pathname + search. Used with the History API
32
+ * fallback and the Navigation API.
33
33
  *
34
34
  * Scroll positions are stored in history.state or Navigation API entry
35
35
  * state, not in this stack — see design/19-client-navigation.md §Scroll Restoration.
@@ -39,26 +39,12 @@ export interface HistoryEntry {
39
39
  */
40
40
  export class HistoryStack {
41
41
  private entries = new Map<string, HistoryEntry>();
42
- /** Entries keyed by Navigation API entry key for duplicate URL disambiguation. */
43
- private entryKeyMap = new Map<string, HistoryEntry>();
44
42
 
45
- push(url: string, entry: HistoryEntry, entryKey?: string): void {
43
+ push(url: string, entry: HistoryEntry): void {
46
44
  this.entries.set(url, entry);
47
- if (entryKey) {
48
- this.entryKeyMap.set(entryKey, entry);
49
- }
50
45
  }
51
46
 
52
- /**
53
- * Get an entry. When an entry key is provided (Navigation API),
54
- * tries the entry-key map first for accurate disambiguation of
55
- * duplicate URLs, then falls back to URL lookup.
56
- */
57
- get(url: string, entryKey?: string): HistoryEntry | undefined {
58
- if (entryKey) {
59
- const byKey = this.entryKeyMap.get(entryKey);
60
- if (byKey) return byKey;
61
- }
47
+ get(url: string): HistoryEntry | undefined {
62
48
  return this.entries.get(url);
63
49
  }
64
50