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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
  2. package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
  3. package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
  4. package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
  6. package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-CQaxupHv.js → cli-schema-sync-DJDdyEae.js} +111 -260
  8. package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
  9. package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
  10. package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
  11. package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
  12. package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
  13. package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
  14. package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
  15. package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
  16. package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
  17. package/dist/_chunks/logger-CM0clugc.js.map +1 -0
  18. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
  19. package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
  20. package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
  21. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
  22. package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
  23. package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
  24. package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
  25. package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
  26. package/dist/_chunks/tree-match-D2l830j2.js +102 -0
  27. package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
  28. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
  29. package/dist/_chunks/{walkers-Ck4Y45XN.js → walkers-DvWDj453.js} +107 -82
  30. package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
  31. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  32. package/dist/adapters/cloudflare-kv-cache.js +36 -14
  33. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  34. package/dist/adapters/compress-module.d.ts +5 -4
  35. package/dist/adapters/compress-module.d.ts.map +1 -1
  36. package/dist/adapters/nitro.d.ts +1 -3
  37. package/dist/adapters/nitro.d.ts.map +1 -1
  38. package/dist/adapters/nitro.js +14 -134
  39. package/dist/adapters/nitro.js.map +1 -1
  40. package/dist/cache/cache-api.d.ts.map +1 -1
  41. package/dist/cache/index.d.ts +3 -1
  42. package/dist/cache/index.d.ts.map +1 -1
  43. package/dist/cache/index.js +20 -235
  44. package/dist/cache/index.js.map +1 -1
  45. package/dist/cache/invalidation-epoch.d.ts +43 -0
  46. package/dist/cache/invalidation-epoch.d.ts.map +1 -0
  47. package/dist/cache/redis-handler.d.ts +13 -0
  48. package/dist/cache/redis-handler.d.ts.map +1 -1
  49. package/dist/cache/timber-cache.d.ts +17 -9
  50. package/dist/cache/timber-cache.d.ts.map +1 -1
  51. package/dist/cli-schema-sync.d.ts +21 -1
  52. package/dist/cli-schema-sync.d.ts.map +1 -1
  53. package/dist/cli.d.ts +12 -0
  54. package/dist/cli.d.ts.map +1 -1
  55. package/dist/cli.js +26 -31
  56. package/dist/cli.js.map +1 -1
  57. package/dist/client/browser-entry/hmr.d.ts.map +1 -1
  58. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  59. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  60. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  61. package/dist/client/history.d.ts +10 -17
  62. package/dist/client/history.d.ts.map +1 -1
  63. package/dist/client/index.js +1 -1
  64. package/dist/client/index.js.map +1 -1
  65. package/dist/client/internal.js +30 -33
  66. package/dist/client/internal.js.map +1 -1
  67. package/dist/client/navigation-api.d.ts +9 -2
  68. package/dist/client/navigation-api.d.ts.map +1 -1
  69. package/dist/client/navigation-context.d.ts +0 -11
  70. package/dist/client/navigation-context.d.ts.map +1 -1
  71. package/dist/client/navigation-root.d.ts +0 -5
  72. package/dist/client/navigation-root.d.ts.map +1 -1
  73. package/dist/client/router.d.ts.map +1 -1
  74. package/dist/client/rsc-fetch.d.ts.map +1 -1
  75. package/dist/client/use-search-params.d.ts +10 -5
  76. package/dist/client/use-search-params.d.ts.map +1 -1
  77. package/dist/codec.js +1 -1
  78. package/dist/config-validation.d.ts.map +1 -1
  79. package/dist/cookies/define-cookie.d.ts.map +1 -1
  80. package/dist/cookies/index.js +2 -4
  81. package/dist/cookies/index.js.map +1 -1
  82. package/dist/dev-tools/dev-page-shell.d.ts +1 -0
  83. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
  84. package/dist/dev-tools/hydration-overlay.d.ts +11 -0
  85. package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
  86. package/dist/dev-tools/index.d.ts +1 -2
  87. package/dist/dev-tools/index.d.ts.map +1 -1
  88. package/dist/fonts/ast-walk.d.ts +68 -0
  89. package/dist/fonts/ast-walk.d.ts.map +1 -0
  90. package/dist/fonts/ast.d.ts +23 -11
  91. package/dist/fonts/ast.d.ts.map +1 -1
  92. package/dist/fonts/bundle.d.ts +3 -1
  93. package/dist/fonts/bundle.d.ts.map +1 -1
  94. package/dist/fonts/dev-middleware.d.ts +1 -1
  95. package/dist/fonts/dev-middleware.d.ts.map +1 -1
  96. package/dist/fonts/fallbacks.d.ts +0 -7
  97. package/dist/fonts/fallbacks.d.ts.map +1 -1
  98. package/dist/fonts/google.d.ts +4 -2
  99. package/dist/fonts/google.d.ts.map +1 -1
  100. package/dist/fonts/local.d.ts +3 -3
  101. package/dist/fonts/local.d.ts.map +1 -1
  102. package/dist/fonts/paths.d.ts +43 -0
  103. package/dist/fonts/paths.d.ts.map +1 -0
  104. package/dist/fonts/pipeline.d.ts +7 -21
  105. package/dist/fonts/pipeline.d.ts.map +1 -1
  106. package/dist/fonts/transform.d.ts.map +1 -1
  107. package/dist/fonts/virtual-modules.d.ts +1 -18
  108. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +609 -406
  111. package/dist/index.js.map +1 -1
  112. package/dist/params/index.js +1 -1
  113. package/dist/params/resolve-schema.d.ts.map +1 -1
  114. package/dist/plugin-context.d.ts +28 -5
  115. package/dist/plugin-context.d.ts.map +1 -1
  116. package/dist/plugins/cache.d.ts +13 -5
  117. package/dist/plugins/cache.d.ts.map +1 -1
  118. package/dist/plugins/dev-server.d.ts.map +1 -1
  119. package/dist/plugins/fonts.d.ts +1 -2
  120. package/dist/plugins/fonts.d.ts.map +1 -1
  121. package/dist/plugins/routing.d.ts.map +1 -1
  122. package/dist/routing/codegen.d.ts.map +1 -1
  123. package/dist/routing/convention-lint.d.ts.map +1 -1
  124. package/dist/routing/file-cache.d.ts +8 -0
  125. package/dist/routing/file-cache.d.ts.map +1 -0
  126. package/dist/routing/index.js +2 -2
  127. package/dist/routing/interception.d.ts +0 -5
  128. package/dist/routing/interception.d.ts.map +1 -1
  129. package/dist/routing/manifest-codegen.d.ts +32 -0
  130. package/dist/routing/manifest-codegen.d.ts.map +1 -0
  131. package/dist/routing/scanner.d.ts.map +1 -1
  132. package/dist/schema-bridge.d.ts +15 -0
  133. package/dist/schema-bridge.d.ts.map +1 -1
  134. package/dist/search-params/define.d.ts.map +1 -1
  135. package/dist/search-params/index.js +7 -3
  136. package/dist/search-params/index.js.map +1 -1
  137. package/dist/search-params/wrappers.d.ts.map +1 -1
  138. package/dist/segment-params/define.d.ts.map +1 -1
  139. package/dist/segment-params/index.js +1 -1
  140. package/dist/server/access-gate.d.ts.map +1 -1
  141. package/dist/server/actions.d.ts +0 -14
  142. package/dist/server/actions.d.ts.map +1 -1
  143. package/dist/server/build-manifest.d.ts +0 -29
  144. package/dist/server/build-manifest.d.ts.map +1 -1
  145. package/dist/server/canonicalize.d.ts +10 -0
  146. package/dist/server/canonicalize.d.ts.map +1 -1
  147. package/dist/server/deny-renderer.d.ts +1 -1
  148. package/dist/server/deny-renderer.d.ts.map +1 -1
  149. package/dist/server/html-injector-core.d.ts +8 -0
  150. package/dist/server/html-injector-core.d.ts.map +1 -1
  151. package/dist/server/html-injectors.d.ts.map +1 -1
  152. package/dist/server/index.js +2 -2
  153. package/dist/server/internal.d.ts +1 -5
  154. package/dist/server/internal.d.ts.map +1 -1
  155. package/dist/server/internal.js +12 -171
  156. package/dist/server/internal.js.map +1 -1
  157. package/dist/server/node-stream-transforms.d.ts +0 -11
  158. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  159. package/dist/server/render-utils.d.ts +25 -0
  160. package/dist/server/render-utils.d.ts.map +1 -0
  161. package/dist/server/route-element-builder.d.ts +11 -25
  162. package/dist/server/route-element-builder.d.ts.map +1 -1
  163. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  164. package/dist/server/rsc-entry/helpers.d.ts +2 -18
  165. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  166. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  167. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  168. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  169. package/dist/server/ssr-render.d.ts.map +1 -1
  170. package/dist/shared/href-validation.d.ts.map +1 -1
  171. package/dist/shims/image.d.ts +15 -15
  172. package/package.json +2 -2
  173. package/src/adapters/cloudflare-kv-cache.ts +56 -19
  174. package/src/adapters/compress-module.ts +27 -118
  175. package/src/adapters/nitro.ts +3 -41
  176. package/src/adapters/raw-imports.d.ts +5 -0
  177. package/src/cache/cache-api.ts +9 -1
  178. package/src/cache/index.ts +5 -2
  179. package/src/cache/invalidation-epoch.ts +93 -0
  180. package/src/cache/redis-handler.ts +33 -2
  181. package/src/cache/timber-cache.ts +109 -53
  182. package/src/cli-schema-sync.ts +123 -30
  183. package/src/cli.ts +33 -37
  184. package/src/client/browser-entry/hmr.ts +0 -11
  185. package/src/client/browser-entry/hydrate.ts +39 -5
  186. package/src/client/browser-entry/post-hydration.ts +6 -15
  187. package/src/client/browser-entry/router-init.ts +98 -3
  188. package/src/client/history.ts +10 -24
  189. package/src/client/navigation-api.ts +14 -9
  190. package/src/client/navigation-context.ts +1 -21
  191. package/src/client/navigation-root.tsx +0 -8
  192. package/src/client/router.ts +18 -8
  193. package/src/client/rsc-fetch.ts +7 -2
  194. package/src/client/use-search-params.ts +10 -5
  195. package/src/config-validation.ts +33 -25
  196. package/src/cookies/define-cookie.ts +2 -18
  197. package/src/dev-tools/dev-page-shell.ts +6 -2
  198. package/src/dev-tools/hydration-overlay.ts +158 -0
  199. package/src/dev-tools/index.ts +3 -16
  200. package/src/fonts/ast-walk.ts +235 -0
  201. package/src/fonts/ast.ts +91 -249
  202. package/src/fonts/bundle.ts +16 -9
  203. package/src/fonts/dev-middleware.ts +16 -5
  204. package/src/fonts/fallbacks.ts +0 -23
  205. package/src/fonts/google.ts +7 -3
  206. package/src/fonts/local.ts +7 -6
  207. package/src/fonts/paths.ts +58 -0
  208. package/src/fonts/pipeline.ts +27 -48
  209. package/src/fonts/transform.ts +27 -0
  210. package/src/fonts/virtual-modules.ts +1 -57
  211. package/src/index.ts +5 -11
  212. package/src/params/resolve-schema.ts +2 -34
  213. package/src/plugin-context.ts +95 -17
  214. package/src/plugins/cache.ts +64 -17
  215. package/src/plugins/dev-server.ts +61 -9
  216. package/src/plugins/fonts.ts +21 -6
  217. package/src/plugins/routing.ts +5 -348
  218. package/src/routing/codegen.ts +3 -2
  219. package/src/routing/convention-lint.ts +4 -3
  220. package/src/routing/file-cache.ts +23 -0
  221. package/src/routing/interception.ts +2 -21
  222. package/src/routing/manifest-codegen.ts +290 -0
  223. package/src/routing/scanner.ts +5 -17
  224. package/src/routing/schema-validation.ts +2 -2
  225. package/src/schema-bridge.ts +63 -2
  226. package/src/search-params/define.ts +34 -2
  227. package/src/search-params/wrappers.ts +21 -3
  228. package/src/segment-params/define.ts +2 -62
  229. package/src/server/access-gate.tsx +8 -0
  230. package/src/server/actions.ts +8 -31
  231. package/src/server/build-manifest.ts +0 -56
  232. package/src/server/canonicalize.ts +6 -3
  233. package/src/server/deny-renderer.ts +6 -0
  234. package/src/server/html-injector-core.ts +19 -0
  235. package/src/server/html-injectors.ts +14 -4
  236. package/src/server/internal.ts +1 -18
  237. package/src/server/node-stream-transforms.ts +5 -72
  238. package/src/server/render-utils.ts +31 -0
  239. package/src/server/route-element-builder.ts +77 -47
  240. package/src/server/rsc-entry/error-renderer.ts +1 -0
  241. package/src/server/rsc-entry/helpers.ts +6 -34
  242. package/src/server/rsc-entry/index.ts +11 -0
  243. package/src/server/rsc-entry/render-route.ts +7 -9
  244. package/src/server/rsc-entry/rsc-payload.ts +2 -1
  245. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  246. package/src/server/ssr-render.ts +1 -11
  247. package/src/shared/href-validation.ts +8 -0
  248. package/dist/_chunks/cli-schema-sync-CBiMEtqa.js +0 -2
  249. package/dist/_chunks/cli-schema-sync-CQaxupHv.js.map +0 -1
  250. package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
  251. package/dist/_chunks/define-DMVhsipD.js.map +0 -1
  252. package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
  253. package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
  254. package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
  255. package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
  256. package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
  257. package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
  258. package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
  259. package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
  260. package/dist/_chunks/walkers-Ck4Y45XN.js.map +0 -1
  261. package/dist/_chunks/warnings-CNPXaypM.js +0 -125
  262. package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
  263. package/dist/client/link-pending-store.d.ts +0 -65
  264. package/dist/client/link-pending-store.d.ts.map +0 -1
  265. package/dist/client/nav-link-store.d.ts +0 -27
  266. package/dist/client/nav-link-store.d.ts.map +0 -1
  267. package/dist/dev-tools/warnings.d.ts +0 -109
  268. package/dist/dev-tools/warnings.d.ts.map +0 -1
  269. package/dist/server/flush.d.ts +0 -74
  270. package/dist/server/flush.d.ts.map +0 -1
  271. package/src/client/link-pending-store.ts +0 -116
  272. package/src/client/nav-link-store.ts +0 -37
  273. package/src/dev-tools/warnings.ts +0 -238
  274. package/src/server/flush.ts +0 -183
