@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
@@ -17,6 +17,7 @@ import { resolve, dirname, extname } from 'node:path';
17
17
  import type { LocalFontConfig, LocalFontSrc, FontFaceDescriptor, ExtractedFont } from './types.js';
18
18
  import { buildFontStack } from './fallbacks.js';
19
19
  import { extractLocalFontConfigAst } from './ast.js';
20
+ import { fontOutputBasename, fontUrlPrefix } from './paths.js';
20
21
 
21
22
  /**
22
23
  * Infer the font format from a file extension.
@@ -85,7 +86,7 @@ export function resolveLocalFontPaths(
85
86
  export function generateFamilyName(sources: LocalFontSrc[]): string {
86
87
  if (sources.length === 0) return 'Local Font';
87
88
  const firstPath = sources[0].path;
88
- const basename = firstPath.split('/').pop() ?? firstPath;
89
+ const basename = fontOutputBasename(firstPath);
89
90
  // Remove extension and weight/style suffixes for a cleaner family name
90
91
  const stem = basename.replace(/\.[^.]+$/, '');
91
92
  // Strip common weight/style suffixes to get the family root
@@ -100,19 +101,19 @@ export function generateFamilyName(sources: LocalFontSrc[]): string {
100
101
  * Generate @font-face descriptors for local font sources.
101
102
  *
102
103
  * Each source entry produces one @font-face rule. The `src` descriptor
103
- * uses a `url()` pointing to the served path under `/_timber/fonts/`.
104
- * The `urlPrefix` defaults to `/_timber/fonts` — the path used by both
105
- * the dev server middleware and the production build output.
104
+ * uses a `url()` pointing to the served path under `<base>_timber/fonts/`.
105
+ * The `urlPrefix` defaults to `fontUrlPrefix()` (root base) — pass
106
+ * `fontUrlPrefix(viteBase)` so non-root deployments resolve correctly.
106
107
  */
