@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
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { n as setViteServer } from "./_chunks/warnings-CNPXaypM.js";
2
- import { a as isDynamicMetadataExtension, o as isMetadataRouteServePath } from "./_chunks/metadata-routes-BjepjefT.js";
3
- import { h as swallow } from "./_chunks/logger-Dt-jQaBO.js";
4
- import { c as loadTimberConfigFile, d as resolveBuildDir, f as resolveClientJavascript, l as mergeFileConfig, m as scanRoutes, o as DEFAULT_BUILD_DIR, p as createNoopTimer, s as createPluginContext, u as resolveAppDir } from "./_chunks/cli-schema-sync-CQaxupHv.js";
5
- import { a as generateRouteMap, i as collectInterceptionRewrites, r as validateSchemaAgainstRoutes, t as collectLeafRoutes } from "./_chunks/walkers-Ck4Y45XN.js";
1
+ import { c as isDynamicMetadataExtension, l as isMetadataRouteServePath, r as canonicalize } from "./_chunks/canonicalize-P41GR6tY.js";
2
+ import { t as matchUrlParts } from "./_chunks/tree-match-D2l830j2.js";
3
+ import { h as swallow } from "./_chunks/logger-CM0clugc.js";
4
+ import { s as scanRoutes } from "./_chunks/cli-schema-sync-DJDdyEae.js";
5
+ import { a as generateRouteMap, i as validateSchemaAgainstRoutes, n as collectInterceptionRewrites, o as readFileCached, t as collectLeafRoutes } from "./_chunks/walkers-DvWDj453.js";
6
+ import { a as mergeFileConfig, c as resolveClientJavascript, i as loadTimberConfigFile, o as resolveAppDir, r as createPluginContext, s as resolveBuildDir, u as createNoopTimer } from "./_chunks/plugin-context-GfDWsdd2.js";
6
7
  import { t as fnv1aHash } from "./_chunks/fast-hash-D6hIVt1Y.js";
7
8
  import { t as formatSize } from "./_chunks/format-CfwjgPz9.js";
8
- import { basename, dirname, extname, join, normalize, relative, resolve } from "node:path";
9
+ import { basename, dirname, extname, join, normalize, relative, resolve, win32 } from "node:path";
9
10
  import { builtinModules, createRequire } from "node:module";
10
11
  import react, { reactCompilerPreset } from "@vitejs/plugin-react";
11
12
  import { existsSync, readFileSync } from "node:fs";
@@ -616,7 +617,7 @@ function extractHmrOptions(config) {
616
617
  const opts = {
617
618
  protocol: hmrOpts.protocol,
618
619
  host: hmrOpts.host,
619
- port: hmrOpts.port,
620
+ port: hmrOpts.clientPort || hmrOpts.port,
620
621
  path: hmrOpts.path,
621
622
  token: config.webSocketToken
622
623
  };
@@ -1152,29 +1153,6 @@ function validateConfig(config) {
1152
1153
  value: config.sitemap.defaultPriority
1153
1154
  });
1154
1155
  }