package/src/fonts/ast.ts CHANGED
@@ -19,13 +19,17 @@
19
19
  import { parseAst as viteParseAst } from 'vite';
20
20
  import type { GoogleFontConfig } from './types.js';
21
21
  import type { LocalFontConfig, LocalFontSrc } from './types.js';
22
+ import type { AstNode, IdentifierNode, PositionedNode, FlaggedUsage } from './ast-walk.js';
23
+ import {
24
+ offsetToLineColumn,
25
+ walkForDynamicCalls,
26
+ walkForNestedFontCalls,
27
+ walkForUnconsumedReferences,
28
+ } from './ast-walk.js';
22
29
 
23
- // ── AST node types (minimal subset of estree) ────────────────────────────────
30
+ export type { FlaggedUsage } from './ast-walk.js';
24
31
 
25
- interface AstNode {
26
- type: string;
27
- [key: string]: unknown;
28
- }
32
+ // ── AST node types (font-config-specific, on top of ast-walk.ts's base) ──────
29
33
 
30
34
  interface LiteralNode extends AstNode {
31
35
  type: 'Literal';
@@ -48,26 +52,6 @@ interface PropertyNode extends AstNode {
48
52
  value: AstNode;
49
53
  }
50
54
 
51
- interface IdentifierNode extends AstNode {
52
- type: 'Identifier';
53
- name: string;
54
- }
55
-
56
- // ── Offset → line/column helper ──────────────────────────────────────────────
57
-
58
- /** Convert a 0-based byte offset into a 1-based line and 0-based column. */
59
- function offsetToLineColumn(source: string, offset: number): { line: number; column: number } {
60
- let line = 1;
61
- let lastNewline = -1;
62
- for (let i = 0; i < offset; i++) {
63
- if (source[i] === '\n') {
64
- line++;
65
- lastNewline = i;
66
- }
67
- }
68
- return { line, column: offset - lastNewline - 1 };
69
- }
70
-
71
55
  // ── Core AST extraction helpers ──────────────────────────────────────────────
72
56
 
73
57
  /**
@@ -166,63 +150,7 @@ function objectProperties(node: ObjectExpressionNode): Map<string, AstNode> {
166
150
  export function extractFontConfigAst(callSource: string): GoogleFontConfig | null {
167
151
  const node = parseExpression(callSource);
168
152
  if (!node || node.type !== 'ObjectExpression') return null;
169
-
170
- const props = objectProperties(node as ObjectExpressionNode);
171
- const config: GoogleFontConfig = {};
172
-
173
- // subsets: string[]
174
- const subsetsNode = props.get('subsets');
175
- if (subsetsNode) {
176
- const arr = extractStringArray(subsetsNode);
177
- if (arr) config.subsets = arr;
178
- }
179
-
180
- // weight: string | string[]
181
- const weightNode = props.get('weight');
182
- if (weightNode) {
183
- const arr = extractStringArray(weightNode);
184
- if (arr) {
185
- config.weight = arr;
186
- } else {
187
- const s = extractString(weightNode);
188
- if (s !== undefined) config.weight = s;
189
- }
190
- }
191
-
192
- // display: string
193
- const displayNode = props.get('display');
194
- if (displayNode) {
195
- const s = extractString(displayNode);
196
- if (s !== undefined) config.display = s as GoogleFontConfig['display'];
197
- }
198
-
199
- // variable: string
200
- const variableNode = props.get('variable');
201
- if (variableNode) {
202
- const s = extractString(variableNode);
203
- if (s !== undefined) config.variable = s;
204
- }
205
-
206
- // style: string | string[]
207
- const styleNode = props.get('style');
208
- if (styleNode) {
209
- const arr = extractStringArray(styleNode);
210
- if (arr) {
211
- config.style = arr;
212
- } else {
213
- const s = extractString(styleNode);
214
- if (s !== undefined) config.style = s;
215
- }
216
- }
217
-
218
- // preload: boolean
219
- const preloadNode = props.get('preload');
220
- if (preloadNode) {
221
- const b = extractBoolean(preloadNode);
222
- if (b !== undefined) config.preload = b;
223
- }
224
-
225
- return config;
153
+ return extractGoogleConfig(node as ObjectExpressionNode);
226
154
  }
227
155
 
228
156
  // ── Local font config extraction ─────────────────────────────────────────────
@@ -265,47 +193,7 @@ function extractLocalFontSrcEntry(node: AstNode): LocalFontSrc | null {
265
193
  export function extractLocalFontConfigAst(callSource: string): LocalFontConfig | null {
266
194
  const node = parseExpression(callSource);
267
195
  if (!node || node.type !== 'ObjectExpression') return null;
268
-
269
- const props = objectProperties(node as ObjectExpressionNode);
270
-
271
- // display: string
272
- const displayNode = props.get('display');
273
- const display = displayNode
274
- ? (extractString(displayNode) as LocalFontConfig['display'])
275
- : undefined;
276
-
277
- // variable: string
278
- const variableNode = props.get('variable');
279
- const variable = variableNode ? extractString(variableNode) : undefined;
280
-
281
- // family: string
282
- const familyNode = props.get('family');
283
- const family = familyNode ? extractString(familyNode) : undefined;
284
-
285
- // src: string | LocalFontSrc[]
286
- const srcNode = props.get('src');
287
- if (!srcNode) return null;
288
-
289
- // String form: src: './fonts/MyFont.woff2'
290
- const srcString = extractString(srcNode);
291
- if (srcString !== undefined) {
292
- return { src: srcString, display, variable, family };
293
- }
294
-
295
- // Array form: src: [{ path: '...', weight: '...' }, ...]
296
- if (srcNode.type === 'ArrayExpression') {
297
- const arr = srcNode as ArrayExpressionNode;
298
- const entries: LocalFontSrc[] = [];
299
- for (const elem of arr.elements) {
300
- const entry = extractLocalFontSrcEntry(elem);
301
- if (!entry) return null;
302
- entries.push(entry);
303
- }
304
- if (entries.length === 0) return null;
305
- return { src: entries, display, variable, family };
306
- }
307
-
308
- return null;
196
+ return extractLocalConfig(node as ObjectExpressionNode);
309
197
  }
310
198
 
311
199
  // ── Dynamic call detection ───────────────────────────────────────────────────
@@ -329,51 +217,7 @@ export function detectDynamicFontCallAst(source: string, importedNames: string[]
329
217
  }
330
218
 
331
219
  const nameSet = new Set(importedNames);
332
- const result = walkForDynamicCalls(ast as unknown as AstNode, nameSet, source);
333
- return result;
334
- }
335
-
336
- /**
337
- * Recursively walk the AST looking for CallExpression nodes where
338
- * the callee is one of the imported font names and the first argument
339
- * is not an ObjectExpression (i.e. it's dynamic).
340
- */
341
- function walkForDynamicCalls(node: AstNode, names: Set<string>, source: string): string | null {
342
- if (!node || typeof node !== 'object') return null;
343
-
344
- if (node.type === 'CallExpression') {
345
- const callee = node.callee as AstNode;
346
- if (callee.type === 'Identifier' && names.has((callee as IdentifierNode).name)) {
347
- const args = node.arguments as AstNode[];
348
- if (args.length > 0 && args[0].type !== 'ObjectExpression') {
349
- // Extract the argument source text
350
- const argStart = (args[0] as AstNode & { start: number }).start;
351
- const argEnd = (args[0] as AstNode & { end: number }).end;
352
- const argText = source.slice(argStart, argEnd);
353
- const calleeName = (callee as IdentifierNode).name;
354
- return `${calleeName}(${argText})`;
355
- }
356
- }
357
- }
358
-
359
- // Walk all child properties
360
- for (const key of Object.keys(node)) {
361
- if (key === 'type') continue;
362
- const child = node[key];
363
- if (Array.isArray(child)) {
364
- for (const item of child) {
365
- if (item && typeof item === 'object' && (item as AstNode).type) {
366
- const result = walkForDynamicCalls(item as AstNode, names, source);
367
- if (result) return result;
368
- }
369
- }
370
- } else if (child && typeof child === 'object' && (child as AstNode).type) {
371
- const result = walkForDynamicCalls(child as AstNode, names, source);
372
- if (result) return result;
373
- }
374
- }
375
-
376
- return null;
220
+ return walkForDynamicCalls(ast as unknown as AstNode, nameSet, source);
377
221
  }
378
222
 
379
223
  // ── AST-node-level config extraction ─────────────────────────────────────────
@@ -554,7 +398,12 @@ export interface FontCallSite {
554
398
  export interface FontAnalysis {
555
399
  bindings: FontBinding[];
556
400
  callSites: FontCallSite[];
557
- /** Import nodes that should be removed (deduplicated). */
401
+ /**
402
+ * Import nodes that should be removed (deduplicated). Only imports that
403
+ * produced at least one font binding are listed — removing an import
404
+ * whose bindings the transform doesn't rewrite (e.g. a namespace
405
+ * import) would leave dangling references behind.
406
+ */
558
407
  importNodesToRemove: Array<{ start: number; end: number }>;
559
408
  /** First dynamic call expression text, or `null`. */
560
409
  dynamicCall: string | null;
@@ -562,12 +411,21 @@ export interface FontAnalysis {
562
411
  * Font calls found in nested scopes (functions, if-blocks, etc.).
563
412
  * These are errors — font calls must be module-level.
564
413
  */
565
- nestedCalls: Array<{ name: string; line: number; column: number }>;
566
- }
567
-
568
- interface PositionedNode extends AstNode {
569
- start: number;
570
- end: number;
414
+ nestedCalls: FlaggedUsage[];
415
+ /**
416
+ * Namespace imports of font modules (`import * as fonts from ...`).
417
+ * These are errors — the transform cannot rewrite `fonts.Inter(...)`
418
+ * member calls, so the fonts would silently never load.
419
+ */
420
+ namespaceImports: FlaggedUsage[];
421
+ /**
422
+ * References to font bindings that were not consumed by a recognized
423
+ * call site (`export default Inter({...})`, object-literal nesting,
424
+ * member access on the call result, aliasing, re-exports). These are
425
+ * errors — the import is removed, so any leftover reference would be a
426
+ * silent runtime ReferenceError.
427
+ */
428
+ unsupportedUsages: FlaggedUsage[];
571
429
  }
572
430
 
573
431
  interface ImportDeclarationNode extends PositionedNode {
@@ -661,6 +519,7 @@ export function findFontCallSites(source: string): FontAnalysis | null {
661
519
  // Step 1: Find all font import declarations and build the bindings list.
662
520
  const bindings: FontBinding[] = [];
663
521
  const importNodesToRemove: Array<{ start: number; end: number }> = [];
522
+ const namespaceImports: FlaggedUsage[] = [];
664
523
 
665
524
  for (const stmt of program.body) {
666
525
  if (!isImportDeclaration(stmt)) continue;
@@ -672,8 +531,10 @@ export function findFontCallSites(source: string): FontAnalysis | null {
672
531
  else if (LOCAL_FONT_SOURCES.has(specSource)) provider = 'local';
673
532
  if (!provider) continue;
674
533
 
534
+ let producedBinding = false;
675
535
  for (const spec of importDecl.specifiers) {
676
536
  if (spec.type === 'ImportSpecifier' && provider === 'google') {
537
+ producedBinding = true;
677
538
  bindings.push({
678
539
  localName: spec.local.name,
679
540
  importedName: spec.imported?.name ?? spec.local.name,
@@ -681,27 +542,39 @@ export function findFontCallSites(source: string): FontAnalysis | null {
681
542
  importNode: importDecl,
682
543
  });
683
544
  } else if (spec.type === 'ImportDefaultSpecifier' && provider === 'local') {
545
+ producedBinding = true;
684
546
  bindings.push({
685
547
  localName: spec.local.name,
686
548
  importedName: 'default',
687
549
  provider,
688
550
  importNode: importDecl,
689
551
  });
552
+ } else if (spec.type === 'ImportNamespaceSpecifier') {
553
+ // `import * as fonts from ...` cannot be rewritten — the transform
554
+ // only matches direct identifier calls, so `fonts.Inter(...)` would
555
+ // silently fall through to the runtime fallback module and never
556
+ // load a real font. Collected as a build error.
557
+ const pos = offsetToLineColumn(source, spec.start);
558
+ namespaceImports.push({ name: spec.local.name, line: pos.line, column: pos.column });
690
559
  }
691
- // ImportNamespaceSpecifier (`import * as fonts from ...`) is not
692
- // currently supported — the old regex didn't handle it either.
693
- // If we add it later, we'd need to also handle `fonts.Inter(...)`
694
- // member expressions in the call-site walk.
695
560
  }
696
561
 
697
- importNodesToRemove.push({ start: stmt.start, end: stmt.end });
562
+ // Only remove imports whose bindings the transform actually rewrites.
563
+ // Removing an import that produced no binding (namespace imports,
564
+ // default imports of the google module) would orphan its references.
565
+ if (producedBinding) {
566
+ importNodesToRemove.push({ start: stmt.start, end: stmt.end });
567
+ }
698
568
  }
699
569
 
700
- if (bindings.length === 0) return null;
570
+ if (bindings.length === 0 && namespaceImports.length === 0) return null;
701
571
 
702
572
  // Step 2: Walk the AST for CallExpression nodes whose callee is a font binding.
703
573
  const bindingsByName = new Map(bindings.map((b) => [b.localName, b]));
704
574
  const callSites: FontCallSite[] = [];
575
+ // Start offsets of recognized (and dynamic-flagged) CallExpressions, so
576
+ // the unsupported-reference walk in step 4 can skip them.
577
+ const consumedCallStarts = new Set<number>();
705
578
  let dynamicCall: string | null = null;
706
579
 
707
580
  for (const stmt of program.body) {
@@ -720,6 +593,7 @@ export function findFontCallSites(source: string): FontAnalysis | null {
720
593
  if (!site) continue;
721
594
 
722
595
  if (site === 'dynamic') {
596
+ consumedCallStarts.add(call.start);
723
597
  if (!dynamicCall && isIdentifier(call.callee)) {
724
598
  const argStart = call.arguments[0].start;
725
599
  const argEnd = call.arguments[0].end;
@@ -731,6 +605,7 @@ export function findFontCallSites(source: string): FontAnalysis | null {
731
605
  const assignedName = isIdentifier(declarator.id) ? declarator.id.name : null;
732
606
 
733
607
  const { line, column } = offsetToLineColumn(source, call.start);
608
+ consumedCallStarts.add(call.start);
734
609
  callSites.push({
735
610
  binding: site.binding,
736
611
  assignedName,
@@ -750,6 +625,7 @@ export function findFontCallSites(source: string): FontAnalysis | null {
750
625
  const site = matchCallExpression(call, bindingsByName, source);
751
626
  if (site && site !== 'dynamic') {
752
627
  const { line, column } = offsetToLineColumn(source, call.start);
628
+ consumedCallStarts.add(call.start);
753
629
  callSites.push({
754
630
  binding: site.binding,
755
631
  assignedName: null,
@@ -759,10 +635,13 @@ export function findFontCallSites(source: string): FontAnalysis | null {
759
635
  line,
760
636
  column,
761
637
  });
762
- } else if (site === 'dynamic' && !dynamicCall && isIdentifier(call.callee)) {
763
- const argStart = call.arguments[0].start;
764
- const argEnd = call.arguments[0].end;
765
- dynamicCall = `${call.callee.name}(${source.slice(argStart, argEnd)})`;
638
+ } else if (site === 'dynamic') {
639
+ consumedCallStarts.add(call.start);
640
+ if (!dynamicCall && isIdentifier(call.callee)) {
641
+ const argStart = call.arguments[0].start;
642
+ const argEnd = call.arguments[0].end;
643
+ dynamicCall = `${call.callee.name}(${source.slice(argStart, argEnd)})`;
644
+ }
766
645
  }
767
646
  }
768
647
  }
@@ -771,75 +650,38 @@ export function findFontCallSites(source: string): FontAnalysis | null {
771
650
  // Step 3: Walk nested scopes to find font calls that aren't module-level.
772
651
  // Font calls must be at module scope (same constraint as Next.js).
773
652
  // If the import is removed but a nested call remains, it's a ReferenceError.
774
- const nestedCalls: Array<{ name: string; line: number; column: number }> = [];
653
+ const nestedCalls: FlaggedUsage[] = [];
775
654
  for (const stmt of program.body) {
776
655
  if (stmt.type === 'ImportDeclaration') continue;
777
656
  walkForNestedFontCalls(stmt as AstNode, bindingsByName, source, nestedCalls, false);
778
657
  }
779
658
 
780
- return { bindings, callSites, importNodesToRemove, dynamicCall, nestedCalls };
781
- }
782
-
783
- /**
784
- * Recursively walk an AST node looking for font CallExpressions in nested
785
- * scopes (function bodies, if-blocks, loops, etc.). Font calls must be
786
- * module-level; nested calls are collected as errors.
787
- *
788
- * `isNested` tracks whether we've descended past the top-level statement.
789
- * Top-level VariableDeclarations and ExpressionStatements are already
790
- * handled by the main loop in `findFontCallSites`, so we only flag calls
791
- * found deeper.
792
- */
793
- function walkForNestedFontCalls(
794
- node: AstNode,
795
- bindings: Map<string, FontBinding>,
796
- source: string,
797
- out: Array<{ name: string; line: number; column: number }>,
798
- isNested: boolean
799
- ): void {
800
- if (!node || typeof node !== 'object') return;
801
-
802
- if (node.type === 'CallExpression') {
803
- const callee = node.callee as AstNode;
804
- if (isNested && callee.type === 'Identifier' && bindings.has((callee as IdentifierNode).name)) {
805
- const pos = offsetToLineColumn(source, (node as PositionedNode).start);
806
- out.push({ name: (callee as IdentifierNode).name, line: pos.line, column: pos.column });
807
- return; // Don't recurse into this call's children
808
- }
809
- }
810
-
811
- // These node types introduce a nested scope — any font call inside is an error.
812
- const scopeTypes = new Set([
813
- 'FunctionDeclaration',
814
- 'FunctionExpression',
815
- 'ArrowFunctionExpression',
816
- 'IfStatement',
817
- 'ForStatement',
818
- 'ForInStatement',
819
- 'ForOfStatement',
820
- 'WhileStatement',
821
- 'DoWhileStatement',
822
- 'SwitchStatement',
823
- 'TryStatement',
824
- 'BlockStatement',
825
- 'ClassDeclaration',
826
- 'ClassExpression',
827
- ]);
828
- const enterNested = isNested || scopeTypes.has(node.type);
829
-
830
- for (const key of Object.keys(node)) {
831
- if (key === 'type') continue;
832
- const child = node[key];
833
- if (Array.isArray(child)) {
834
- for (const item of child) {
835
- if (item && typeof item === 'object' && (item as AstNode).type) {
836
- walkForNestedFontCalls(item as AstNode, bindings, source, out, enterNested);
837
- }
838
- }
839
- } else if (child && typeof child === 'object' && (child as AstNode).type) {
840
- walkForNestedFontCalls(child as AstNode, bindings, source, out, enterNested);
841
- }
842
- }
659
+ // Step 4: Walk for references to font bindings that were not consumed by
660
+ // a recognized call site. The transform removes the import declarations,
661
+ // so any leftover reference (`export default Inter({...})`, object-literal
662
+ // nesting, `Inter({...}).className`, `const f = Inter`, `export { Inter }`)
663
+ // would become a silent runtime ReferenceError. Collected as build errors.
664
+ const unsupportedUsages: FlaggedUsage[] = [];
665
+ for (const stmt of program.body) {
666
+ if (stmt.type === 'ImportDeclaration') continue;
667
+ walkForUnconsumedReferences(
668
+ stmt as AstNode,
669
+ bindingsByName,
670
+ consumedCallStarts,
671
+ source,
672
+ unsupportedUsages
673
+ );
674
+ }
675
+
676
+ return {
677
+ bindings,
678
+ callSites,
679
+ importNodesToRemove,
680
+ dynamicCall,
681
+ nestedCalls,
682
+ namespaceImports,
683
+ unsupportedUsages,
684
+ };
843
685
  }
844
686
 
845
687
  /**
@@ -10,9 +10,9 @@
10
10
  */
11
11
 
12
12
  import { readFileSync, existsSync } from 'node:fs';
13
- import { resolve, normalize, extname } from 'node:path';
13
+ import { resolve, normalize } from 'node:path';
14
14
  import type { ManifestFontEntry, BuildManifest } from '../server/build-manifest.js';
15
- import { contentHash } from './google.js';
15
+ import { fontOutputBasename, fontUrlPrefix, FONT_OUTPUT_DIR } from './paths.js';
16
16
 
17
17
  /**
18
18
  * Minimal shape of the asset descriptor accepted by Rollup's `emitFile`.
@@ -63,7 +63,7 @@ export function emitFontAssets(
63
63
  for (const cf of pipeline.uniqueCachedFiles()) {
64
64
  emitFile({
65
65
  type: 'asset',
66
- fileName: `_timber/fonts/${cf.hashedFilename}`,
66
+ fileName: `${FONT_OUTPUT_DIR}/${cf.hashedFilename}`,
67
67
  source: cf.data,
68
68
  });
69
69
  }
@@ -77,11 +77,11 @@ export function emitFontAssets(
77
77
  warn(`Local font file not found: ${absolutePath}`);
78
78
  continue;
79
79
  }
80
- const filename = src.hashedBasename ?? src.path.split('/').pop() ?? src.path;
80
+ const filename = src.hashedBasename ?? fontOutputBasename(src.path);
81
81
  const data = readFileSync(absolutePath);
82
82
  emitFile({
83
83
  type: 'asset',
84
- fileName: `_timber/fonts/${filename}`,
84
+ fileName: `${FONT_OUTPUT_DIR}/${filename}`,
85
85
  source: data,
86
86
  });
87
87
  }
@@ -95,8 +95,15 @@ export function emitFontAssets(
95
95
  * `buildManifest.css` keys work after TIM-1014.
96
96
  *
97
97
  * All font binaries use content-hashed filenames for immutable caching.
98
+ * Hrefs are prefixed with Vite's resolved `base` so they match the URLs
99
+ * the platform serves the emitted binaries under.
98
100
  */
99
- export function writeFontManifest(pipeline: FontPipeline, buildManifest: BuildManifest): void {
101
+ export function writeFontManifest(
102
+ pipeline: FontPipeline,
103
+ buildManifest: BuildManifest,
104
+ base = '/'
105
+ ): void {
106
+ const urlPrefix = fontUrlPrefix(base);
100
107
  const fontsByImporter = new Map<string, ManifestFontEntry[]>();
101
108
 
102
109
  for (const entry of pipeline.entries()) {
@@ -104,10 +111,10 @@ export function writeFontManifest(pipeline: FontPipeline, buildManifest: BuildMa
104
111
 
105
112
  if (entry.provider === 'local' && entry.localSources) {
106
113
  for (const src of entry.localSources) {
107
- const filename = src.hashedBasename ?? src.path.split('/').pop() ?? src.path;
114
+ const filename = src.hashedBasename ?? fontOutputBasename(src.path);
108
115
  const format = inferFontFormat(src.path);
109
116
  manifestEntries.push({
110
- href: `/_timber/fonts/${filename}`,
117
+ href: `${urlPrefix}/${filename}`,
111
118
  format,
112
119
  crossOrigin: 'anonymous',
113
120
  });
@@ -115,7 +122,7 @@ export function writeFontManifest(pipeline: FontPipeline, buildManifest: BuildMa
115
122
  } else if (entry.cachedFiles) {
116
123
  for (const cf of entry.cachedFiles) {
117
124
  manifestEntries.push({
118
- href: `/_timber/fonts/${cf.hashedFilename}`,
125
+ href: `${urlPrefix}/${cf.hashedFilename}`,
119
126
  format: 'woff2',
120
127
  crossOrigin: 'anonymous',
121
128
  });
@@ -14,6 +14,7 @@ import type { ViteDevServer } from 'vite';
14
14
  import { readFileSync, existsSync } from 'node:fs';
15
15
  import { resolve, normalize } from 'node:path';
16
16
  import type { FontPipeline } from './pipeline.js';
17
+ import { fontOutputBasename, fontUrlPrefix } from './paths.js';
17
18
 
18
19
  const FONT_MIME_TYPES: Record<string, string> = {
19
20
  woff2: 'font/woff2',
@@ -30,12 +31,17 @@ const FONT_MIME_TYPES: Record<string, string> = {
30
31
  * captured by reference so that fonts registered after `configureServer`
31
32
  * runs (during transform) are still resolvable.
32
33
  */
33
- export function installFontDevMiddleware(server: ViteDevServer, pipeline: FontPipeline): void {
34
+ export function installFontDevMiddleware(
35
+ server: ViteDevServer,
36
+ pipeline: FontPipeline,
37
+ base = '/'
38
+ ): void {
39
+ const urlPrefix = `${fontUrlPrefix(base)}/`;
34
40
  server.middlewares.use((req, res, next) => {
35
41
  const url = req.url;
36
- if (!url || !url.startsWith('/_timber/fonts/')) return next();
42
+ if (!url || !url.startsWith(urlPrefix)) return next();
37
43
 
38
- const requestedFilename = url.slice('/_timber/fonts/'.length);
44
+ const requestedFilename = url.slice(urlPrefix.length);
39
45
  // Reject path traversal attempts and any subdirectory access. We only
40
46
  // serve flat basenames out of the registry.
41
47
  if (requestedFilename.includes('..') || requestedFilename.includes('/')) {
@@ -50,7 +56,7 @@ export function installFontDevMiddleware(server: ViteDevServer, pipeline: FontPi
50
56
  for (const font of pipeline.fonts()) {
51
57
  if (font.provider !== 'local' || !font.localSources) continue;
52
58
  for (const src of font.localSources) {
53
- const basename = src.path.split('/').pop() ?? '';
59
+ const basename = fontOutputBasename(src.path);
54
60
  if (basename !== requestedFilename && src.hashedBasename !== requestedFilename) continue;
55
61
 
56
62
  const absolutePath = normalize(resolve(src.path));
@@ -62,7 +68,12 @@ export function installFontDevMiddleware(server: ViteDevServer, pipeline: FontPi
62
68
  const data = readFileSync(absolutePath);
63
69
  const ext = absolutePath.split('.').pop()?.toLowerCase();
64
70
  res.setHeader('Content-Type', FONT_MIME_TYPES[ext ?? ''] ?? 'application/octet-stream');
65
- res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
71
+ // Dev URLs are raw (unhashed) basenames — `immutable` is only
72
+ // justified by content hashing (design/24-fonts.md). `no-cache`
73
+ // keeps responses revalidatable so edited fonts update without a
74
+ // hard refresh. Production binaries are content-hashed and served
75
+ // immutable by the platform, not this middleware.
76
+ res.setHeader('Cache-Control', 'no-cache');
66
77
  res.setHeader('Access-Control-Allow-Origin', '*');
67
78
  res.end(data);
68
79
  return;
@@ -8,8 +8,6 @@
8
8
  * Design doc: 24-fonts.md §"Step 4: Size-Adjusted Fallbacks"
9
9
  */
10
10
 
11
- import type { FontFaceDescriptor } from './types.js';
12
-
13
11
  /**
14
12
  * Font metrics for size-adjusted fallback calculation.
15
13
  *
@@ -169,27 +167,6 @@ export function getGenericFamily(family: string): string {
169
167
  return 'sans-serif';
170
168
  }
171
169
 
172
- /**
173
- * Generate a size-adjusted fallback @font-face for a given font family.
174
- *
175
- * Returns null if no metrics are available (unknown font — no fallback generated).
176
- */
177
- export function generateFallbackFontFace(family: string): FontFaceDescriptor | null {
178
- const metrics = FALLBACK_METRICS[family.toLowerCase()];
179
- if (!metrics) return null;
180
-
181
- const fallbackFamily = `${family} Fallback`;
182
-
183
- return {
184
- family: fallbackFamily,
185
- src: `local('${metrics.fallbackFont}')`,
186
- // Encode the metrics into a CSS descriptor string.
187
- // We abuse the 'style' field to carry the override properties
188
- // since FontFaceDescriptor doesn't have dedicated fields.
189
- // The generateFallbackCss function handles this specially.
190
- };
191
- }
192
-
193
170
  /**
194
171
  * Generate the full CSS for a size-adjusted fallback font.
195
172
  *
@@ -19,6 +19,7 @@ import { createHash } from 'node:crypto';
19
19
  import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
20
20
  import { join } from 'node:path';
21
21
  import type { ExtractedFont, FontFaceDescriptor } from './types.js';
22
+ import { fontUrlPrefix } from './paths.js';
22
23
 
23
24
  /** Google Fonts CSS API v2 base URL. */
24
25
  const GOOGLE_FONTS_API = 'https://fonts.googleapis.com/css2';
@@ -281,15 +282,18 @@ async function isCacheHit(metaPath: string, dataPath: string): Promise<boolean>
281
282
  * Generate @font-face descriptors for cached (production) Google Fonts.
282
283
  *
283
284
  * Each CachedFont gets a FontFaceDescriptor pointing to the
284
- * content-hashed URL under `/_timber/fonts/`.
285
+ * content-hashed URL under `<base>_timber/fonts/`. The `urlPrefix` comes
286
+ * from `fontUrlPrefix(viteBase)` so apps deployed under a non-root base
287
+ * resolve fonts correctly.
285
288
  */
286
289
  export function generateProductionFontFaces(
287
290
  cachedFonts: CachedFont[],
288
- display: string
291
+ display: string,
292
+ urlPrefix = fontUrlPrefix()
289
293
  ): FontFaceDescriptor[] {
290
294
  return cachedFonts.map((cf) => ({
291
295
  family: cf.face.family,
292
- src: `url('/_timber/fonts/${cf.hashedFilename}') format('woff2')`,
296
+ src: `url('${urlPrefix}/${cf.hashedFilename}') format('woff2')`,
293
297
  weight: cf.face.weight,
294
298
  style: cf.face.style,
295
299
  display,