107
108
  export function generateLocalFontFaces(
108
109
  family: string,
109
110
  sources: LocalFontSrc[],
110
111
  display: string,
111
- urlPrefix = '/_timber/fonts'
112
+ urlPrefix = fontUrlPrefix()
112
113
  ): FontFaceDescriptor[] {
113
114
  return sources.map((entry) => {
114
115
  const format = inferFontFormat(entry.path);
115
- const filename = entry.hashedBasename ?? entry.path.split('/').pop() ?? entry.path;
116
+ const filename = entry.hashedBasename ?? fontOutputBasename(entry.path);
116
117
  return {
117
118
  family,
118
119
  src: `url('${urlPrefix}/${filename}') format('${format}')`,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Shared path/URL helpers for the timber-fonts pipeline.
3
+ *
4
+ * Two concerns live here:
5
+ *
6
+ * - `fontUrlPrefix` — the public URL prefix for font binaries, derived
7
+ * from Vite's resolved `base`. Every URL the pipeline emits (@font-face
8
+ * src, manifest hrefs, dev middleware matching) must go through this so
9
+ * apps deployed under a non-root base (`base: '/my-app/'`) resolve
10
+ * fonts correctly. Emitted *file* locations are not base-prefixed —
11
+ * only URLs are.
12
+ *
13
+ * - `fontOutputBasename` / `hashedFontBasename` — basename derivation for
14
+ * local font sources. Paths are absolutized with `resolve()`, which
15
+ * produces backslash separators on Windows, so `split('/').pop()` is
16
+ * not safe. `path.win32.basename` handles both separators.
17
+ *
18
+ * Design doc: 24-fonts.md
19
+ */
20
+
21
+ import { win32 } from 'node:path';
22
+
23
+ /** Directory (relative to the build output / URL root) holding font binaries. */
24
+ export const FONT_OUTPUT_DIR = '_timber/fonts';
25
+
26
+ /**
27
+ * Build the URL prefix for font binaries from Vite's resolved `base`.
28
+ *
29
+ * `fontUrlPrefix('/')` → `/_timber/fonts`
30
+ * `fontUrlPrefix('/my-app/')` → `/my-app/_timber/fonts`
31
+ */
32
+ export function fontUrlPrefix(base = '/'): string {
33
+ const normalized = base.endsWith('/') ? base : `${base}/`;
34
+ return `${normalized}${FONT_OUTPUT_DIR}`;
35
+ }
36
+
37
+ /**
38
+ * Derive the output basename for a local font source path.
39
+ *
40
+ * Uses `path.win32.basename` because it splits on both `/` and `\`,
41
+ * handling absolute paths produced by `resolve()` on any platform.
42
+ */
43
+ export function fontOutputBasename(srcPath: string): string {
44
+ return win32.basename(srcPath);
45
+ }
46
+
47
+ /**
48
+ * Derive the content-hashed output basename for a local font source.
49
+ *
50
+ * `hashedFontBasename('C:\\fonts\\MyFont-Bold.woff2', 'a1b2c3d4')`
51
+ * → `MyFont-Bold-a1b2c3d4.woff2`
52
+ */
53
+ export function hashedFontBasename(srcPath: string, hash: string): string {
54
+ const basename = fontOutputBasename(srcPath);
55
+ const ext = win32.extname(basename);
56
+ const stem = ext ? basename.slice(0, -ext.length) : basename;
57
+ return `${stem}-${hash}${ext}`;
58
+ }
@@ -27,27 +27,16 @@
27
27
  */
28
28
 
29
29
  import { readFileSync, existsSync } from 'node:fs';
30
- import { resolve, normalize, extname } from 'node:path';
30
+ import { resolve, normalize } from 'node:path';
31
31
  import type { ExtractedFont, FontEntry, FontFaceDescriptor } from './types.js';
32
32
  import type { CachedFont } from './google.js';
33
33
  import { contentHash } from './google.js';
34
34
  import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
35
35
  import { generateFallbackCss } from './fallbacks.js';
36
36
  import { generateLocalFontFaces } from './local.js';
37
+ import { hashedFontBasename, fontUrlPrefix } from './paths.js';
37
38
  import { VirtualFontCssIdMap } from './virtual-modules.js';
38
39
 
39
- /**
40
- * Backwards-compatible alias for the registry shape that the standalone
41
- * `pruneRegistryEntries` and `generateAllFontCss` helpers operate on.
42
- *
43
- * The `FontPipeline` itself stores `Map<string, FontEntry>` internally and
44
- * never exposes its underlying map. Tests and external callers that build
45
- * their own font registries (e.g. fixtures in `tests/fonts-hmr-prune.test.ts`)
46
- * still pass plain `Map<string, ExtractedFont>` instances to the standalone
47
- * helpers, which is why the alias survives.
48
- */
49
- export type FontRegistry = Map<string, ExtractedFont>;
50
-
51
40
  export class FontPipeline {
52
41
  /**
53
42
  * The single per-font store. Keyed by `FontEntry.id` (which is itself
@@ -62,10 +51,21 @@ export class FontPipeline {
62
51
  /** When true, local font sources are content-hashed at registration time. */
63
52
  private readonly _hashOnRegister: boolean;
64
53
 
54
+ /**
55
+ * Vite's resolved `base`. Set from the plugin's `configResolved` hook so
56
+ * every @font-face URL the pipeline renders carries the prefix.
57
+ */
58
+ private _base = '/';
59
+
65
60
  constructor(opts?: { hashOnRegister?: boolean }) {
66
61
  this._hashOnRegister = opts?.hashOnRegister ?? false;
67
62
  }
68
63
 
64
+ /** Set Vite's resolved `base` (from `configResolved`). */
65
+ setBase(base: string): void {
66
+ this._base = base;
67
+ }
68
+
69
69
  /**
70
70
  * Per-importer virtual CSS module id registry. Populated by the
71
71
  * transform hook when it inlines a font function call, read by the
@@ -156,11 +156,7 @@ export class FontPipeline {
156
156
  const absolutePath = normalize(resolve(src.path));
157
157
  if (!existsSync(absolutePath)) continue;
158
158
  const data = readFileSync(absolutePath);
159
- const hash = contentHash(data);
160
- const basename = src.path.split('/').pop() ?? src.path;
161
- const ext = extname(basename);
162
- const stem = ext ? basename.slice(0, -ext.length) : basename;
163
- src.hashedBasename = `${stem}-${hash}${ext}`;
159
+ src.hashedBasename = hashedFontBasename(src.path, contentHash(data));
164
160
  }
165
161
  }
166
162
  this._entries.set(entry.id, entry);
@@ -247,9 +243,10 @@ export class FontPipeline {
247
243
  * or lazily in `load` for dev).
248
244
  */
249
245
  getCss(): string {
246
+ const urlPrefix = fontUrlPrefix(this._base);
250
247
  const cssParts: string[] = [];
251
248
  for (const entry of this._entries.values()) {
252
- cssParts.push(renderEntryCss(entry));
249
+ cssParts.push(renderEntryCss(entry, urlPrefix));
253
250
  }
254
251
  return cssParts.join('\n\n');
255
252
  }
@@ -271,10 +268,11 @@ export class FontPipeline {
271
268
  * pipeline handles it as a valid empty CSS module.
272
269
  */
273
270
  getCssForSegment(importer: string): string {
271
+ const urlPrefix = fontUrlPrefix(this._base);
274
272
  const cssParts: string[] = [];
275
273
  for (const entry of this._entries.values()) {
276
274
  if (entry.importer !== importer) continue;
277
- cssParts.push(renderEntryCss(entry));
275
+ cssParts.push(renderEntryCss(entry, urlPrefix));
278
276
  }
279
277
  return cssParts.join('\n\n');
280
278
  }
@@ -283,16 +281,19 @@ export class FontPipeline {
283
281
  /**
284
282
  * Render the combined CSS for a single font entry.
285
283
  *
286
- * Mirrors the standalone `generateFontCss` helper exported from
287
- * `virtual-modules.ts`, but reads `faces` straight off the entry instead
288
- * of from a parallel map. Kept private to this module so the only public
289
- * way to render a `FontEntry`'s CSS is through `FontPipeline.getCss()`.
284
+ * Reads `faces` straight off the entry. Private to this module — the only
285
+ * public way to render a `FontEntry`'s CSS is through `FontPipeline.getCss()`.
290
286
  */
291
- function renderEntryCss(entry: FontEntry): string {
287
+ function renderEntryCss(entry: FontEntry, urlPrefix: string): string {
292
288
  const cssParts: string[] = [];
293
289
 
294
290
  if (entry.provider === 'local' && entry.localSources) {
295
- const faces = generateLocalFontFaces(entry.family, entry.localSources, entry.display);
291
+ const faces = generateLocalFontFaces(
292
+ entry.family,
293
+ entry.localSources,
294
+ entry.display,
295
+ urlPrefix
296
+ );
296
297
  const faceCss = generateFontFaces(faces);
297
298
  if (faceCss) cssParts.push(faceCss);
298
299
  }
@@ -313,25 +314,3 @@ function renderEntryCss(entry: FontEntry): string {
313
314
 
314
315
  return cssParts.join('\n\n');
315
316
  }
316
-
317
- /**
318
- * Drop every registry entry that originated from `importer` and shares
319
- * `family`. Standalone helper retained as a public export for the test
320
- * suite (`tests/fonts-hmr-prune.test.ts`) and any external callers that
321
- * still build their own `FontRegistry` maps.
322
- *
323
- * Prefer `FontPipeline#pruneFor` inside the plugin itself — it also drops
324
- * attached `faces` and `cachedFiles` in the same operation.
325
- */
326
- export function pruneRegistryEntries(
327
- registry: FontRegistry,
328
- importer: string,
329
- family: string
330
- ): void {
331
- const familyKey = family.toLowerCase();
332
- for (const [id, font] of registry) {
333
- if (font.importer === importer && font.family.toLowerCase() === familyKey) {
334
- registry.delete(id);
335
- }
336
- }
337
- }
@@ -221,6 +221,33 @@ export function runFontsTransform(
221
221
  );
222
222
  }
223
223
 
224
+ // Bail on namespace imports of font modules — the transform cannot
225
+ // rewrite `fonts.Inter(...)` member calls, so the font would silently
226
+ // never load.
227
+ if (analysis.namespaceImports.length > 0) {
228
+ const first = analysis.namespaceImports[0];
229
+ emitError(
230
+ `Namespace imports of font modules are not supported ` +
231
+ `(\`* as ${first.name}\` at line ${first.line}, column ${first.column}). ` +
232
+ `Use a named import instead: \`import { Inter } from 'next/font/google'\`.`
233
+ );
234
+ }
235
+
236
+ // Bail on font binding references the transform cannot rewrite
237
+ // (`export default Inter({...})`, object-literal nesting, member access
238
+ // on the call result, aliasing, re-exports). The import declaration is
239
+ // removed, so leaving these would be a silent runtime ReferenceError.
240
+ if (analysis.unsupportedUsages.length > 0) {
241
+ const first = analysis.unsupportedUsages[0];
242
+ emitError(
243
+ `Unsupported usage of font function \`${first.name}\` at ` +
244
+ `line ${first.line}, column ${first.column}. ` +
245
+ `A font call must be \`const x = Font({...})\` at module scope ` +
246
+ `(export const is fine). The call result cannot be nested in other ` +
247
+ `expressions, and the imported font function cannot be aliased or re-exported.`
248
+ );
249
+ }
250
+
224
251
  if (analysis.callSites.length === 0 && analysis.bindings.length === 0) return null;
225
252
 
226
253
  const s = new MagicString(code);
@@ -30,10 +30,7 @@
30
30
  */
31
31
 
32
32
  import { createHash } from 'node:crypto';
33
- import type { ExtractedFont, FontFaceDescriptor } from './types.js';
34
- import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
35
- import { generateFallbackCss } from './fallbacks.js';
36
- import { generateLocalFontFaces } from './local.js';
33
+ import type { ExtractedFont } from './types.js';
37
34
 
38
35
  export const VIRTUAL_GOOGLE = '@timber/fonts/google';
39
36
  export const VIRTUAL_LOCAL = '@timber/fonts/local';
@@ -180,56 +177,3 @@ export function generateLocalVirtualModule(): string {
180
177
  '}',
181
178
  ].join('\n');
182
179
  }
183
-
184
- /**
185
- * Generate combined CSS for a single extracted font.
186
- *
187
- * Includes `@font-face` rules (local or Google), the size-adjusted fallback
188
- * `@font-face`, and the scoped class rule. For Google fonts the resolved
189
- * `FontFaceDescriptor[]` must be passed in (from `generateProductionFontFaces`
190
- * in production or `resolveDevFontFaces` in dev).
191
- */
192
- export function generateFontCss(font: ExtractedFont, googleFaces?: FontFaceDescriptor[]): string {
193
- const cssParts: string[] = [];
194
-
195
- if (font.provider === 'local' && font.localSources) {
196
- const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
197
- const faceCss = generateFontFaces(faces);
198
- if (faceCss) cssParts.push(faceCss);
199
- }
200
-
201
- if (font.provider === 'google' && googleFaces && googleFaces.length > 0) {
202
- const faceCss = generateFontFaces(googleFaces);
203
- if (faceCss) cssParts.push(faceCss);
204
- }
205
-
206
- const fallbackCss = generateFallbackCss(font.family);
207
- if (fallbackCss) cssParts.push(fallbackCss);
208
-
209
- if (font.variable) {
210
- cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
211
- } else {
212
- cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
213
- }
214
-
215
- return cssParts.join('\n\n');
216
- }
217
-
218
- /**
219
- * Generate the CSS output for all extracted fonts.
220
- *
221
- * Includes `@font-face` rules for local and Google fonts, fallback rules,
222
- * and scoped classes. `googleFontFacesMap` provides pre-resolved
223
- * `FontFaceDescriptor[]` for each Google font (keyed by `ExtractedFont.id`).
224
- */
225
- export function generateAllFontCss(
226
- registry: Map<string, ExtractedFont>,
227
- googleFontFacesMap?: Map<string, FontFaceDescriptor[]>
228
- ): string {
229
- const cssParts: string[] = [];
230
- for (const font of registry.values()) {
231
- const googleFaces = googleFontFacesMap?.get(font.id);
232
- cssParts.push(generateFontCss(font, googleFaces));
233
- }
234
- return cssParts.join('\n\n');
235
- }
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Plugin, PluginOption } from 'vite';
14
- import { join, resolve } from 'node:path';
14
+ import { join, relative, resolve } from 'node:path';
15
15
  import { createRequire } from 'node:module';
16
16
  import react, { reactCompilerPreset } from '@vitejs/plugin-react';
17
17
  import { timberContent } from './plugins/content';
@@ -46,7 +46,6 @@ import {
46
46
  resolveAppDir,
47
47
  resolveClientJavascript,
48
48
  resolveBuildDir,
49
- DEFAULT_BUILD_DIR,
50
49
  } from './plugin-context.js';
51
50
 
52
51
  // ── Public API ────────────────────────────────────────────────────────────
@@ -251,7 +250,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
251
250
  ctx.timer.start('config-load');
252
251
  const fileConfig = loadTimberConfigFile(viteRoot);
253
252
  if (fileConfig) {
254
- mergeFileConfig(ctx, fileConfig);
253
+ ctx.config = mergeFileConfig(ctx.config, fileConfig);
255
254
  ctx.clientJavascript = resolveClientJavascript(ctx.config);
256
255
  }
257
256
  // Apply defaults AFTER merge so file-based config isn't overridden
@@ -353,14 +352,9 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
353
352
  resolvedDevPortExplicit = start.explicit;
354
353
  }
355
354
 
356
- // Return the resolved buildDir as Vite's build.outDir so the RSC
357
- // plugin and all environment builds write to the correct location.
358
- // This is always set — either from timber config, Vite config, or default.
359
- const buildOutDir = timberBuildDir
360
- ? timberBuildDir
361
- : viteOutDir && viteOutDir !== 'dist'
362
- ? viteOutDir
363
- : DEFAULT_BUILD_DIR;
355
+ // Derive relative buildOutDir from the absolute ctx.buildDir (set above)
356
+ // so Vite's config receives a root-relative path.
357
+ const buildOutDir = relative(resolvedRoot, ctx.buildDir);
364
358
 
365
359
  // Set per-environment build.outDir so Vite's RSC plugin writes
366
360
  // each environment to the correct subdirectory under our buildDir.
@@ -10,39 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Codec } from '../codec.js';
13
- import { isCodec, isStandardSchema, validateSync } from '../schema-bridge.js';
14
- import type { StandardSchemaV1 } from '../schema-bridge.js';
15
-
16
- /**
17
- * Wrap a Standard Schema into a Codec for route params.
18
- * Parse throws on failure (invalid param → 404). Serialize returns string.
19
- */
20
- function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
21
- return {
22
- parse(value: string | string[] | undefined): T {
23
- const result = validateSync(schema, value);
24
- if (!result.issues) {
25
- return result.value;
26
- }
27
- const messages = result.issues.map((i) => i.message).join(', ');
28
- throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
29
- },
30
- serialize(value: T): string | null {
31
- if (value === null || value === undefined) return null;
32
- if (Array.isArray(value)) return value.join('/');
33
- return String(value);
34
- },
35
- };
36
- }
37
-
38
- /**
39
- * Resolve a schema entry to a Codec. Accepts Codec<T> or StandardSchemaV1<T>.
40
- */
41
- function resolveCodec(fieldName: string, value: unknown): Codec<unknown> {
42
- if (isCodec(value)) return value;
43
- if (isStandardSchema(value)) return fromParamSchema(fieldName, value);
44
- throw new Error(`[timber] Schema field '${fieldName}' is not a valid codec or Standard Schema.`);
45
- }
13
+ import { isCodec, resolveCodecOrSchema } from '../schema-bridge.js';
46
14
 
47
15
  /**
48
16
  * Reconstruct the bracket key for a segment from its type and param name.
@@ -109,7 +77,7 @@ export function resolveSchemaCodecs(
109
77
  `(e.g. codec.optionalCatchAll(codec.string)). Got a non-optional catch-all codec.`
110
78
  );
111
79
  }
112
- resolved[bracketName] = resolveCodec(bracketName, value);
80
+ resolved[bracketName] = resolveCodecOrSchema(bracketName, value, 'param');
113
81
  }
114
82
 
115
83
  return resolved;
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync } from 'node:fs';
12
- import { join } from 'node:path';
12
+ import { join, resolve } from 'node:path';
13
13
  import { createRequire } from 'node:module';
14
14
  import type { RouteTree } from './routing/types';
15
15
  import type { BuildManifest } from './server/build-manifest';
@@ -168,22 +168,73 @@ export function resolveBuildDir(
168
168
  viteBuildOutDir?: string
169
169
  ): string {
170
170
  if (timberBuildDir) {
171
- return join(root, timberBuildDir);
171
+ return resolve(root, timberBuildDir);
172
172
  }
173
173
  if (viteBuildOutDir && viteBuildOutDir !== 'dist') {
174
- return join(root, viteBuildOutDir);
174
+ return resolve(root, viteBuildOutDir);
175
175
  }
176
176
  return join(root, DEFAULT_BUILD_DIR);
177
177
  }
178
178
 
179
179
  // ── Config file loading ───────────────────────────────────────────────────
180
180
 
181
+ /**
182
+ * Map known timber.config load failures to actionable hints (TIM-1040).
183
+ * Returns null when the error has no known remedy beyond its own message.
184
+ *
185
+ * Known failure modes of loading timber.config.ts via native require():
186
+ * - Node < 22.18 has no default type stripping → ERR_UNKNOWN_FILE_EXTENSION
187
+ * - Non-erasable TS syntax (enums, namespaces, parameter properties)
188
+ * → ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX / ERR_INVALID_TYPESCRIPT_SYNTAX
189
+ * - ESM syntax in a `"type": "commonjs"` package → the stripped .ts is
190
+ * parsed as CJS and throws SyntaxError: "Unexpected token 'export'"
191
+ * (export statements) or "Cannot use import statement outside a
192
+ * module" (import statements)
193
+ */
194
+ export function configLoadHint(configName: string, err: unknown): string | null {
195
+ const code = (err as NodeJS.ErrnoException | null)?.code;
196
+ const isTs = configName.endsWith('.ts');
197
+
198
+ if (isTs && code === 'ERR_UNKNOWN_FILE_EXTENSION') {
199
+ return (
200
+ 'Loading a TypeScript config requires Node.js 22.18+ (built-in type stripping). ' +
201
+ 'Upgrade Node.js or rewrite the config as timber.config.js.'
202
+ );
203
+ }
204
+ if (code === 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX' || code === 'ERR_INVALID_TYPESCRIPT_SYNTAX') {
205
+ return (
206
+ 'Node.js loads the config with type stripping, which only supports erasable TypeScript syntax — ' +
207
+ 'replace enums, namespaces, and constructor parameter properties with plain objects and types.'
208
+ );
209
+ }
210
+ if (
211
+ isTs &&
212
+ err instanceof SyntaxError &&
213
+ /Unexpected token '(export|import)'|Cannot use import statement outside a module/.test(
214
+ err.message
215
+ )
216
+ ) {
217
+ return (
218
+ 'The config uses ESM syntax but Node.js parsed it as CommonJS — ' +
219
+ 'set "type": "module" in package.json, or use module.exports instead of export default.'
220
+ );
221
+ }
222
+ return null;
223
+ }
224
+
181
225
  /**
182
226
  * Load timber.config.ts (or .js, .mjs) from the project root.
183
227
  * Returns the config object or null if no config file is found.
228
+ * Throws a descriptive error naming the file when a config file exists
229
+ * but fails to load — a broken config must fail loudly, never silently
230
+ * degrade (see TIM-1066). Known failure modes get an actionable hint
231
+ * appended (see configLoadHint, TIM-1040).
184
232
  *
185
- * Uses require() which works for ESM modules on Node 22.12+.
186
- * This keeps timber() synchronous — no async config loading needed.
233
+ * Uses require() — Node 22.12+ can require() ESM modules, and Node
234
+ * 22.18+ strips types from .ts files by default (hence the engines
235
+ * field requiring >=22.18.0). This keeps timber() synchronous — no
236
+ * async config loading needed.
237
+ * This is the single config loader — dev, build, and preview all use it.
187
238
  */
188
239
  export function loadTimberConfigFile(root: string): TimberUserConfig | null {
189
240
  const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
@@ -192,8 +243,17 @@ export function loadTimberConfigFile(root: string): TimberUserConfig | null {
192
243
  for (const name of configNames) {
193
244
  const configPath = join(root, name);
194
245
  if (existsSync(configPath)) {
195
- const mod = req(configPath);
196
- return (mod.default ?? mod) as TimberUserConfig;
246
+ try {
247
+ const mod = req(configPath);
248
+ return (mod.default ?? mod) as TimberUserConfig;
249
+ } catch (err) {
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ const hint = configLoadHint(name, err);
252
+ throw new Error(
253
+ `[timber] Failed to load ${configPath}: ${message}${hint ? `\n${hint}` : ''}`,
254
+ { cause: err }
255
+ );
256
+ }
197
257
  }
198
258
  }
199
259
  return null;
@@ -228,20 +288,38 @@ export function warnConfigConflicts(
228
288
  }
229
289
 
230
290
  /**
231
- * Merge file-based config into ctx.config. Inline config (already in ctx.config)
232
- * takes precedence — file config only fills in missing fields.
291
+ * Drop own keys whose value is `undefined` so they don't clobber file
292
+ * config during spread merging. `timber({ buildDir: process.env.BUILD_DIR })`
293
+ * with the var unset must not erase the timber.config.ts value.
294
+ */
295
+ function stripUndefined<T extends object>(obj: T): T {
296
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T;
297
+ }
298
+
299
+ /**
300
+ * Merge file-based config (timber.config.ts) with inline config (vite.config.ts).
301
+ * Inline config takes precedence — file config only fills in missing fields.
302
+ * Inline keys explicitly set to `undefined` are treated as absent, matching
303
+ * the `!== undefined` check in warnConfigConflicts.
233
304
  */
234
- export function mergeFileConfig(ctx: PluginContext, fileConfig: TimberUserConfig): void {
235
- const inline = ctx.config;
305
+ export function mergeFileConfig(
306
+ inline: TimberUserConfig,
307
+ fileConfig: TimberUserConfig
308
+ ): TimberUserConfig {
236
309
  warnConfigConflicts(inline, fileConfig);
310
+ const definedInline = stripUndefined(inline);
237
311
 
238
- ctx.config = {
312
+ return {
239
313
  ...fileConfig,
240
- ...inline,
241
- ...(fileConfig.limits && inline.limits
242
- ? { limits: { ...fileConfig.limits, ...inline.limits } }
314
+ ...definedInline,
315
+ ...(fileConfig.limits && definedInline.limits
316
+ ? { limits: { ...fileConfig.limits, ...stripUndefined(definedInline.limits) } }
317
+ : {}),
318
+ ...(fileConfig.dev && definedInline.dev
319
+ ? { dev: { ...fileConfig.dev, ...stripUndefined(definedInline.dev) } }
320
+ : {}),
321
+ ...(fileConfig.mdx && definedInline.mdx
322
+ ? { mdx: { ...fileConfig.mdx, ...stripUndefined(definedInline.mdx) } }
243
323
  : {}),
244
- ...(fileConfig.dev && inline.dev ? { dev: { ...fileConfig.dev, ...inline.dev } } : {}),
245
- ...(fileConfig.mdx && inline.mdx ? { mdx: { ...fileConfig.mdx, ...inline.mdx } } : {}),
246
324
  };
247
325
  }