1155
- const knownKeys = new Set([
1156
- "output",
1157
- "debug",
1158
- "clientJavascript",
1159
- "adapter",
1160
- "cacheHandler",
1161
- "allowedOrigins",
1162
- "csrf",
1163
- "limits",
1164
- "pageExtensions",
1165
- "slowRequestMs",
1166
- "renderTimeoutMs",
1167
- "devBrowserLogs",
1168
- "dev",
1169
- "serverTiming",
1170
- "appDir",
1171
- "mdx",
1172
- "actionEncryption",
1173
- "reactCompiler",
1174
- "sitemap",
1175
- "buildDir",
1176
- "topLoader"
1177
- ]);
1178
1156
  for (const key of Object.keys(config)) if (!knownKeys.has(key)) errors.push({
1179
1157
  field: key,
1180
1158
  message: `Unknown config option: "${key}".`,
@@ -1245,8 +1223,33 @@ function formatMissingPeers(results) {
1245
1223
  lines.push(` ${BOLD$1}pnpm add ${names.join(" ")}${RESET$1}`);
1246
1224
  return lines.join("\n");
1247
1225
  }
1248
- var RED$1, BOLD$1, DIM$1, RESET$1, VIRTUAL_MODULE_NAMES, REQUIRED_PEERS;
1226
+ var knownKeys, RED$1, BOLD$1, DIM$1, RESET$1, VIRTUAL_MODULE_NAMES, REQUIRED_PEERS;
1249
1227
  var init_config_validation = __esmMin((() => {
1228
+ knownKeys = new Set(Object.keys({
1229
+ output: true,
1230
+ debug: true,
1231
+ clientJavascript: true,
1232
+ adapter: true,
1233
+ cacheHandler: true,
1234
+ allowedOrigins: true,
1235
+ csrf: true,
1236
+ limits: true,
1237
+ actions: true,
1238
+ forms: true,
1239
+ pageExtensions: true,
1240
+ slowRequestMs: true,
1241
+ renderTimeoutMs: true,
1242
+ devBrowserLogs: true,
1243
+ dev: true,
1244
+ serverTiming: true,
1245
+ appDir: true,
1246
+ mdx: true,
1247
+ actionEncryption: true,
1248
+ reactCompiler: true,
1249
+ sitemap: true,
1250
+ buildDir: true,
1251
+ topLoader: true
1252
+ }));
1250
1253
  RED$1 = "\x1B[31m";
1251
1254
  BOLD$1 = "\x1B[1m";
1252
1255
  DIM$1 = "\x1B[2m";
@@ -1433,9 +1436,8 @@ function timberDevServer(ctx) {
1433
1436
  configureServer(server) {
1434
1437
  const configPaths = CONFIG_FILE_NAMES.map((name) => join(ctx.root, name));
1435
1438
  server.watcher.add(configPaths);
1436
- setViteServer(server);
1437
1439
  listenForClientErrors(server, ctx.root);
1438
- server.middlewares.use(createTimberMiddleware(server, ctx.root));
1440
+ server.middlewares.use(createTimberMiddleware(server, ctx));
1439
1441
  if (ctx.holdingServer) {
1440
1442
  const originalListen = server.listen.bind(server);
1441
1443
  const holdingRef = ctx.holdingServer;
@@ -1463,7 +1465,8 @@ function timberDevServer(ctx) {
1463
1465
  * For non-route requests (assets, Vite internals, HMR), the middleware
1464
1466
  * calls next() to let Vite handle them.
1465
1467
  */
1466
- function createTimberMiddleware(server, projectRoot) {
1468
+ function createTimberMiddleware(server, ctx) {
1469
+ const projectRoot = ctx.root;
1467
1470
  const hmrOptions = extractHmrOptions(server.config);
1468
1471
  return async (req, res, next) => {
1469
1472
  const url = req.url;
@@ -1475,7 +1478,7 @@ function createTimberMiddleware(server, projectRoot) {
1475
1478
  next();
1476
1479
  return;
1477
1480
  }
1478
- if (isAssetRequest(url) && !isMetadataRouteServePath(url)) {
1481
+ if (isAssetRequest(url) && !isMetadataRouteServePath(url) && !isRouteOwnedAsset(ctx.routeTree, server, url)) {
1479
1482
  next();
1480
1483
  return;
1481
1484
  }
@@ -1658,6 +1661,40 @@ function isAssetRequest(url) {
1658
1661
  return ASSET_EXTENSIONS.test(url);
1659
1662
  }
1660
1663
  /**
1664
+ * Check whether an asset-extension URL belongs to a scanned route rather
1665
+ * than a static file.
1666
+ *
1667
+ * Route handlers may live at paths ending in asset-like extensions
1668
+ * (app/feed.json/route.ts → /feed.json), which the asset bypass would
1669
+ * otherwise hand to Vite — 404ing in dev while working in production.
1670
+ * Uses the same canonicalization and tree walker as the runtime matcher
1671
+ * so dev classification agrees with what the pipeline would match.
1672
+ *
1673
+ * A file that actually exists on disk wins over the route match: the
1674
+ * production server serves public/ statics before the RSC handler (see
1675
+ * adapters/nitro.ts), and Vite serves root-relative source files (e.g.
1676
+ * /app/global.css) in dev. Without this, a broad catch-all route like
1677
+ * app/[...slug] would shadow every static asset. Joining the canonical
1678
+ * pathname is traversal-safe — canonicalize() resolves `..` segments and
1679
+ * rejects root escapes and encoded separators.
1680
+ *
1681
+ * Reads ctx.routeTree per request — the routing plugin rescans it on
1682
+ * file add/unlink/change, so newly created routes are picked up live.
1683
+ */
1684
+ function isRouteOwnedAsset(routeTree, server, url) {
1685
+ if (!routeTree) return false;
1686
+ const rawPathname = url.split("?")[0];
1687
+ const result = canonicalize(rawPathname);
1688
+ if (!result.ok) return false;
1689
+ const parts = result.pathname === "/" ? [] : result.pathname.slice(1).split("/");
1690
+ if (matchUrlParts(routeTree.root, parts) === null) return false;
1691
+ const relativePath = result.pathname.slice(1);
1692
+ const { publicDir, root } = server.config;
1693
+ if (publicDir && existsSync(join(publicDir, relativePath))) return false;
1694
+ if (existsSync(join(root, relativePath))) return false;
1695
+ return true;
1696
+ }
1697
+ /**
1661
1698
  * Listen for client-side errors forwarded from the browser via HMR.
1662
1699
  *
1663
1700
  * The browser entry catches uncaught errors and unhandled rejections,
@@ -2132,6 +2169,208 @@ async function writeSegmentTypes(routeTree, appDir, root) {
2132
2169
  await Promise.all(writes);
2133
2170
  }
2134
2171
  //#endregion
2172
+ //#region src/routing/manifest-codegen.ts
2173
+ /**
2174
+ * Route manifest codegen — generates virtual module source for the route
2175
+ * manifest, search-params registry, and global schema.
2176
+ *
2177
+ * Extracted from plugins/routing.ts to keep it under the 500-line budget.
2178
+ * See design/18-build-system.md §"Virtual Modules".
2179
+ *
2180
+ * Task: TIM-1071 item 8
2181
+ */
2182
+ var SINGLE_FILE_KEYS = [
2183
+ "page",
2184
+ "layout",
2185
+ "middleware",
2186
+ "access",
2187
+ "route",
2188
+ "error",
2189
+ "default",
2190
+ "denied",
2191
+ "params"
2192
+ ];
2193
+ var RECORD_FILE_KEYS = [
2194
+ "statusFiles",
2195
+ "jsonStatusFiles",
2196
+ "legacyStatusFiles",
2197
+ "metadataRoutes"
2198
+ ];
2199
+ var SCHEMA_EXTENSIONS = [
2200
+ ".ts",
2201
+ ".tsx",
2202
+ ".js",
2203
+ ".jsx"
2204
+ ];
2205
+ /**
2206
+ * Generate the virtual module source for the route manifest.
2207
+ *
2208
+ * The output is a default-exported object containing the serialized route tree.
2209
+ * All file references use absolute paths (required for virtual modules).
2210
+ */
2211
+ function generateManifestModule(tree, viteRoot) {
2212
+ const imports = [];
2213
+ let importIndex = 0;
2214
+ function addImport(file) {
2215
+ const varName = `_route${importIndex++}`;
2216
+ imports.push(`const ${varName} = () => import("${file.filePath}");`);
2217
+ return varName;
2218
+ }
2219
+ function serializeRouteFile(key, file, indent) {
2220
+ return `${indent}${key}: { load: ${addImport(file)}, filePath: ${JSON.stringify(file.filePath)} },`;
2221
+ }
2222
+ function serializeRouteFileRecord(key, record, indent) {
2223
+ const entries = Object.entries(record);
2224
+ if (entries.length === 0) return null;
2225
+ return `${indent}${key}: {\n${entries.map(([name, file]) => {
2226
+ const v = addImport(file);
2227
+ return `${indent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`;
2228
+ }).join(",\n")}\n${indent}},`;
2229
+ }
2230
+ function serializeNode(node, indent) {
2231
+ const nextIndent = indent + " ";
2232
+ const parts = [];
2233
+ parts.push(`${nextIndent}segmentName: ${JSON.stringify(node.segmentName)},`);
2234
+ parts.push(`${nextIndent}segmentType: ${JSON.stringify(node.segmentType)},`);
2235
+ parts.push(`${nextIndent}urlPath: ${JSON.stringify(node.urlPath)},`);
2236
+ if (node.paramName) parts.push(`${nextIndent}paramName: ${JSON.stringify(node.paramName)},`);
2237
+ if (node.interceptionMarker) parts.push(`${nextIndent}interceptionMarker: ${JSON.stringify(node.interceptionMarker)},`);
2238
+ if (node.interceptedSegmentName) parts.push(`${nextIndent}interceptedSegmentName: ${JSON.stringify(node.interceptedSegmentName)},`);
2239
+ for (const key of SINGLE_FILE_KEYS) {
2240
+ const file = node[key];
2241
+ if (file) parts.push(serializeRouteFile(key, file, nextIndent));
2242
+ }
2243
+ for (const key of RECORD_FILE_KEYS) {
2244
+ const record = node[key];
2245
+ if (record) {
2246
+ const line = serializeRouteFileRecord(key, record, nextIndent);
2247
+ if (line) parts.push(line);
2248
+ }
2249
+ }
2250
+ if (node.children.length > 0) {
2251
+ const childNodes = node.children.map((c) => serializeNode(c, nextIndent));
2252
+ parts.push(`${nextIndent}children: [\n${childNodes.join(",\n")}\n${nextIndent}],`);
2253
+ } else parts.push(`${nextIndent}children: [],`);
2254
+ const slotKeys = Object.keys(node.slots);
2255
+ if (slotKeys.length > 0) {
2256
+ const slotEntries = [];
2257
+ for (const slotName of slotKeys) {
2258
+ const slotNode = node.slots[slotName];
2259
+ slotEntries.push(`${nextIndent} ${JSON.stringify(slotName)}: ${serializeNode(slotNode, nextIndent + " ")}`);
2260
+ }
2261
+ parts.push(`${nextIndent}slots: {\n${slotEntries.join(",\n")}\n${nextIndent}},`);
2262
+ } else parts.push(`${nextIndent}slots: {},`);
2263
+ return `${indent}{\n${parts.join("\n")}\n${indent}}`;
2264
+ }
2265
+ const rootSerialized = serializeNode(tree.root, " ");
2266
+ let proxyLine = "";
2267
+ if (tree.proxy) proxyLine = ` proxy: { load: ${addImport(tree.proxy)}, filePath: ${JSON.stringify(tree.proxy.filePath)} },`;
2268
+ let globalErrorLine = "";
2269
+ if (tree.globalError) globalErrorLine = ` globalError: { load: ${addImport(tree.globalError)}, filePath: ${JSON.stringify(tree.globalError.filePath)} },`;
2270
+ const rewrites = collectInterceptionRewrites(tree.root);
2271
+ const rewritesLine = rewrites.length > 0 ? ` interceptionRewrites: ${JSON.stringify(rewrites.map((r) => ({
2272
+ interceptedPattern: r.interceptedPattern,
2273
+ interceptingPrefix: r.interceptingPrefix
2274
+ })))},` : "";
2275
+ return [
2276
+ "// Auto-generated route manifest — do not edit.",
2277
+ "// Generated by timber-routing plugin.",
2278
+ "",
2279
+ ...imports,
2280
+ "",
2281
+ "const manifest = {",
2282
+ ` viteRoot: ${JSON.stringify(viteRoot)},`,
2283
+ proxyLine,
2284
+ globalErrorLine,
2285
+ rewritesLine,
2286
+ ` root: ${rootSerialized},`,
2287
+ "};",
2288
+ "",
2289
+ "export default manifest;"
2290
+ ].filter((line) => line !== void 0 && line !== "").join("\n");
2291
+ }
2292
+ /**
2293
+ * Walk the route tree and collect every page that exports a
2294
+ * `searchParams` definition, then emit a module that statically imports
2295
+ * each export and registers it into the shared search-params registry.
2296
+ */
2297
+ function generateSearchParamsRegistryModule(tree) {
2298
+ const entries = [];
2299
+ function walk(node) {
2300
+ if (node.page) {
2301
+ if (node.params && fileHasSearchParamsExport(node.params.filePath)) entries.push({
2302
+ urlPath: node.urlPath,
2303
+ filePath: node.params.filePath
2304
+ });
2305
+ else if (fileHasSearchParamsExport(node.page.filePath)) entries.push({
2306
+ urlPath: node.urlPath,
2307
+ filePath: node.page.filePath
2308
+ });
2309
+ }
2310
+ for (const child of node.children) walk(child);
2311
+ for (const slot of Object.values(node.slots)) walk(slot);
2312
+ }
2313
+ walk(tree.root);
2314
+ entries.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
2315
+ const lines = [];
2316
+ lines.push("// Auto-generated search-params registry — do not edit.");
2317
+ lines.push("// Generated by timber-routing plugin (TIM-830).");
2318
+ lines.push("");
2319
+ lines.push("import { registerSearchParams } from '@timber-js/app/search-params';");
2320
+ lines.push("");
2321
+ entries.forEach((entry, i) => {
2322
+ lines.push(`import { searchParams as r${i} } from ${JSON.stringify(entry.filePath)};`);
2323
+ });
2324
+ lines.push("");
2325
+ entries.forEach((entry, i) => {
2326
+ lines.push(`registerSearchParams(${JSON.stringify(entry.urlPath)}, r${i});`);
2327
+ });
2328
+ lines.push("");
2329
+ lines.push("export {};");
2330
+ lines.push("");
2331
+ return lines.join("\n");
2332
+ }
2333
+ function fileHasSearchParamsExport(filePath) {
2334
+ try {
2335
+ if (!existsSync(filePath)) return false;
2336
+ const content = readFileCached(filePath);
2337
+ return /export\s+(const|let|var)\s+searchParams\b/.test(content) || /export\s*\{[^}]*\bsearchParams\b[^}]*\}/.test(content);
2338
+ } catch {
2339
+ return false;
2340
+ }
2341
+ }
2342
+ /**
2343
+ * Generate the virtual:timber-schema module.
2344
+ *
2345
+ * When app/schema.{ts,tsx,js,jsx} exists, generates a module that
2346
+ * imports the schema and resolves its segmentParams codecs into a
2347
+ * bare-name codec map for runtime coercion.
2348
+ */
2349
+ function generateSchemaModule(appDir) {
2350
+ let schemaPath = null;
2351
+ for (const ext of SCHEMA_EXTENSIONS) {
2352
+ const candidate = join(appDir, `schema${ext}`);
2353
+ if (existsSync(candidate)) {
2354
+ schemaPath = candidate;
2355
+ break;
2356
+ }
2357
+ }
2358
+ if (!schemaPath) return ["// Auto-generated schema loader — no app/schema.ts found.", "export default null;"].join("\n");
2359
+ return [
2360
+ "// Auto-generated schema loader — do not edit.",
2361
+ "// Generated by timber-routing plugin (TIM-931).",
2362
+ "",
2363
+ `import schema from ${JSON.stringify(schemaPath)};`,
2364
+ `import { resolveSchemaCodecs } from '@timber-js/app/params';`,
2365
+ "",
2366
+ "const globalCodecs = schema?.segmentParams",
2367
+ " ? resolveSchemaCodecs(schema.segmentParams)",
2368
+ " : null;",
2369
+ "",
2370
+ "export default globalCodecs;"
2371
+ ].join("\n");
2372
+ }
2373
+ //#endregion
2135
2374
  //#region src/routing/convention-lint.ts
2136
2375
  /**
2137
2376
  * Convention linter — validates common misconfigurations in the route tree.
@@ -2231,7 +2470,7 @@ function checkRouteExports(node, warnings) {
2231
2470
  if (node.route) {
2232
2471
  const filePath = node.route.filePath;
2233
2472
  try {
2234
- const source = readFileSync(filePath, "utf-8");
2473
+ const source = readFileCached(filePath);
2235
2474
  if (!EXPORT_PATTERN.test(source)) {
2236
2475
  const hint = /export\s+default\b/.test(source) ? " It looks like you have a default export. route.ts uses named exports\n for HTTP methods (GET, POST, etc.), not a default export.\n" : "";
2237
2476
  warnings.push({
@@ -2297,7 +2536,7 @@ function isScriptExtension(ext) {
2297
2536
  }
2298
2537
  function checkFileDefaultExport(filePath, fileType, warnings) {
2299
2538
  try {
2300
- const source = readFileSync(filePath, "utf-8");
2539
+ const source = readFileCached(filePath);
2301
2540
  if (!DEFAULT_EXPORT_PATTERN.test(source)) warnings.push({
2302
2541
  id: `NO_DEFAULT_EXPORT:${filePath}`,
2303
2542
  summary: `${fileType}.tsx has no default export: ${filePath}`,
@@ -2620,245 +2859,6 @@ function invalidateManifest(server) {
2620
2859
  }
2621
2860
  server.hot.send({ type: "full-reload" });
2622
2861
  }
2623
- /**
2624
- * TIM-830: Walk the route tree and collect every page that exports a
2625
- * `searchParams` definition, then emit a module that statically imports
2626
- * each export and registers it into the shared search-params registry.
2627
- *
2628
- * Keys use the un-interpolated route pattern (e.g. '/products/[id]') so
2629
- * <Link> can look up the definition from the original `href` prop before
2630
- * segmentParams are interpolated.
2631
- */
2632
- /** Schema file extensions to check, in priority order. */
2633
- var SCHEMA_EXTENSIONS = [
2634
- ".ts",
2635
- ".tsx",
2636
- ".js",
2637
- ".jsx"
2638
- ];
2639
- /**
2640
- * TIM-931: Generate the virtual:timber-schema module.
2641
- *
2642
- * When app/schema.{ts,tsx,js,jsx} exists, generates a module that
2643
- * imports the schema and resolves its segmentParams codecs into a
2644
- * bare-name codec map for runtime coercion.
2645
- *
2646
- * When no schema exists, exports null.
2647
- */
2648
- function generateSchemaModule(appDir) {
2649
- let schemaPath = null;
2650
- for (const ext of SCHEMA_EXTENSIONS) {
2651
- const candidate = join(appDir, `schema${ext}`);
2652
- if (existsSync(candidate)) {
2653
- schemaPath = candidate;
2654
- break;
2655
- }
2656
- }
2657
- if (!schemaPath) return ["// Auto-generated schema loader — no app/schema.ts found.", "export default null;"].join("\n");
2658
- return [
2659
- "// Auto-generated schema loader — do not edit.",
2660
- "// Generated by timber-routing plugin (TIM-931).",
2661
- "",
2662
- `import schema from ${JSON.stringify(schemaPath)};`,
2663
- `import { resolveSchemaCodecs } from '@timber-js/app/params';`,
2664
- "",
2665
- "const globalCodecs = schema?.segmentParams",
2666
- " ? resolveSchemaCodecs(schema.segmentParams)",
2667
- " : null;",
2668
- "",
2669
- "export default globalCodecs;"
2670
- ].join("\n");
2671
- }
2672
- function generateSearchParamsRegistryModule(tree) {
2673
- const entries = [];
2674
- function walk(node) {
2675
- if (node.page) {
2676
- if (node.params && fileHasSearchParamsExport(node.params.filePath)) entries.push({
2677
- urlPath: node.urlPath,
2678
- filePath: node.params.filePath
2679
- });
2680
- else if (fileHasSearchParamsExport(node.page.filePath)) entries.push({
2681
- urlPath: node.urlPath,
2682
- filePath: node.page.filePath
2683
- });
2684
- }
2685
- for (const child of node.children) walk(child);
2686
- for (const slot of Object.values(node.slots)) walk(slot);
2687
- }
2688
- walk(tree.root);
2689
- entries.sort((a, b) => a.urlPath.localeCompare(b.urlPath));
2690
- const lines = [];
2691
- lines.push("// Auto-generated search-params registry — do not edit.");
2692
- lines.push("// Generated by timber-routing plugin (TIM-830).");
2693
- lines.push("");
2694
- lines.push("import { registerSearchParams } from '@timber-js/app/search-params';");
2695
- lines.push("");
2696
- entries.forEach((entry, i) => {
2697
- lines.push(`import { searchParams as r${i} } from ${JSON.stringify(entry.filePath)};`);
2698
- });
2699
- lines.push("");
2700
- entries.forEach((entry, i) => {
2701
- lines.push(`registerSearchParams(${JSON.stringify(entry.urlPath)}, r${i});`);
2702
- });
2703
- lines.push("");
2704
- lines.push("export {};");
2705
- lines.push("");
2706
- return lines.join("\n");
2707
- }
2708
- /**
2709
- * Lightweight check for a `searchParams` named export. Mirrors
2710
- * `fileHasExport('searchParams')` in routing/codegen.ts so we stay
2711
- * in sync with what the type codegen detects.
2712
- */
2713
- function fileHasSearchParamsExport(filePath) {
2714
- try {
2715
- if (!existsSync(filePath)) return false;
2716
- const content = readFileSync(filePath, "utf-8");
2717
- return /export\s+(const|let|var)\s+searchParams\b/.test(content) || /export\s*\{[^}]*\bsearchParams\b[^}]*\}/.test(content);
2718
- } catch {
2719
- return false;
2720
- }
2721
- }
2722
- /**
2723
- * Generate the virtual module source code for the route manifest.
2724
- *
2725
- * The output is a default-exported object containing the serialized route tree.
2726
- * All file references use absolute paths (required for virtual modules).
2727
- */
2728
- function generateManifestModule(tree, viteRoot) {
2729
- const imports = [];
2730
- let importIndex = 0;
2731
- /**
2732
- * Create a lazy import expression for a route file.
2733
- * Returns the import variable name.
2734
- */
2735
- function addImport(file) {
2736
- const varName = `_route${importIndex++}`;
2737
- imports.push(`const ${varName} = () => import("${file.filePath}");`);
2738
- return varName;
2739
- }
2740
- /**
2741
- * Serialize a segment node to a JS object literal.
2742
- */
2743
- function serializeNode(node, indent) {
2744
- const nextIndent = indent + " ";
2745
- const parts = [];
2746
- parts.push(`${nextIndent}segmentName: ${JSON.stringify(node.segmentName)},`);
2747
- parts.push(`${nextIndent}segmentType: ${JSON.stringify(node.segmentType)},`);
2748
- parts.push(`${nextIndent}urlPath: ${JSON.stringify(node.urlPath)},`);
2749
- if (node.paramName) parts.push(`${nextIndent}paramName: ${JSON.stringify(node.paramName)},`);
2750
- if (node.interceptionMarker) parts.push(`${nextIndent}interceptionMarker: ${JSON.stringify(node.interceptionMarker)},`);
2751
- if (node.interceptedSegmentName) parts.push(`${nextIndent}interceptedSegmentName: ${JSON.stringify(node.interceptedSegmentName)},`);
2752
- if (node.page) {
2753
- const v = addImport(node.page);
2754
- parts.push(`${nextIndent}page: { load: ${v}, filePath: ${JSON.stringify(node.page.filePath)} },`);
2755
- }
2756
- if (node.layout) {
2757
- const v = addImport(node.layout);
2758
- parts.push(`${nextIndent}layout: { load: ${v}, filePath: ${JSON.stringify(node.layout.filePath)} },`);
2759
- }
2760
- if (node.middleware) {
2761
- const v = addImport(node.middleware);
2762
- parts.push(`${nextIndent}middleware: { load: ${v}, filePath: ${JSON.stringify(node.middleware.filePath)} },`);
2763
- }
2764
- if (node.access) {
2765
- const v = addImport(node.access);
2766
- parts.push(`${nextIndent}access: { load: ${v}, filePath: ${JSON.stringify(node.access.filePath)} },`);
2767
- }
2768
- if (node.route) {
2769
- const v = addImport(node.route);
2770
- parts.push(`${nextIndent}route: { load: ${v}, filePath: ${JSON.stringify(node.route.filePath)} },`);
2771
- }
2772
- if (node.error) {
2773
- const v = addImport(node.error);
2774
- parts.push(`${nextIndent}error: { load: ${v}, filePath: ${JSON.stringify(node.error.filePath)} },`);
2775
- }
2776
- if (node.default) {
2777
- const v = addImport(node.default);
2778
- parts.push(`${nextIndent}default: { load: ${v}, filePath: ${JSON.stringify(node.default.filePath)} },`);
2779
- }
2780
- if (node.denied) {
2781
- const v = addImport(node.denied);
2782
- parts.push(`${nextIndent}denied: { load: ${v}, filePath: ${JSON.stringify(node.denied.filePath)} },`);
2783
- }
2784
- if (node.params) {
2785
- const v = addImport(node.params);
2786
- parts.push(`${nextIndent}params: { load: ${v}, filePath: ${JSON.stringify(node.params.filePath)} },`);
2787
- }
2788
- if (node.statusFiles && Object.keys(node.statusFiles).length > 0) {
2789
- const statusEntries = [];
2790
- for (const [code, file] of Object.entries(node.statusFiles)) {
2791
- const v = addImport(file);
2792
- statusEntries.push(`${nextIndent} ${JSON.stringify(code)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`);
2793
- }
2794
- parts.push(`${nextIndent}statusFiles: {\n${statusEntries.join(",\n")}\n${nextIndent}},`);
2795
- }
2796
- if (node.jsonStatusFiles && Object.keys(node.jsonStatusFiles).length > 0) {
2797
- const jsonEntries = [];
2798
- for (const [code, file] of Object.entries(node.jsonStatusFiles)) {
2799
- const v = addImport(file);
2800
- jsonEntries.push(`${nextIndent} ${JSON.stringify(code)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`);
2801
- }
2802
- parts.push(`${nextIndent}jsonStatusFiles: {\n${jsonEntries.join(",\n")}\n${nextIndent}},`);
2803
- }
2804
- if (node.legacyStatusFiles && Object.keys(node.legacyStatusFiles).length > 0) {
2805
- const legacyEntries = [];
2806
- for (const [name, file] of Object.entries(node.legacyStatusFiles)) {
2807
- const v = addImport(file);
2808
- legacyEntries.push(`${nextIndent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`);
2809
- }
2810
- parts.push(`${nextIndent}legacyStatusFiles: {\n${legacyEntries.join(",\n")}\n${nextIndent}},`);
2811
- }
2812
- if (node.metadataRoutes && Object.keys(node.metadataRoutes).length > 0) {
2813
- const metaEntries = [];
2814
- for (const [name, file] of Object.entries(node.metadataRoutes)) {
2815
- const v = addImport(file);
2816
- metaEntries.push(`${nextIndent} ${JSON.stringify(name)}: { load: ${v}, filePath: ${JSON.stringify(file.filePath)} }`);
2817
- }
2818
- parts.push(`${nextIndent}metadataRoutes: {\n${metaEntries.join(",\n")}\n${nextIndent}},`);
2819
- }
2820
- if (node.children.length > 0) {
2821
- const childNodes = node.children.map((c) => serializeNode(c, nextIndent));
2822
- parts.push(`${nextIndent}children: [\n${childNodes.join(",\n")}\n${nextIndent}],`);
2823
- } else parts.push(`${nextIndent}children: [],`);
2824
- const slotKeys = Object.keys(node.slots);
2825
- if (slotKeys.length > 0) {
2826
- const slotEntries = [];
2827
- for (const slotName of slotKeys) {
2828
- const slotNode = node.slots[slotName];
2829
- slotEntries.push(`${nextIndent} ${JSON.stringify(slotName)}: ${serializeNode(slotNode, nextIndent + " ")}`);
2830
- }
2831
- parts.push(`${nextIndent}slots: {\n${slotEntries.join(",\n")}\n${nextIndent}},`);
2832
- } else parts.push(`${nextIndent}slots: {},`);
2833
- return `${indent}{\n${parts.join("\n")}\n${indent}}`;
2834
- }
2835
- const rootSerialized = serializeNode(tree.root, " ");
2836
- let proxyLine = "";
2837
- if (tree.proxy) proxyLine = ` proxy: { load: ${addImport(tree.proxy)}, filePath: ${JSON.stringify(tree.proxy.filePath)} },`;
2838
- let globalErrorLine = "";
2839
- if (tree.globalError) globalErrorLine = ` globalError: { load: ${addImport(tree.globalError)}, filePath: ${JSON.stringify(tree.globalError.filePath)} },`;
2840
- const rewrites = collectInterceptionRewrites(tree.root);
2841
- const rewritesLine = rewrites.length > 0 ? ` interceptionRewrites: ${JSON.stringify(rewrites.map((r) => ({
2842
- interceptedPattern: r.interceptedPattern,
2843
- interceptingPrefix: r.interceptingPrefix
2844
- })))},` : "";
2845
- return [
2846
- "// Auto-generated route manifest — do not edit.",
2847
- "// Generated by timber-routing plugin.",
2848
- "",
2849
- ...imports,
2850
- "",
2851
- "const manifest = {",
2852
- ` viteRoot: ${JSON.stringify(viteRoot)},`,
2853
- proxyLine,
2854
- globalErrorLine,
2855
- rewritesLine,
2856
- ` root: ${rootSerialized},`,
2857
- "};",
2858
- "",
2859
- "export default manifest;"
2860
- ].filter((line) => line !== void 0 && line !== "").join("\n");
2861
- }
2862
2862
  //#endregion
2863
2863
  //#region src/plugins/shims.ts
2864
2864
  var __dirname = dirname(fileURLToPath(import.meta.url));
@@ -3061,6 +3061,58 @@ ${SERVER_STUB_EXPORTS.map((name) => name === "RedirectType" ? `export const Redi
3061
3061
  };
3062
3062
  }
3063
3063
  //#endregion
3064
+ //#region src/fonts/paths.ts
3065
+ /**
3066
+ * Shared path/URL helpers for the timber-fonts pipeline.
3067
+ *
3068
+ * Two concerns live here:
3069
+ *
3070
+ * - `fontUrlPrefix` — the public URL prefix for font binaries, derived
3071
+ * from Vite's resolved `base`. Every URL the pipeline emits (@font-face
3072
+ * src, manifest hrefs, dev middleware matching) must go through this so
3073
+ * apps deployed under a non-root base (`base: '/my-app/'`) resolve
3074
+ * fonts correctly. Emitted *file* locations are not base-prefixed —
3075
+ * only URLs are.
3076
+ *
3077
+ * - `fontOutputBasename` / `hashedFontBasename` — basename derivation for
3078
+ * local font sources. Paths are absolutized with `resolve()`, which
3079
+ * produces backslash separators on Windows, so `split('/').pop()` is
3080
+ * not safe. `path.win32.basename` handles both separators.
3081
+ *
3082
+ * Design doc: 24-fonts.md
3083
+ */
3084
+ /** Directory (relative to the build output / URL root) holding font binaries. */
3085
+ var FONT_OUTPUT_DIR = "_timber/fonts";
3086
+ /**
3087
+ * Build the URL prefix for font binaries from Vite's resolved `base`.
3088
+ *
3089
+ * `fontUrlPrefix('/')` → `/_timber/fonts`
3090
+ * `fontUrlPrefix('/my-app/')` → `/my-app/_timber/fonts`
3091
+ */
3092
+ function fontUrlPrefix(base = "/") {
3093
+ return `${base.endsWith("/") ? base : `${base}/`}${FONT_OUTPUT_DIR}`;
3094
+ }
3095
+ /**
3096
+ * Derive the output basename for a local font source path.
3097
+ *
3098
+ * Uses `path.win32.basename` because it splits on both `/` and `\`,
3099
+ * handling absolute paths produced by `resolve()` on any platform.
3100
+ */
3101
+ function fontOutputBasename(srcPath) {
3102
+ return win32.basename(srcPath);
3103
+ }
3104
+ /**
3105
+ * Derive the content-hashed output basename for a local font source.
3106
+ *
3107
+ * `hashedFontBasename('C:\\fonts\\MyFont-Bold.woff2', 'a1b2c3d4')`
3108
+ * → `MyFont-Bold-a1b2c3d4.woff2`
3109
+ */
3110
+ function hashedFontBasename(srcPath, hash) {
3111
+ const basename = fontOutputBasename(srcPath);
3112
+ const ext = win32.extname(basename);
3113
+ return `${ext ? basename.slice(0, -ext.length) : basename}-${hash}${ext}`;
3114
+ }
3115
+ //#endregion
3064
3116
  //#region src/fonts/google.ts
3065
3117
  /**
3066
3118
  * Google Fonts download, caching, and dev CDN fallback.
@@ -3256,12 +3308,14 @@ async function isCacheHit(metaPath, dataPath) {
3256
3308
  * Generate @font-face descriptors for cached (production) Google Fonts.
3257
3309
  *
3258
3310
  * Each CachedFont gets a FontFaceDescriptor pointing to the
3259
- * content-hashed URL under `/_timber/fonts/`.
3311
+ * content-hashed URL under `<base>_timber/fonts/`. The `urlPrefix` comes
3312
+ * from `fontUrlPrefix(viteBase)` so apps deployed under a non-root base
3313
+ * resolve fonts correctly.
3260
3314
  */
3261
- function generateProductionFontFaces(cachedFonts, display) {
3315
+ function generateProductionFontFaces(cachedFonts, display, urlPrefix = fontUrlPrefix()) {
3262
3316
  return cachedFonts.map((cf) => ({
3263
3317
  family: cf.face.family,
3264
- src: `url('/_timber/fonts/${cf.hashedFilename}') format('woff2')`,
3318
+ src: `url('${urlPrefix}/${cf.hashedFilename}') format('woff2')`,
3265
3319
  weight: cf.face.weight,
3266
3320
  style: cf.face.style,
3267
3321
  display,
@@ -3528,6 +3582,126 @@ function buildFontStack(family) {
3528
3582
  return parts.join(", ");
3529
3583
  }
3530
3584
  //#endregion
3585
+ //#region src/fonts/ast-walk.ts
3586
+ /** Convert a 0-based byte offset into a 1-based line and 0-based column. */
3587
+ function offsetToLineColumn(source, offset) {
3588
+ let line = 1;
3589
+ let lastNewline = -1;
3590
+ for (let i = 0; i < offset; i++) if (source[i] === "\n") {
3591
+ line++;
3592
+ lastNewline = i;
3593
+ }
3594
+ return {
3595
+ line,
3596
+ column: offset - lastNewline - 1
3597
+ };
3598
+ }
3599
+ /**
3600
+ * Invoke `visit` on every direct child AST node of `node` (array elements
3601
+ * and object-valued properties with a `type` field).
3602
+ */
3603
+ function walkChildNodes(node, visit) {
3604
+ for (const key of Object.keys(node)) {
3605
+ if (key === "type") continue;
3606
+ const child = node[key];
3607
+ if (Array.isArray(child)) {
3608
+ for (const item of child) if (item && typeof item === "object" && item.type) visit(item);
3609
+ } else if (child && typeof child === "object" && child.type) visit(child);
3610
+ }
3611
+ }
3612
+ /**
3613
+ * Node types that introduce a nested scope — any font call inside is an
3614
+ * error (font calls must be module-level, same constraint as Next.js).
3615
+ * Module scope so it isn't rebuilt on every recursive walk call.
3616
+ */
3617
+ var NESTED_SCOPE_TYPES = new Set([
3618
+ "FunctionDeclaration",
3619
+ "FunctionExpression",
3620
+ "ArrowFunctionExpression",
3621
+ "IfStatement",
3622
+ "ForStatement",
3623
+ "ForInStatement",
3624
+ "ForOfStatement",
3625
+ "WhileStatement",
3626
+ "DoWhileStatement",
3627
+ "SwitchStatement",
3628
+ "TryStatement",
3629
+ "BlockStatement",
3630
+ "ClassDeclaration",
3631
+ "ClassExpression"
3632
+ ]);
3633
+ /**
3634
+ * Recursively walk an AST node looking for font CallExpressions in nested
3635
+ * scopes (function bodies, if-blocks, loops, etc.). Font calls must be
3636
+ * module-level; nested calls are collected as errors.
3637
+ *
3638
+ * `isNested` tracks whether we've descended past the top-level statement.
3639
+ * Top-level VariableDeclarations and ExpressionStatements are already
3640
+ * handled by the main loop in `findFontCallSites`, so we only flag calls
3641
+ * found deeper.
3642
+ */
3643
+ function walkForNestedFontCalls(node, bindingNames, source, out, isNested) {
3644
+ if (!node || typeof node !== "object") return;
3645
+ if (node.type === "CallExpression") {
3646
+ const callee = node.callee;
3647
+ if (isNested && callee.type === "Identifier" && bindingNames.has(callee.name)) {
3648
+ const pos = offsetToLineColumn(source, node.start);
3649
+ out.push({
3650
+ name: callee.name,
3651
+ line: pos.line,
3652
+ column: pos.column
3653
+ });
3654
+ return;
3655
+ }
3656
+ }
3657
+ const enterNested = isNested || NESTED_SCOPE_TYPES.has(node.type);
3658
+ walkChildNodes(node, (child) => walkForNestedFontCalls(child, bindingNames, source, out, enterNested));
3659
+ }
3660
+ /**
3661
+ * Recursively walk an AST node looking for references to font bindings
3662
+ * that were not consumed by a recognized call site.
3663
+ *
3664
+ * Skips:
3665
+ * - recognized CallExpressions (the transform rewrites them wholesale)
3666
+ * - TS type-position nodes (erased at runtime)
3667
+ * - non-computed member properties (`obj.Inter` doesn't reference the binding)
3668
+ * - non-computed object keys (`{ Inter: 1 }` doesn't reference the binding)
3669
+ * - export aliases (`export { inter as Inter }` — only `local` is a reference)
3670
+ */
3671
+ function walkForUnconsumedReferences(node, bindingNames, consumedCallStarts, source, out) {
3672
+ if (!node || typeof node !== "object") return;
3673
+ if (node.type === "ImportDeclaration" || node.type.startsWith("TS")) return;
3674
+ if (node.type === "Identifier") {
3675
+ const name = node.name;
3676
+ if (bindingNames.has(name)) {
3677
+ const pos = offsetToLineColumn(source, node.start);
3678
+ out.push({
3679
+ name,
3680
+ line: pos.line,
3681
+ column: pos.column
3682
+ });
3683
+ }
3684
+ return;
3685
+ }
3686
+ if (node.type === "CallExpression" && consumedCallStarts.has(node.start)) return;
3687
+ if (node.type === "MemberExpression" && !node.computed) {
3688
+ const object = node.object;
3689
+ if (object) walkForUnconsumedReferences(object, bindingNames, consumedCallStarts, source, out);
3690
+ return;
3691
+ }
3692
+ if (node.type === "Property" && !node.computed) {
3693
+ const value = node.value;
3694
+ if (value) walkForUnconsumedReferences(value, bindingNames, consumedCallStarts, source, out);
3695
+ return;
3696
+ }
3697
+ if (node.type === "ExportSpecifier") {
3698
+ const local = node.local;
3699
+ if (local) walkForUnconsumedReferences(local, bindingNames, consumedCallStarts, source, out);
3700
+ return;
3701
+ }
3702
+ walkChildNodes(node, (child) => walkForUnconsumedReferences(child, bindingNames, consumedCallStarts, source, out));
3703
+ }
3704
+ //#endregion
3531
3705
  //#region src/fonts/ast.ts
3532
3706
  /**
3533
3707
  * AST utilities for extracting static font configs from source code.
@@ -3546,19 +3720,6 @@ function buildFontStack(family) {
3546
3720
  *
3547
3721
  * Design doc: 24-fonts.md §"Step 1: Static Analysis"
3548
3722
  */
3549
- /** Convert a 0-based byte offset into a 1-based line and 0-based column. */
3550
- function offsetToLineColumn(source, offset) {
3551
- let line = 1;
3552
- let lastNewline = -1;
3553
- for (let i = 0; i < offset; i++) if (source[i] === "\n") {
3554
- line++;
3555
- lastNewline = i;
3556
- }
3557
- return {
3558
- line,
3559
- column: offset - lastNewline - 1
3560
- };
3561
- }
3562
3723
  /**
3563
3724
  * Extract a static string value from an AST node.
3564
3725
  * Returns undefined if the node is not a string literal.
@@ -3769,6 +3930,7 @@ function findFontCallSites(source) {
3769
3930
  }
3770
3931
  const bindings = [];
3771
3932
  const importNodesToRemove = [];
3933
+ const namespaceImports = [];
3772
3934
  for (const stmt of program.body) {
3773
3935
  if (!isImportDeclaration(stmt)) continue;
3774
3936
  const importDecl = stmt;
@@ -3777,26 +3939,40 @@ function findFontCallSites(source) {
3777
3939
  if (GOOGLE_FONT_SOURCES.has(specSource)) provider = "google";
3778
3940
  else if (LOCAL_FONT_SOURCES.has(specSource)) provider = "local";
3779
3941
  if (!provider) continue;
3780
- for (const spec of importDecl.specifiers) if (spec.type === "ImportSpecifier" && provider === "google") bindings.push({
3781
- localName: spec.local.name,
3782
- importedName: spec.imported?.name ?? spec.local.name,
3783
- provider,
3784
- importNode: importDecl
3785
- });
3786
- else if (spec.type === "ImportDefaultSpecifier" && provider === "local") bindings.push({
3787
- localName: spec.local.name,
3788
- importedName: "default",
3789
- provider,
3790
- importNode: importDecl
3791
- });
3792
- importNodesToRemove.push({
3942
+ let producedBinding = false;
3943
+ for (const spec of importDecl.specifiers) if (spec.type === "ImportSpecifier" && provider === "google") {
3944
+ producedBinding = true;
3945
+ bindings.push({
3946
+ localName: spec.local.name,
3947
+ importedName: spec.imported?.name ?? spec.local.name,
3948
+ provider,
3949
+ importNode: importDecl
3950
+ });
3951
+ } else if (spec.type === "ImportDefaultSpecifier" && provider === "local") {
3952
+ producedBinding = true;
3953
+ bindings.push({
3954
+ localName: spec.local.name,
3955
+ importedName: "default",
3956
+ provider,
3957
+ importNode: importDecl
3958
+ });
3959
+ } else if (spec.type === "ImportNamespaceSpecifier") {
3960
+ const pos = offsetToLineColumn(source, spec.start);
3961
+ namespaceImports.push({
3962
+ name: spec.local.name,
3963
+ line: pos.line,
3964
+ column: pos.column
3965
+ });
3966
+ }
3967
+ if (producedBinding) importNodesToRemove.push({
3793
3968
  start: stmt.start,
3794
3969
  end: stmt.end
3795
3970
  });
3796
3971
  }
3797
- if (bindings.length === 0) return null;
3972
+ if (bindings.length === 0 && namespaceImports.length === 0) return null;
3798
3973
  const bindingsByName = new Map(bindings.map((b) => [b.localName, b]));
3799
3974
  const callSites = [];
3975
+ const consumedCallStarts = /* @__PURE__ */ new Set();
3800
3976
  let dynamicCall = null;
3801
3977
  for (const stmt of program.body) {
3802
3978
  const effectiveStmt = isExportNamedDeclaration(stmt) && stmt.declaration ? stmt.declaration : stmt;
@@ -3807,6 +3983,7 @@ function findFontCallSites(source) {
3807
3983
  const site = matchCallExpression(call, bindingsByName, source);
3808
3984
  if (!site) continue;
3809
3985
  if (site === "dynamic") {
3986
+ consumedCallStarts.add(call.start);
3810
3987
  if (!dynamicCall && isIdentifier(call.callee)) {
3811
3988
  const argStart = call.arguments[0].start;
3812
3989
  const argEnd = call.arguments[0].end;
@@ -3816,6 +3993,7 @@ function findFontCallSites(source) {
3816
3993
  }
3817
3994
  const assignedName = isIdentifier(declarator.id) ? declarator.id.name : null;
3818
3995
  const { line, column } = offsetToLineColumn(source, call.start);
3996
+ consumedCallStarts.add(call.start);
3819
3997
  callSites.push({
3820
3998
  binding: site.binding,
3821
3999
  assignedName,
@@ -3832,6 +4010,7 @@ function findFontCallSites(source) {
3832
4010
  const site = matchCallExpression(call, bindingsByName, source);
3833
4011
  if (site && site !== "dynamic") {
3834
4012
  const { line, column } = offsetToLineColumn(source, call.start);
4013
+ consumedCallStarts.add(call.start);
3835
4014
  callSites.push({
3836
4015
  binding: site.binding,
3837
4016
  assignedName: null,
@@ -3841,10 +4020,13 @@ function findFontCallSites(source) {
3841
4020
  line,
3842
4021
  column
3843
4022
  });
3844
- } else if (site === "dynamic" && !dynamicCall && isIdentifier(call.callee)) {
3845
- const argStart = call.arguments[0].start;
3846
- const argEnd = call.arguments[0].end;
3847
- dynamicCall = `${call.callee.name}(${source.slice(argStart, argEnd)})`;
4023
+ } else if (site === "dynamic") {
4024
+ consumedCallStarts.add(call.start);
4025
+ if (!dynamicCall && isIdentifier(call.callee)) {
4026
+ const argStart = call.arguments[0].start;
4027
+ const argEnd = call.arguments[0].end;
4028
+ dynamicCall = `${call.callee.name}(${source.slice(argStart, argEnd)})`;
4029
+ }
3848
4030
  }
3849
4031
  }
3850
4032
  }
@@ -3854,63 +4036,22 @@ function findFontCallSites(source) {
3854
4036
  if (stmt.type === "ImportDeclaration") continue;
3855
4037
  walkForNestedFontCalls(stmt, bindingsByName, source, nestedCalls, false);
3856
4038
  }
4039
+ const unsupportedUsages = [];
4040
+ for (const stmt of program.body) {
4041
+ if (stmt.type === "ImportDeclaration") continue;
4042
+ walkForUnconsumedReferences(stmt, bindingsByName, consumedCallStarts, source, unsupportedUsages);
4043
+ }
3857
4044
  return {
3858
4045
  bindings,
3859
4046
  callSites,
3860
4047
  importNodesToRemove,
3861
4048
  dynamicCall,
3862
- nestedCalls
4049
+ nestedCalls,
4050
+ namespaceImports,
4051
+ unsupportedUsages
3863
4052
  };
3864
4053
  }
3865
4054
  /**
3866
- * Recursively walk an AST node looking for font CallExpressions in nested
3867
- * scopes (function bodies, if-blocks, loops, etc.). Font calls must be
3868
- * module-level; nested calls are collected as errors.
3869
- *
3870
- * `isNested` tracks whether we've descended past the top-level statement.
3871
- * Top-level VariableDeclarations and ExpressionStatements are already
3872
- * handled by the main loop in `findFontCallSites`, so we only flag calls
3873
- * found deeper.
3874
- */
3875
- function walkForNestedFontCalls(node, bindings, source, out, isNested) {
3876
- if (!node || typeof node !== "object") return;
3877
- if (node.type === "CallExpression") {
3878
- const callee = node.callee;
3879
- if (isNested && callee.type === "Identifier" && bindings.has(callee.name)) {
3880
- const pos = offsetToLineColumn(source, node.start);
3881
- out.push({
3882
- name: callee.name,
3883
- line: pos.line,
3884
- column: pos.column
3885
- });
3886
- return;
3887
- }
3888
- }
3889
- const enterNested = isNested || new Set([
3890
- "FunctionDeclaration",
3891
- "FunctionExpression",
3892
- "ArrowFunctionExpression",
3893
- "IfStatement",
3894
- "ForStatement",
3895
- "ForInStatement",
3896
- "ForOfStatement",
3897
- "WhileStatement",
3898
- "DoWhileStatement",
3899
- "SwitchStatement",
3900
- "TryStatement",
3901
- "BlockStatement",
3902
- "ClassDeclaration",
3903
- "ClassExpression"
3904
- ]).has(node.type);
3905
- for (const key of Object.keys(node)) {
3906
- if (key === "type") continue;
3907
- const child = node[key];
3908
- if (Array.isArray(child)) {
3909
- for (const item of child) if (item && typeof item === "object" && item.type) walkForNestedFontCalls(item, bindings, source, out, enterNested);
3910
- } else if (child && typeof child === "object" && child.type) walkForNestedFontCalls(child, bindings, source, out, enterNested);
3911
- }
3912
- }
3913
- /**
3914
4055
  * Match a CallExpression against the font bindings map.
3915
4056
  *
3916
4057
  * Returns the binding + config node if the call is a static font call,
@@ -4001,23 +4142,23 @@ function resolveLocalFontPaths(importerPath, sources) {
4001
4142
  function generateFamilyName(sources) {
4002
4143
  if (sources.length === 0) return "Local Font";
4003
4144
  const firstPath = sources[0].path;
4004
- const stem = (firstPath.split("/").pop() ?? firstPath).replace(/\.[^.]+$/, "");
4145
+ const stem = fontOutputBasename(firstPath).replace(/\.[^.]+$/, "");
4005
4146
  return stem.replace(/[-_]?(Regular|Bold|Italic|Light|Medium|SemiBold|ExtraBold|Thin|Black|Heavy)$/i, "") || stem;
4006
4147
  }
4007
4148
  /**
4008
4149
  * Generate @font-face descriptors for local font sources.
4009
4150
  *
4010
4151
  * Each source entry produces one @font-face rule. The `src` descriptor
4011
- * uses a `url()` pointing to the served path under `/_timber/fonts/`.
4012
- * The `urlPrefix` defaults to `/_timber/fonts` — the path used by both
4013
- * the dev server middleware and the production build output.
4152
+ * uses a `url()` pointing to the served path under `<base>_timber/fonts/`.
4153
+ * The `urlPrefix` defaults to `fontUrlPrefix()` (root base) — pass
4154
+ * `fontUrlPrefix(viteBase)` so non-root deployments resolve correctly.
4014
4155
  */
4015
- function generateLocalFontFaces(family, sources, display, urlPrefix = "/_timber/fonts") {
4156
+ function generateLocalFontFaces(family, sources, display, urlPrefix = fontUrlPrefix()) {
4016
4157
  return sources.map((entry) => {
4017
4158
  const format = inferFontFormat(entry.path);
4018
4159
  return {
4019
4160
  family,
4020
- src: `url('${urlPrefix}/${entry.hashedBasename ?? entry.path.split("/").pop() ?? entry.path}') format('${format}')`,
4161
+ src: `url('${urlPrefix}/${entry.hashedBasename ?? fontOutputBasename(entry.path)}') format('${format}')`,
4021
4162
  weight: entry.weight,
4022
4163
  style: entry.style,
4023
4164
  display
@@ -4224,9 +4365,18 @@ var FontPipeline = class {
4224
4365
  _entries = /* @__PURE__ */ new Map();
4225
4366
  /** When true, local font sources are content-hashed at registration time. */
4226
4367
  _hashOnRegister;
4368
+ /**
4369
+ * Vite's resolved `base`. Set from the plugin's `configResolved` hook so
4370
+ * every @font-face URL the pipeline renders carries the prefix.
4371
+ */
4372
+ _base = "/";
4227
4373
  constructor(opts) {
4228
4374
  this._hashOnRegister = opts?.hashOnRegister ?? false;
4229
4375
  }
4376
+ /** Set Vite's resolved `base` (from `configResolved`). */
4377
+ setBase(base) {
4378
+ this._base = base;
4379
+ }
4230
4380
  /**
4231
4381
  * Per-importer virtual CSS module id registry. Populated by the
4232
4382
  * transform hook when it inlines a font function call, read by the
@@ -4296,10 +4446,8 @@ var FontPipeline = class {
4296
4446
  if (this._hashOnRegister && entry.provider === "local" && entry.localSources) for (const src of entry.localSources) {
4297
4447
  const absolutePath = normalize(resolve(src.path));
4298
4448
  if (!existsSync(absolutePath)) continue;
4299
- const hash = contentHash(readFileSync(absolutePath));
4300
- const basename = src.path.split("/").pop() ?? src.path;
4301
- const ext = extname(basename);
4302
- src.hashedBasename = `${ext ? basename.slice(0, -ext.length) : basename}-${hash}${ext}`;
4449
+ const data = readFileSync(absolutePath);
4450
+ src.hashedBasename = hashedFontBasename(src.path, contentHash(data));
4303
4451
  }
4304
4452
  this._entries.set(entry.id, entry);
4305
4453
  }
@@ -4363,8 +4511,9 @@ var FontPipeline = class {
4363
4511
  * or lazily in `load` for dev).
4364
4512
  */
4365
4513
  getCss() {
4514
+ const urlPrefix = fontUrlPrefix(this._base);
4366
4515
  const cssParts = [];
4367
- for (const entry of this._entries.values()) cssParts.push(renderEntryCss(entry));
4516
+ for (const entry of this._entries.values()) cssParts.push(renderEntryCss(entry, urlPrefix));
4368
4517
  return cssParts.join("\n\n");
4369
4518
  }
4370
4519
  /**
@@ -4384,10 +4533,11 @@ var FontPipeline = class {
4384
4533
  * pipeline handles it as a valid empty CSS module.
4385
4534
  */
4386
4535
  getCssForSegment(importer) {
4536
+ const urlPrefix = fontUrlPrefix(this._base);
4387
4537
  const cssParts = [];
4388
4538
  for (const entry of this._entries.values()) {
4389
4539
  if (entry.importer !== importer) continue;
4390
- cssParts.push(renderEntryCss(entry));
4540
+ cssParts.push(renderEntryCss(entry, urlPrefix));
4391
4541
  }
4392
4542
  return cssParts.join("\n\n");
4393
4543
  }
@@ -4395,15 +4545,13 @@ var FontPipeline = class {
4395
4545
  /**
4396
4546
  * Render the combined CSS for a single font entry.
4397
4547
  *
4398
- * Mirrors the standalone `generateFontCss` helper exported from
4399
- * `virtual-modules.ts`, but reads `faces` straight off the entry instead
4400
- * of from a parallel map. Kept private to this module so the only public
4401
- * way to render a `FontEntry`'s CSS is through `FontPipeline.getCss()`.
4548
+ * Reads `faces` straight off the entry. Private to this module — the only
4549
+ * public way to render a `FontEntry`'s CSS is through `FontPipeline.getCss()`.
4402
4550
  */
4403
- function renderEntryCss(entry) {
4551
+ function renderEntryCss(entry, urlPrefix) {
4404
4552
  const cssParts = [];
4405
4553
  if (entry.provider === "local" && entry.localSources) {
4406
- const faceCss = generateFontFaces(generateLocalFontFaces(entry.family, entry.localSources, entry.display));
4554
+ const faceCss = generateFontFaces(generateLocalFontFaces(entry.family, entry.localSources, entry.display, urlPrefix));
4407
4555
  if (faceCss) cssParts.push(faceCss);
4408
4556
  }
4409
4557
  if (entry.provider === "google" && entry.faces && entry.faces.length > 0) {
@@ -5525,6 +5673,14 @@ function runFontsTransform(code, id, pipeline, emitError) {
5525
5673
  const first = analysis.nestedCalls[0];
5526
5674
  emitError(`Font function \`${first.name}()\` must be called at module scope. Found nested call at line ${first.line}, column ${first.column}. Move the call to the top level of the module.`);
5527
5675
  }
5676
+ if (analysis.namespaceImports.length > 0) {
5677
+ const first = analysis.namespaceImports[0];
5678
+ emitError(`Namespace imports of font modules are not supported (\`* as ${first.name}\` at line ${first.line}, column ${first.column}). Use a named import instead: \`import { Inter } from 'next/font/google'\`.`);
5679
+ }
5680
+ if (analysis.unsupportedUsages.length > 0) {
5681
+ const first = analysis.unsupportedUsages[0];
5682
+ emitError(`Unsupported usage of font function \`${first.name}\` at line ${first.line}, column ${first.column}. A font call must be \`const x = Font({...})\` at module scope (export const is fine). The call result cannot be nested in other expressions, and the imported font function cannot be aliased or re-exported.`);
5683
+ }
5528
5684
  if (analysis.callSites.length === 0 && analysis.bindings.length === 0) return null;
5529
5685
  const s = new MagicString(code);
5530
5686
  for (const site of analysis.callSites) if (site.binding.provider === "google") {
@@ -5600,11 +5756,12 @@ var FONT_MIME_TYPES = {
5600
5756
  * captured by reference so that fonts registered after `configureServer`
5601
5757
  * runs (during transform) are still resolvable.
5602
5758
  */
5603
- function installFontDevMiddleware(server, pipeline) {
5759
+ function installFontDevMiddleware(server, pipeline, base = "/") {
5760
+ const urlPrefix = `${fontUrlPrefix(base)}/`;
5604
5761
  server.middlewares.use((req, res, next) => {
5605
5762
  const url = req.url;
5606
- if (!url || !url.startsWith("/_timber/fonts/")) return next();
5607
- const requestedFilename = url.slice(15);
5763
+ if (!url || !url.startsWith(urlPrefix)) return next();
5764
+ const requestedFilename = url.slice(urlPrefix.length);
5608
5765
  if (requestedFilename.includes("..") || requestedFilename.includes("/")) {
5609
5766
  res.statusCode = 400;
5610
5767
  res.end("Bad request");
@@ -5613,7 +5770,7 @@ function installFontDevMiddleware(server, pipeline) {
5613
5770
  for (const font of pipeline.fonts()) {
5614
5771
  if (font.provider !== "local" || !font.localSources) continue;
5615
5772
  for (const src of font.localSources) {
5616
- if ((src.path.split("/").pop() ?? "") !== requestedFilename && src.hashedBasename !== requestedFilename) continue;
5773
+ if (fontOutputBasename(src.path) !== requestedFilename && src.hashedBasename !== requestedFilename) continue;
5617
5774
  const absolutePath = normalize(resolve(src.path));
5618
5775
  if (!existsSync(absolutePath)) {
5619
5776
  res.statusCode = 404;
@@ -5623,7 +5780,7 @@ function installFontDevMiddleware(server, pipeline) {
5623
5780
  const data = readFileSync(absolutePath);
5624
5781
  const ext = absolutePath.split(".").pop()?.toLowerCase();
5625
5782
  res.setHeader("Content-Type", FONT_MIME_TYPES[ext ?? ""] ?? "application/octet-stream");
5626
- res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
5783
+ res.setHeader("Cache-Control", "no-cache");
5627
5784
  res.setHeader("Access-Control-Allow-Origin", "*");
5628
5785
  res.end(data);
5629
5786
  return;
@@ -5667,7 +5824,7 @@ function groupCachedFontsByFamily(cachedFonts) {
5667
5824
  function emitFontAssets(pipeline, emitFile, warn) {
5668
5825
  for (const cf of pipeline.uniqueCachedFiles()) emitFile({
5669
5826
  type: "asset",
5670
- fileName: `_timber/fonts/${cf.hashedFilename}`,
5827
+ fileName: `${FONT_OUTPUT_DIR}/${cf.hashedFilename}`,
5671
5828
  source: cf.data
5672
5829
  });
5673
5830
  for (const font of pipeline.fonts()) {
@@ -5678,11 +5835,11 @@ function emitFontAssets(pipeline, emitFile, warn) {
5678
5835
  warn(`Local font file not found: ${absolutePath}`);
5679
5836
  continue;
5680
5837
  }
5681
- const filename = src.hashedBasename ?? src.path.split("/").pop() ?? src.path;
5838
+ const filename = src.hashedBasename ?? fontOutputBasename(src.path);
5682
5839
  const data = readFileSync(absolutePath);
5683
5840
  emitFile({
5684
5841
  type: "asset",
5685
- fileName: `_timber/fonts/${filename}`,
5842
+ fileName: `${FONT_OUTPUT_DIR}/${filename}`,
5686
5843
  source: data
5687
5844
  });
5688
5845
  }
@@ -5695,22 +5852,25 @@ function emitFontAssets(pipeline, emitFile, warn) {
5695
5852
  * `buildManifest.css` keys work after TIM-1014.
5696
5853
  *
5697
5854
  * All font binaries use content-hashed filenames for immutable caching.
5855
+ * Hrefs are prefixed with Vite's resolved `base` so they match the URLs
5856
+ * the platform serves the emitted binaries under.
5698
5857
  */
5699
- function writeFontManifest(pipeline, buildManifest) {
5858
+ function writeFontManifest(pipeline, buildManifest, base = "/") {
5859
+ const urlPrefix = fontUrlPrefix(base);
5700
5860
  const fontsByImporter = /* @__PURE__ */ new Map();
5701
5861
  for (const entry of pipeline.entries()) {
5702
5862
  const manifestEntries = fontsByImporter.get(entry.importer) ?? [];
5703
5863
  if (entry.provider === "local" && entry.localSources) for (const src of entry.localSources) {
5704
- const filename = src.hashedBasename ?? src.path.split("/").pop() ?? src.path;
5864
+ const filename = src.hashedBasename ?? fontOutputBasename(src.path);
5705
5865
  const format = inferFontFormat(src.path);
5706
5866
  manifestEntries.push({
5707
- href: `/_timber/fonts/${filename}`,
5867
+ href: `${urlPrefix}/${filename}`,
5708
5868
  format,
5709
5869
  crossOrigin: "anonymous"
5710
5870
  });
5711
5871
  }
5712
5872
  else if (entry.cachedFiles) for (const cf of entry.cachedFiles) manifestEntries.push({
5713
- href: `/_timber/fonts/${cf.hashedFilename}`,
5873
+ href: `${urlPrefix}/${cf.hashedFilename}`,
5714
5874
  format: "woff2",
5715
5875
  crossOrigin: "anonymous"
5716
5876
  });
@@ -5725,9 +5885,19 @@ function writeFontManifest(pipeline, buildManifest) {
5725
5885
  */
5726
5886
  function timberFonts(ctx) {
5727
5887
  const pipeline = new FontPipeline({ hashOnRegister: !ctx.dev });
5888
+ let resolvedBase = "/";
5728
5889
  return {
5729
5890
  name: "timber-fonts",
5730
5891
  /**
5892
+ * Capture Vite's resolved `base` so every font URL the pipeline emits
5893
+ * (@font-face src, manifest hrefs, dev middleware matching) carries
5894
+ * the prefix. Emitted file locations are unaffected — only URLs.
5895
+ */
5896
+ configResolved(config) {
5897
+ resolvedBase = config.base;
5898
+ pipeline.setBase(config.base);
5899
+ },
5900
+ /**
5731
5901
  * `enforce: 'pre'` is load-bearing for the font CSS pipeline (TIM-828).
5732
5902
  *
5733
5903
  * The transform hook injects a side-effect
@@ -5811,7 +5981,7 @@ function timberFonts(ctx) {
5811
5981
  * this middleware.
5812
5982
  */
5813
5983
  configureServer(server) {
5814
- installFontDevMiddleware(server, pipeline);
5984
+ installFontDevMiddleware(server, pipeline, resolvedBase);
5815
5985
  },
5816
5986
  /**
5817
5987
  * Download and cache Google Fonts during production builds.
@@ -5828,10 +5998,11 @@ function timberFonts(ctx) {
5828
5998
  const googleFonts = [...pipeline.googleFonts()];
5829
5999
  if (googleFonts.length === 0) return;
5830
6000
  const cachedByFamily = groupCachedFontsByFamily(await downloadAndCacheFonts(googleFonts, ctx.root));
6001
+ const urlPrefix = fontUrlPrefix(resolvedBase);
5831
6002
  for (const font of googleFonts) {
5832
6003
  const familyCached = cachedByFamily.get(font.family.toLowerCase()) ?? [];
5833
6004
  pipeline.attachCachedFiles(font.id, familyCached);
5834
- pipeline.attachFaces(font.id, generateProductionFontFaces(familyCached, font.display));
6005
+ pipeline.attachFaces(font.id, generateProductionFontFaces(familyCached, font.display, urlPrefix));
5835
6006
  }
5836
6007
  },
5837
6008
  /**
@@ -5853,7 +6024,7 @@ function timberFonts(ctx) {
5853
6024
  generateBundle() {
5854
6025
  emitFontAssets(pipeline, (asset) => this.emitFile(asset), (msg) => this.warn(msg));
5855
6026
  if (!ctx.buildManifest) return;
5856
- writeFontManifest(pipeline, ctx.buildManifest);
6027
+ writeFontManifest(pipeline, ctx.buildManifest, resolvedBase);
5857
6028
  }
5858
6029
  };
5859
6030
  }
@@ -6658,6 +6829,7 @@ function clientChunkGroup(meta, appDir, root) {
6658
6829
  */
6659
6830
  var BUILD_ONLY_EXTERNALS = [
6660
6831
  ...builtinModules.flatMap((m) => [m, `node:${m}`]),
6832
+ "vite",
6661
6833
  "nitro",
6662
6834
  "rollup",
6663
6835
  "@mdx-js/rollup",
@@ -6747,13 +6919,21 @@ function timberServerBundle() {
6747
6919
  * timber-cache-transform — Vite plugin that injects stable callsite IDs
6748
6920
  * into `cache()` calls imported from `@timber-js/app/cache`.
6749
6921
  *
6750
- * Without this plugin, cache keys use a process-local counter (fnIdCounter)
6751
- * which is unstable across cold starts in serverless/multi-instance
6752
- * deployments. With a shared CacheHandler (Redis/KV), colliding counters
6753
- * cause cache key poisoning — serving one route's cached data to another.
6922
+ * Detected callsite forms (TIM-1054):
6923
+ * - named imports, including aliases: `import { cache as c }` + `c(fn, opts)`
6924
+ * - namespace imports: `import * as ns` + `ns.cache(fn, opts)`
6925
+ * Forms a per-module transform cannot trace — local variable aliases
6926
+ * (`const c = cache`), user re-export wrapper modules, computed access
6927
+ * (`ns['cache']`) — fall back to the runtime fnId derived from
6928
+ * fnv1a(fn.toString()) (see cache/timber-cache.ts), which is deterministic
6929
+ * across instances and cold starts of the same build. Without either
6930
+ * mechanism, an ordinal counter would collide across instances with a shared
6931
+ * CacheHandler (Redis/KV) — cache key poisoning, serving one route's cached
6932
+ * data to another.
6754
6933
  *
6755
6934
  * The plugin transforms:
6756
- * cache(fn, opts) → cache(fn, opts, "stable-id")
6935
+ * cache(fn, opts) → cache(fn, opts, "stable-id")
6936
+ * ns.cache(fn, opts) → ns.cache(fn, opts, "stable-id")
6757
6937
  *
6758
6938
  * where stable-id =
6759
6939
  * fnv1a(relPath + ":cache:" + fnv1a(stmtText) + ":" + fnv1a(callSpanText) + ":" + occurrence)
@@ -6788,20 +6968,43 @@ function timberServerBundle() {
6788
6968
  */
6789
6969
  var CACHE_MODULE_SOURCES = new Set(["@timber-js/app/cache", "timber/cache"]);
6790
6970
  /**
6791
- * Find the local binding name(s) for `cache` imported from the cache module.
6971
+ * Find the local binding name(s) for `cache` imported from the cache module —
6972
+ * both named imports and namespace imports (TIM-1054).
6792
6973
  */
6793
6974
  function findCacheBindings(program) {
6794
- const bindings = /* @__PURE__ */ new Set();
6975
+ const named = /* @__PURE__ */ new Set();
6976
+ const namespaces = /* @__PURE__ */ new Set();
6795
6977
  for (const stmt of program.body) {
6796
6978
  if (stmt.type !== "ImportDeclaration") continue;
6797
6979
  const decl = stmt;
6798
6980
  if (!CACHE_MODULE_SOURCES.has(decl.source.value)) continue;
6799
6981
  for (const spec of decl.specifiers) {
6982
+ if (spec.type === "ImportNamespaceSpecifier") {
6983
+ namespaces.add(spec.local.name);
6984
+ continue;
6985
+ }
6800
6986
  if (spec.type !== "ImportSpecifier") continue;
6801
- if ((spec.imported?.name ?? spec.local.name) === "cache") bindings.add(spec.local.name);
6987
+ if ((spec.imported?.name ?? spec.local.name) === "cache") named.add(spec.local.name);
6802
6988
  }
6803
6989
  }
6804
- return bindings;
6990
+ return {
6991
+ named,
6992
+ namespaces
6993
+ };
6994
+ }
6995
+ /**
6996
+ * Is this callee a reference to the imported `cache` function — either a bare
6997
+ * identifier bound by a named import, or `ns.cache` on a namespace import?
6998
+ * Computed access (`ns['cache']`) is deliberately not matched; it falls back
6999
+ * to the runtime fnId derivation.
7000
+ */
7001
+ function isCacheCallee(callee, bindings) {
7002
+ if (callee.type === "Identifier") return bindings.named.has(callee.name);
7003
+ if (callee.type === "MemberExpression") {
7004
+ const member = callee;
7005
+ return !member.computed && member.object.type === "Identifier" && bindings.namespaces.has(member.object.name) && member.property.type === "Identifier" && member.property.name === "cache";
7006
+ }
7007
+ return false;
6805
7008
  }
6806
7009
  /**
6807
7010
  * Recursively walk AST nodes to find cache() call expressions.
@@ -6812,7 +7015,7 @@ function findCacheBindings(program) {
6812
7015
  function findCacheCallSites(node, cacheBindings, results) {
6813
7016
  if (node.type === "CallExpression") {
6814
7017
  const call = node;
6815
- if (call.callee.type === "Identifier" && cacheBindings.has(call.callee.name) && call.arguments.length === 2) results.push(call);
7018
+ if (call.arguments.length === 2 && isCacheCallee(call.callee, cacheBindings)) results.push(call);
6816
7019
  }
6817
7020
  for (const key of Object.keys(node)) {
6818
7021
  if (key === "type" || key === "start" || key === "end") continue;
@@ -6856,7 +7059,7 @@ function timberCacheTransform(ctx) {
6856
7059
  return;
6857
7060
  }
6858
7061
  const cacheBindings = findCacheBindings(program);
6859
- if (cacheBindings.size === 0) return;
7062
+ if (cacheBindings.named.size === 0 && cacheBindings.namespaces.size === 0) return;
6860
7063
  const callSites = [];
6861
7064
  for (const stmt of program.body) {
6862
7065
  const calls = [];
@@ -7750,14 +7953,15 @@ function timber(config) {
7750
7953
  ctx.timer.start("config-load");
7751
7954
  const fileConfig = loadTimberConfigFile(viteRoot);
7752
7955
  if (fileConfig) {
7753
- mergeFileConfig(ctx, fileConfig);
7956
+ ctx.config = mergeFileConfig(ctx.config, fileConfig);
7754
7957
  ctx.clientJavascript = resolveClientJavascript(ctx.config);
7755
7958
  }
7756
7959
  ctx.config.output ??= "server";
7757
7960
  ctx.timer.end("config-load");
7758
7961
  const viteOutDir = userConfig.build?.outDir;
7759
7962
  const timberBuildDir = ctx.config.buildDir;
7760
- ctx.buildDir = resolveBuildDir(resolve(userConfig.root ?? process.cwd()), timberBuildDir, viteOutDir);
7963
+ const resolvedRoot = resolve(userConfig.root ?? process.cwd());
7964
+ ctx.buildDir = resolveBuildDir(resolvedRoot, timberBuildDir, viteOutDir);
7761
7965
  if (viteRoot !== process.cwd()) {
7762
7966
  const hasCompilerInReloaded = !config?.reactCompiler && ctx.config.reactCompiler;
7763
7967
  const hadCompilerInEarly = !config?.reactCompiler && earlyFileConfig?.reactCompiler;
@@ -7784,7 +7988,7 @@ function timber(config) {
7784
7988
  resolvedDevPort = start.port;
7785
7989
  resolvedDevPortExplicit = start.explicit;
7786
7990
  }
7787
- const buildOutDir = timberBuildDir ? timberBuildDir : viteOutDir && viteOutDir !== "dist" ? viteOutDir : DEFAULT_BUILD_DIR;
7991
+ const buildOutDir = relative(resolvedRoot, ctx.buildDir);
7788
7992
  const envOutDirs = {};
7789
7993
  for (const envName of [
7790
7994
  "rsc",