@timber-js/app 0.2.0-alpha.87 → 0.2.0-alpha.88

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 (55) hide show
  1. package/LICENSE +8 -0
  2. package/dist/client/index.d.ts +44 -1
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/link.d.ts +7 -44
  6. package/dist/client/link.d.ts.map +1 -1
  7. package/dist/config-types.d.ts +39 -0
  8. package/dist/config-types.d.ts.map +1 -1
  9. package/dist/fonts/bundle.d.ts +48 -0
  10. package/dist/fonts/bundle.d.ts.map +1 -0
  11. package/dist/fonts/dev-middleware.d.ts +22 -0
  12. package/dist/fonts/dev-middleware.d.ts.map +1 -0
  13. package/dist/fonts/pipeline.d.ts +138 -0
  14. package/dist/fonts/pipeline.d.ts.map +1 -0
  15. package/dist/fonts/transform.d.ts +72 -0
  16. package/dist/fonts/transform.d.ts.map +1 -0
  17. package/dist/fonts/types.d.ts +45 -1
  18. package/dist/fonts/types.d.ts.map +1 -1
  19. package/dist/fonts/virtual-modules.d.ts +59 -0
  20. package/dist/fonts/virtual-modules.d.ts.map +1 -0
  21. package/dist/index.js +742 -573
  22. package/dist/index.js.map +1 -1
  23. package/dist/plugins/entries.d.ts.map +1 -1
  24. package/dist/plugins/fonts.d.ts +16 -83
  25. package/dist/plugins/fonts.d.ts.map +1 -1
  26. package/dist/server/action-client.d.ts +8 -0
  27. package/dist/server/action-client.d.ts.map +1 -1
  28. package/dist/server/action-handler.d.ts +7 -0
  29. package/dist/server/action-handler.d.ts.map +1 -1
  30. package/dist/server/index.js +158 -2
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/route-matcher.d.ts +7 -0
  33. package/dist/server/route-matcher.d.ts.map +1 -1
  34. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  35. package/dist/server/sensitive-fields.d.ts +74 -0
  36. package/dist/server/sensitive-fields.d.ts.map +1 -0
  37. package/package.json +6 -7
  38. package/src/cli.ts +0 -0
  39. package/src/client/index.ts +77 -1
  40. package/src/client/link.tsx +15 -65
  41. package/src/config-types.ts +39 -0
  42. package/src/fonts/bundle.ts +142 -0
  43. package/src/fonts/dev-middleware.ts +74 -0
  44. package/src/fonts/pipeline.ts +275 -0
  45. package/src/fonts/transform.ts +353 -0
  46. package/src/fonts/types.ts +50 -1
  47. package/src/fonts/virtual-modules.ts +159 -0
  48. package/src/plugins/entries.ts +37 -0
  49. package/src/plugins/fonts.ts +102 -704
  50. package/src/plugins/routing.ts +6 -5
  51. package/src/server/action-client.ts +34 -4
  52. package/src/server/action-handler.ts +32 -2
  53. package/src/server/route-matcher.ts +7 -0
  54. package/src/server/rsc-entry/index.ts +19 -3
  55. package/src/server/sensitive-fields.ts +230 -0
package/dist/index.js CHANGED
@@ -1612,6 +1612,25 @@ function stripRootPrefix(id, root) {
1612
1612
  *
1613
1613
  * Serializes output mode and feature flags for runtime consumption.
1614
1614
  */
1615
+ /**
1616
+ * Extract the JSON-serializable subset of `forms` config.
1617
+ *
1618
+ * Drops function-valued `stripSensitiveFields` with a build-time warning —
1619
+ * functions cannot cross the JSON boundary to the runtime, so users must
1620
+ * configure function predicates per-action via `createActionClient` instead.
1621
+ */
1622
+ function serializeFormsConfig(forms) {
1623
+ if (!forms) return void 0;
1624
+ const opt = forms.stripSensitiveFields;
1625
+ if (opt === void 0) return {};
1626
+ if (typeof opt === "boolean") return { stripSensitiveFields: opt };
1627
+ if (Array.isArray(opt)) return { stripSensitiveFields: opt.filter((v) => typeof v === "string") };
1628
+ if (typeof opt === "function") {
1629
+ console.warn("[timber] forms.stripSensitiveFields was set to a function in timber.config.ts — this is not supported at the global level (functions cannot be serialized into the runtime config). Use a per-action override via `createActionClient({ stripSensitiveFields: (name) => ... })` instead. The built-in deny-list will be used globally.");
1630
+ return {};
1631
+ }
1632
+ return {};
1633
+ }
1615
1634
  function generateConfigModule(ctx) {
1616
1635
  const runtimeConfig = {
1617
1636
  output: ctx.config.output ?? "server",
@@ -1626,6 +1645,7 @@ function generateConfigModule(ctx) {
1626
1645
  serverTiming: ctx.config.serverTiming,
1627
1646
  renderTimeoutMs: ctx.config.renderTimeoutMs ?? 3e4,
1628
1647
  sitemap: ctx.config.sitemap,
1648
+ forms: serializeFormsConfig(ctx.config.forms),
1629
1649
  deploymentId: ctx.deploymentId ?? null
1630
1650
  };
1631
1651
  return [
@@ -13147,7 +13167,7 @@ function timberRouting(ctx) {
13147
13167
  load(id) {
13148
13168
  if (id !== RESOLVED_VIRTUAL_ID$1) return null;
13149
13169
  if (!ctx.routeTree) rescan();
13150
- return generateManifestModule(ctx.routeTree);
13170
+ return generateManifestModule(ctx.routeTree, ctx.root);
13151
13171
  },
13152
13172
  buildStart() {
13153
13173
  if (ctx.dev) return;
@@ -13157,7 +13177,7 @@ function timberRouting(ctx) {
13157
13177
  rescan();
13158
13178
  devServer.watcher.add(ctx.appDir);
13159
13179
  /** Snapshot of the last generated manifest, used to detect structural changes. */
13160
- let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : "";
13180
+ let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : "";
13161
13181
  /**
13162
13182
  * Handle a route-significant file being added or removed.
13163
13183
  * Always triggers a full-reload since the route tree structure changed.
@@ -13166,7 +13186,7 @@ function timberRouting(ctx) {
13166
13186
  if (!filePath.startsWith(ctx.appDir)) return;
13167
13187
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
13168
13188
  rescan();
13169
- lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : "";
13189
+ lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : "";
13170
13190
  invalidateManifest(devServer);
13171
13191
  };
13172
13192
  /**
@@ -13181,7 +13201,7 @@ function timberRouting(ctx) {
13181
13201
  if (!filePath.startsWith(ctx.appDir)) return;
13182
13202
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
13183
13203
  rescan();
13184
- const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree) : "";
13204
+ const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : "";
13185
13205
  if (newManifest !== lastManifest) {
13186
13206
  lastManifest = newManifest;
13187
13207
  invalidateManifest(devServer);
@@ -13214,7 +13234,7 @@ function invalidateManifest(server) {
13214
13234
  * The output is a default-exported object containing the serialized route tree.
13215
13235
  * All file references use absolute paths (required for virtual modules).
13216
13236
  */
13217
- function generateManifestModule(tree) {
13237
+ function generateManifestModule(tree, viteRoot) {
13218
13238
  const imports = [];
13219
13239
  let importIndex = 0;
13220
13240
  /**
@@ -13334,6 +13354,7 @@ function generateManifestModule(tree) {
13334
13354
  ...imports,
13335
13355
  "",
13336
13356
  "const manifest = {",
13357
+ ` viteRoot: ${JSON.stringify(viteRoot)},`,
13337
13358
  proxyLine,
13338
13359
  globalErrorLine,
13339
13360
  rewritesLine,
@@ -13497,6 +13518,240 @@ export const coerce = stub;
13497
13518
  };
13498
13519
  }
13499
13520
  //#endregion
13521
+ //#region src/fonts/google.ts
13522
+ /**
13523
+ * Google Fonts download, caching, and dev CDN fallback.
13524
+ *
13525
+ * At build time (production only):
13526
+ * 1. Queries Google Fonts CSS API v2 for font metadata and file URLs
13527
+ * 2. Downloads woff2 font files
13528
+ * 3. Caches them in node_modules/.cache/timber-fonts/
13529
+ * 4. Content-hashes filenames for cache busting
13530
+ * 5. Emits font files into the build output via generateBundle
13531
+ *
13532
+ * In dev mode:
13533
+ * - Generates @font-face rules pointing to Google Fonts CDN
13534
+ * - No downloads, no caching
13535
+ *
13536
+ * Design doc: 24-fonts.md §"Step 2: Font Download & Subsetting"
13537
+ */
13538
+ /** Google Fonts CSS API v2 base URL. */
13539
+ var GOOGLE_FONTS_API = "https://fonts.googleapis.com/css2";
13540
+ /**
13541
+ * User-Agent string that requests woff2 format from Google Fonts API.
13542
+ * Google serves different formats based on user-agent.
13543
+ */
13544
+ var WOFF2_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
13545
+ /** Default cache directory for downloaded font files. */
13546
+ var DEFAULT_CACHE_DIR = "node_modules/.cache/timber-fonts";
13547
+ /**
13548
+ * Build the Google Fonts CSS API v2 URL for a given font config.
13549
+ *
13550
+ * Example output:
13551
+ * https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap&subset=latin
13552
+ */
13553
+ function buildGoogleFontsUrl(font) {
13554
+ const family = font.family.replace(/\s+/g, "+");
13555
+ const hasItalic = font.styles.includes("italic");
13556
+ const weights = font.weights.map(Number).sort((a, b) => a - b);
13557
+ let axisSpec;
13558
+ if (hasItalic && weights.length > 0) {
13559
+ const pairs = [];
13560
+ for (const ital of [0, 1]) {
13561
+ if (ital === 1 && !hasItalic) continue;
13562
+ if (ital === 0 && font.styles.length === 1 && font.styles[0] === "italic") continue;
13563
+ for (const w of weights) pairs.push(`${ital},${w}`);
13564
+ }
13565
+ axisSpec = `ital,wght@${pairs.join(";")}`;
13566
+ } else if (weights.length > 0) axisSpec = `wght@${weights.join(";")}`;
13567
+ else axisSpec = "";
13568
+ const parts = [`family=${axisSpec ? `${family}:${axisSpec}` : family}`];
13569
+ if (font.display) parts.push(`display=${font.display}`);
13570
+ return `${GOOGLE_FONTS_API}?${parts.join("&")}`;
13571
+ }
13572
+ /**
13573
+ * Fetch the CSS from Google Fonts API and parse out @font-face blocks.
13574
+ *
13575
+ * The API returns CSS with subset comments like:
13576
+ * ```
13577
+ * /* latin * /
13578
+ * @font-face { ... }
13579
+ * ```
13580
+ *
13581
+ * We parse each block to extract the font URL, unicode-range, and subset label.
13582
+ */
13583
+ async function fetchGoogleFontsCss(url) {
13584
+ const response = await fetch(url, { headers: { "User-Agent": WOFF2_USER_AGENT } });
13585
+ if (!response.ok) throw new Error(`Google Fonts API returned ${response.status}: ${response.statusText} for ${url}`);
13586
+ return parseGoogleFontsCss(await response.text());
13587
+ }
13588
+ /**
13589
+ * Parse the CSS response from Google Fonts API into structured font face data.
13590
+ *
13591
+ * Handles the Google Fonts CSS format with subset comments and @font-face blocks.
13592
+ */
13593
+ function parseGoogleFontsCss(css) {
13594
+ const faces = [];
13595
+ const blockPattern = /\/\*\s*([a-z0-9-]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g;
13596
+ let match;
13597
+ while ((match = blockPattern.exec(css)) !== null) {
13598
+ const subset = match[1];
13599
+ const block = match[2];
13600
+ const familyMatch = block.match(/font-family:\s*'([^']+)'/);
13601
+ const weightMatch = block.match(/font-weight:\s*(\d+)/);
13602
+ const styleMatch = block.match(/font-style:\s*(\w+)/);
13603
+ const urlMatch = block.match(/url\(([^)]+)\)\s*format\('woff2'\)/);
13604
+ const rangeMatch = block.match(/unicode-range:\s*([^;]+)/);
13605
+ if (familyMatch && urlMatch) faces.push({
13606
+ family: familyMatch[1],
13607
+ weight: weightMatch?.[1] ?? "400",
13608
+ style: styleMatch?.[1] ?? "normal",
13609
+ url: urlMatch[1],
13610
+ unicodeRange: rangeMatch?.[1]?.trim() ?? "",
13611
+ subset
13612
+ });
13613
+ }
13614
+ return faces;
13615
+ }
13616
+ /**
13617
+ * Filter parsed font faces to only the requested subsets.
13618
+ */
13619
+ function filterBySubsets(faces, subsets) {
13620
+ if (subsets.length === 0) return faces;
13621
+ const subsetSet = new Set(subsets);
13622
+ return faces.filter((f) => subsetSet.has(f.subset));
13623
+ }
13624
+ /**
13625
+ * Generate a content hash for font data.
13626
+ * Returns the first 8 hex chars of the SHA-256 hash.
13627
+ */
13628
+ function contentHash(data) {
13629
+ return createHash("sha256").update(data).digest("hex").slice(0, 8);
13630
+ }
13631
+ /**
13632
+ * Generate a content-hashed filename for a font face.
13633
+ *
13634
+ * Format: `<family>-<subset>-<weight>-<style>-<hash>.woff2`
13635
+ * Example: `inter-latin-400-normal-abc12345.woff2`
13636
+ */
13637
+ function hashedFontFilename(face, data) {
13638
+ const slug = face.family.toLowerCase().replace(/\s+/g, "-");
13639
+ const hash = contentHash(data);
13640
+ return `${slug}-${face.subset}-${face.weight}-${face.style}-${hash}.woff2`;
13641
+ }
13642
+ /**
13643
+ * Build the cache key for a font face.
13644
+ * Used to check if a font has already been downloaded.
13645
+ */
13646
+ function cacheKey(face) {
13647
+ return `${face.family.toLowerCase().replace(/\s+/g, "-")}-${face.subset}-${face.weight}-${face.style}`;
13648
+ }
13649
+ /**
13650
+ * Download a single font file from its URL.
13651
+ */
13652
+ async function downloadFontFile(url) {
13653
+ const response = await fetch(url);
13654
+ if (!response.ok) throw new Error(`Failed to download font from ${url}: ${response.status}`);
13655
+ return Buffer.from(await response.arrayBuffer());
13656
+ }
13657
+ /**
13658
+ * Download and cache all font files for a set of extracted Google fonts.
13659
+ *
13660
+ * - Checks the local cache first (node_modules/.cache/timber-fonts/)
13661
+ * - Downloads missing fonts from Google Fonts CDN
13662
+ * - Writes downloaded fonts to cache
13663
+ * - Returns CachedFont entries with content-hashed filenames
13664
+ */
13665
+ async function downloadAndCacheFonts(fonts, projectRoot) {
13666
+ const cacheDir = join(projectRoot, DEFAULT_CACHE_DIR);
13667
+ await mkdir(cacheDir, { recursive: true });
13668
+ const googleFonts = fonts.filter((f) => f.provider === "google");
13669
+ const cached = [];
13670
+ for (const font of googleFonts) {
13671
+ const filtered = filterBySubsets(await fetchGoogleFontsCss(buildGoogleFontsUrl(font)), font.subsets);
13672
+ for (const face of filtered) {
13673
+ const key = cacheKey(face);
13674
+ const metaPath = join(cacheDir, `${key}.meta.json`);
13675
+ const dataPath = join(cacheDir, `${key}.woff2`);
13676
+ let data;
13677
+ let filename;
13678
+ if (await isCacheHit(metaPath, dataPath)) {
13679
+ data = await readFile(dataPath);
13680
+ filename = JSON.parse(await readFile(metaPath, "utf-8")).hashedFilename;
13681
+ } else {
13682
+ data = await downloadFontFile(face.url);
13683
+ filename = hashedFontFilename(face, data);
13684
+ await writeFile(dataPath, data);
13685
+ await writeFile(metaPath, JSON.stringify({
13686
+ hashedFilename: filename,
13687
+ url: face.url
13688
+ }));
13689
+ }
13690
+ cached.push({
13691
+ face,
13692
+ hashedFilename: filename,
13693
+ cachePath: dataPath,
13694
+ data
13695
+ });
13696
+ }
13697
+ }
13698
+ return cached;
13699
+ }
13700
+ /**
13701
+ * Check if both the meta and data files exist in the cache.
13702
+ */
13703
+ async function isCacheHit(metaPath, dataPath) {
13704
+ try {
13705
+ await stat(metaPath);
13706
+ await stat(dataPath);
13707
+ return true;
13708
+ } catch {
13709
+ return false;
13710
+ }
13711
+ }
13712
+ /**
13713
+ * Generate @font-face descriptors for cached (production) Google Fonts.
13714
+ *
13715
+ * Each CachedFont gets a FontFaceDescriptor pointing to the
13716
+ * content-hashed URL under `/_timber/fonts/`.
13717
+ */
13718
+ function generateProductionFontFaces(cachedFonts, display) {
13719
+ return cachedFonts.map((cf) => ({
13720
+ family: cf.face.family,
13721
+ src: `url('/_timber/fonts/${cf.hashedFilename}') format('woff2')`,
13722
+ weight: cf.face.weight,
13723
+ style: cf.face.style,
13724
+ display,
13725
+ unicodeRange: cf.face.unicodeRange
13726
+ }));
13727
+ }
13728
+ /**
13729
+ * Generate @font-face descriptors for dev mode (CDN-pointing).
13730
+ *
13731
+ * In dev mode, we query the Google Fonts API but use the CDN URLs
13732
+ * directly instead of downloading. This avoids the download/cache
13733
+ * step during `vite dev`.
13734
+ */
13735
+ function generateDevFontFaces(faces, display) {
13736
+ return faces.map((face) => ({
13737
+ family: face.family,
13738
+ src: `url('${face.url}') format('woff2')`,
13739
+ weight: face.weight,
13740
+ style: face.style,
13741
+ display,
13742
+ unicodeRange: face.unicodeRange
13743
+ }));
13744
+ }
13745
+ /**
13746
+ * Resolve dev-mode font faces for an extracted font.
13747
+ *
13748
+ * Fetches the CSS from Google Fonts API and returns FontFaceDescriptors
13749
+ * pointing to CDN URLs. No files are downloaded.
13750
+ */
13751
+ async function resolveDevFontFaces(font) {
13752
+ return generateDevFontFaces(filterBySubsets(await fetchGoogleFontsCss(buildGoogleFontsUrl(font)), font.subsets), font.display);
13753
+ }
13754
+ //#endregion
13500
13755
  //#region src/fonts/css.ts
13501
13756
  /**
13502
13757
  * Generate a single `@font-face` CSS rule from a descriptor.
@@ -14113,268 +14368,242 @@ function processLocalFont(config, importerPath) {
14113
14368
  };
14114
14369
  }
14115
14370
  //#endregion
14116
- //#region src/fonts/google.ts
14117
- /**
14118
- * Google Fonts download, caching, and dev CDN fallback.
14119
- *
14120
- * At build time (production only):
14121
- * 1. Queries Google Fonts CSS API v2 for font metadata and file URLs
14122
- * 2. Downloads woff2 font files
14123
- * 3. Caches them in node_modules/.cache/timber-fonts/
14124
- * 4. Content-hashes filenames for cache busting
14125
- * 5. Emits font files into the build output via generateBundle
14126
- *
14127
- * In dev mode:
14128
- * - Generates @font-face rules pointing to Google Fonts CDN
14129
- * - No downloads, no caching
14130
- *
14131
- * Design doc: 24-fonts.md §"Step 2: Font Download & Subsetting"
14132
- */
14133
- /** Google Fonts CSS API v2 base URL. */
14134
- var GOOGLE_FONTS_API = "https://fonts.googleapis.com/css2";
14135
- /**
14136
- * User-Agent string that requests woff2 format from Google Fonts API.
14137
- * Google serves different formats based on user-agent.
14138
- */
14139
- var WOFF2_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
14140
- /** Default cache directory for downloaded font files. */
14141
- var DEFAULT_CACHE_DIR = "node_modules/.cache/timber-fonts";
14142
- /**
14143
- * Build the Google Fonts CSS API v2 URL for a given font config.
14144
- *
14145
- * Example output:
14146
- * https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap&subset=latin
14147
- */
14148
- function buildGoogleFontsUrl(font) {
14149
- const family = font.family.replace(/\s+/g, "+");
14150
- const hasItalic = font.styles.includes("italic");
14151
- const weights = font.weights.map(Number).sort((a, b) => a - b);
14152
- let axisSpec;
14153
- if (hasItalic && weights.length > 0) {
14154
- const pairs = [];
14155
- for (const ital of [0, 1]) {
14156
- if (ital === 1 && !hasItalic) continue;
14157
- if (ital === 0 && font.styles.length === 1 && font.styles[0] === "italic") continue;
14158
- for (const w of weights) pairs.push(`${ital},${w}`);
14159
- }
14160
- axisSpec = `ital,wght@${pairs.join(";")}`;
14161
- } else if (weights.length > 0) axisSpec = `wght@${weights.join(";")}`;
14162
- else axisSpec = "";
14163
- const parts = [`family=${axisSpec ? `${family}:${axisSpec}` : family}`];
14164
- if (font.display) parts.push(`display=${font.display}`);
14165
- return `${GOOGLE_FONTS_API}?${parts.join("&")}`;
14166
- }
14167
- /**
14168
- * Fetch the CSS from Google Fonts API and parse out @font-face blocks.
14169
- *
14170
- * The API returns CSS with subset comments like:
14171
- * ```
14172
- * /* latin * /
14173
- * @font-face { ... }
14174
- * ```
14175
- *
14176
- * We parse each block to extract the font URL, unicode-range, and subset label.
14177
- */
14178
- async function fetchGoogleFontsCss(url) {
14179
- const response = await fetch(url, { headers: { "User-Agent": WOFF2_USER_AGENT } });
14180
- if (!response.ok) throw new Error(`Google Fonts API returned ${response.status}: ${response.statusText} for ${url}`);
14181
- return parseGoogleFontsCss(await response.text());
14182
- }
14183
- /**
14184
- * Parse the CSS response from Google Fonts API into structured font face data.
14185
- *
14186
- * Handles the Google Fonts CSS format with subset comments and @font-face blocks.
14187
- */
14188
- function parseGoogleFontsCss(css) {
14189
- const faces = [];
14190
- const blockPattern = /\/\*\s*([a-z0-9-]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g;
14191
- let match;
14192
- while ((match = blockPattern.exec(css)) !== null) {
14193
- const subset = match[1];
14194
- const block = match[2];
14195
- const familyMatch = block.match(/font-family:\s*'([^']+)'/);
14196
- const weightMatch = block.match(/font-weight:\s*(\d+)/);
14197
- const styleMatch = block.match(/font-style:\s*(\w+)/);
14198
- const urlMatch = block.match(/url\(([^)]+)\)\s*format\('woff2'\)/);
14199
- const rangeMatch = block.match(/unicode-range:\s*([^;]+)/);
14200
- if (familyMatch && urlMatch) faces.push({
14201
- family: familyMatch[1],
14202
- weight: weightMatch?.[1] ?? "400",
14203
- style: styleMatch?.[1] ?? "normal",
14204
- url: urlMatch[1],
14205
- unicodeRange: rangeMatch?.[1]?.trim() ?? "",
14206
- subset
14207
- });
14371
+ //#region src/fonts/pipeline.ts
14372
+ var FontPipeline = class {
14373
+ /**
14374
+ * The single per-font store. Keyed by `FontEntry.id` (which is itself
14375
+ * derived from `family + weights + styles + subsets + display`, so any
14376
+ * config change produces a new key).
14377
+ *
14378
+ * Marked `readonly` so the field reference can never be reassigned, but
14379
+ * the map contents are mutable through the methods below.
14380
+ */
14381
+ _entries = /* @__PURE__ */ new Map();
14382
+ /** Number of registered fonts. */
14383
+ size() {
14384
+ return this._entries.size;
14208
14385
  }
14209
- return faces;
14210
- }
14211
- /**
14212
- * Filter parsed font faces to only the requested subsets.
14213
- */
14214
- function filterBySubsets(faces, subsets) {
14215
- if (subsets.length === 0) return faces;
14216
- const subsetSet = new Set(subsets);
14217
- return faces.filter((f) => subsetSet.has(f.subset));
14218
- }
14219
- /**
14220
- * Generate a content hash for font data.
14221
- * Returns the first 8 hex chars of the SHA-256 hash.
14222
- */
14223
- function contentHash(data) {
14224
- return createHash("sha256").update(data).digest("hex").slice(0, 8);
14225
- }
14226
- /**
14227
- * Generate a content-hashed filename for a font face.
14228
- *
14229
- * Format: `<family>-<subset>-<weight>-<style>-<hash>.woff2`
14230
- * Example: `inter-latin-400-normal-abc12345.woff2`
14231
- */
14232
- function hashedFontFilename(face, data) {
14233
- const slug = face.family.toLowerCase().replace(/\s+/g, "-");
14234
- const hash = contentHash(data);
14235
- return `${slug}-${face.subset}-${face.weight}-${face.style}-${hash}.woff2`;
14236
- }
14237
- /**
14238
- * Build the cache key for a font face.
14239
- * Used to check if a font has already been downloaded.
14240
- */
14241
- function cacheKey(face) {
14242
- return `${face.family.toLowerCase().replace(/\s+/g, "-")}-${face.subset}-${face.weight}-${face.style}`;
14243
- }
14244
- /**
14245
- * Download a single font file from its URL.
14246
- */
14247
- async function downloadFontFile(url) {
14248
- const response = await fetch(url);
14249
- if (!response.ok) throw new Error(`Failed to download font from ${url}: ${response.status}`);
14250
- return Buffer.from(await response.arrayBuffer());
14251
- }
14252
- /**
14253
- * Download and cache all font files for a set of extracted Google fonts.
14254
- *
14255
- * - Checks the local cache first (node_modules/.cache/timber-fonts/)
14256
- * - Downloads missing fonts from Google Fonts CDN
14257
- * - Writes downloaded fonts to cache
14258
- * - Returns CachedFont entries with content-hashed filenames
14259
- */
14260
- async function downloadAndCacheFonts(fonts, projectRoot) {
14261
- const cacheDir = join(projectRoot, DEFAULT_CACHE_DIR);
14262
- await mkdir(cacheDir, { recursive: true });
14263
- const googleFonts = fonts.filter((f) => f.provider === "google");
14264
- const cached = [];
14265
- for (const font of googleFonts) {
14266
- const filtered = filterBySubsets(await fetchGoogleFontsCss(buildGoogleFontsUrl(font)), font.subsets);
14267
- for (const face of filtered) {
14268
- const key = cacheKey(face);
14269
- const metaPath = join(cacheDir, `${key}.meta.json`);
14270
- const dataPath = join(cacheDir, `${key}.woff2`);
14271
- let data;
14272
- let filename;
14273
- if (await isCacheHit(metaPath, dataPath)) {
14274
- data = await readFile(dataPath);
14275
- filename = JSON.parse(await readFile(metaPath, "utf-8")).hashedFilename;
14276
- } else {
14277
- data = await downloadFontFile(face.url);
14278
- filename = hashedFontFilename(face, data);
14279
- await writeFile(dataPath, data);
14280
- await writeFile(metaPath, JSON.stringify({
14281
- hashedFilename: filename,
14282
- url: face.url
14283
- }));
14386
+ /** Iterate every entry as a `FontEntry` (with `faces` / `cachedFiles`). */
14387
+ entries() {
14388
+ return this._entries.values();
14389
+ }
14390
+ /** Iterate every entry typed as the narrower `ExtractedFont` shape. */
14391
+ fonts() {
14392
+ return this._entries.values();
14393
+ }
14394
+ /** Iterate every Google-provider entry. */
14395
+ *googleFonts() {
14396
+ for (const entry of this._entries.values()) if (entry.provider === "google") yield entry;
14397
+ }
14398
+ /** Lookup an entry by font ID. */
14399
+ getEntry(id) {
14400
+ return this._entries.get(id);
14401
+ }
14402
+ /**
14403
+ * True if `attachFaces()` has been called for `fontId` — even with an
14404
+ * empty array. This intentionally treats an empty resolution as
14405
+ * "already processed" so the dev `load()` path doesn't keep retrying
14406
+ * `resolveDevFontFaces()` on every request when Google returns no
14407
+ * matching faces for the requested subset(s). Mirrors the previous
14408
+ * `Map.has()` semantics this method replaced. (Codex review on PR #596.)
14409
+ *
14410
+ * Failures inside `resolveDevFontFaces` do not call `attachFaces` at
14411
+ * all, so they correctly stay retryable on the next request — see the
14412
+ * TIM-636 comment in `plugins/fonts.ts`.
14413
+ */
14414
+ hasFaces(fontId) {
14415
+ return this._entries.get(fontId)?.faces !== void 0;
14416
+ }
14417
+ /**
14418
+ * True if `attachCachedFiles()` has been called for `fontId` — even
14419
+ * with an empty array. Same presence-vs-content rationale as
14420
+ * `hasFaces`: an empty resolution is a deterministic outcome, not a
14421
+ * "not yet processed" signal.
14422
+ */
14423
+ hasCachedFiles(fontId) {
14424
+ return this._entries.get(fontId)?.cachedFiles !== void 0;
14425
+ }
14426
+ /**
14427
+ * Register an extracted font under its content-derived ID.
14428
+ *
14429
+ * If an entry with the same ID already exists it is replaced wholesale,
14430
+ * dropping any previously-attached `faces` and `cachedFiles`. Callers
14431
+ * must call `pruneFor(importer, family)` first to ensure that any
14432
+ * previous registration from the same `(importer, family)` pair has been
14433
+ * dropped (TIM-824).
14434
+ */
14435
+ register(extracted) {
14436
+ this._entries.set(extracted.id, { ...extracted });
14437
+ }
14438
+ /** Attach pre-resolved `@font-face` descriptors to a font entry. */
14439
+ attachFaces(fontId, faces) {
14440
+ const entry = this._entries.get(fontId);
14441
+ if (!entry) throw new Error(`[timber-fonts] attachFaces: no font entry registered for id "${fontId}". Call register() first.`);
14442
+ entry.faces = faces;
14443
+ }
14444
+ /** Attach downloaded Google Font binaries to a font entry. */
14445
+ attachCachedFiles(fontId, files) {
14446
+ const entry = this._entries.get(fontId);
14447
+ if (!entry) throw new Error(`[timber-fonts] attachCachedFiles: no font entry registered for id "${fontId}". Call register() first.`);
14448
+ entry.cachedFiles = files;
14449
+ }
14450
+ /**
14451
+ * Drop every registry entry that originated from `importer` and shares
14452
+ * `family` (case-insensitive), **along with all of its attached state**.
14453
+ *
14454
+ * This is the single chokepoint that guarantees stale `@font-face`
14455
+ * descriptors and orphaned cached binaries cannot leak across HMR edits.
14456
+ * Because every piece of per-font state lives on the `FontEntry`,
14457
+ * deleting the entry from the underlying map drops everything in one
14458
+ * operation — no parallel maps to keep in sync.
14459
+ *
14460
+ * See TIM-824 (the original prune fix) and TIM-829 (single-store
14461
+ * refactor that made this guarantee structural).
14462
+ */
14463
+ pruneFor(importer, family) {
14464
+ const familyKey = family.toLowerCase();
14465
+ for (const [id, entry] of this._entries) if (entry.importer === importer && entry.family.toLowerCase() === familyKey) this._entries.delete(id);
14466
+ }
14467
+ /** Drop every registered font. Used by tests and rebuild flows. */
14468
+ clear() {
14469
+ this._entries.clear();
14470
+ }
14471
+ /**
14472
+ * Iterate every cached Google Font binary across all registered entries,
14473
+ * deduplicated by `hashedFilename`. Multiple entries that share the same
14474
+ * family hold references to the same `CachedFont` objects, so this
14475
+ * iterator yields each unique binary exactly once.
14476
+ */
14477
+ *uniqueCachedFiles() {
14478
+ const seen = /* @__PURE__ */ new Set();
14479
+ for (const entry of this._entries.values()) {
14480
+ if (!entry.cachedFiles) continue;
14481
+ for (const cf of entry.cachedFiles) {
14482
+ if (seen.has(cf.hashedFilename)) continue;
14483
+ seen.add(cf.hashedFilename);
14484
+ yield cf;
14284
14485
  }
14285
- cached.push({
14286
- face,
14287
- hashedFilename: filename,
14288
- cachePath: dataPath,
14289
- data
14290
- });
14291
14486
  }
14292
14487
  }
14293
- return cached;
14294
- }
14488
+ /**
14489
+ * Generate the combined CSS output for every registered font.
14490
+ *
14491
+ * Includes `@font-face` rules for local and Google fonts, fallback
14492
+ * `@font-face` rules, and scoped class rules. Google fonts use the
14493
+ * `faces` attached via `attachFaces()` (in `buildStart` for production
14494
+ * or lazily in `load` for dev).
14495
+ */
14496
+ getCss() {
14497
+ const cssParts = [];
14498
+ for (const entry of this._entries.values()) cssParts.push(renderEntryCss(entry));
14499
+ return cssParts.join("\n\n");
14500
+ }
14501
+ };
14295
14502
  /**
14296
- * Check if both the meta and data files exist in the cache.
14503
+ * Render the combined CSS for a single font entry.
14504
+ *
14505
+ * Mirrors the standalone `generateFontCss` helper exported from
14506
+ * `virtual-modules.ts`, but reads `faces` straight off the entry instead
14507
+ * of from a parallel map. Kept private to this module so the only public
14508
+ * way to render a `FontEntry`'s CSS is through `FontPipeline.getCss()`.
14297
14509
  */
14298
- async function isCacheHit(metaPath, dataPath) {
14299
- try {
14300
- await stat(metaPath);
14301
- await stat(dataPath);
14302
- return true;
14303
- } catch {
14304
- return false;
14510
+ function renderEntryCss(entry) {
14511
+ const cssParts = [];
14512
+ if (entry.provider === "local" && entry.localSources) {
14513
+ const faceCss = generateFontFaces(generateLocalFontFaces(entry.family, entry.localSources, entry.display));
14514
+ if (faceCss) cssParts.push(faceCss);
14515
+ }
14516
+ if (entry.provider === "google" && entry.faces && entry.faces.length > 0) {
14517
+ const faceCss = generateFontFaces(entry.faces);
14518
+ if (faceCss) cssParts.push(faceCss);
14305
14519
  }
14520
+ const fallbackCss = generateFallbackCss(entry.family);
14521
+ if (fallbackCss) cssParts.push(fallbackCss);
14522
+ if (entry.variable) cssParts.push(generateVariableClass(entry.className, entry.variable, entry.fontFamily));
14523
+ else cssParts.push(generateFontFamilyClass(entry.className, entry.fontFamily));
14524
+ return cssParts.join("\n\n");
14306
14525
  }
14526
+ var RESOLVED_GOOGLE = "\0@timber/fonts/google";
14527
+ var RESOLVED_LOCAL = "\0@timber/fonts/local";
14528
+ var VIRTUAL_FONT_CSS_REGISTER = "virtual:timber-font-css-register";
14529
+ var RESOLVED_FONT_CSS_REGISTER = "\0virtual:timber-font-css-register";
14307
14530
  /**
14308
- * Generate @font-face descriptors for cached (production) Google Fonts.
14309
- *
14310
- * Each CachedFont gets a FontFaceDescriptor pointing to the
14311
- * content-hashed URL under `/_timber/fonts/`.
14531
+ * Convert a font family name to a PascalCase export name.
14532
+ * e.g. "JetBrains Mono" → "JetBrains_Mono"
14312
14533
  */
14313
- function generateProductionFontFaces(cachedFonts, display) {
14314
- return cachedFonts.map((cf) => ({
14315
- family: cf.face.family,
14316
- src: `url('/_timber/fonts/${cf.hashedFilename}') format('woff2')`,
14317
- weight: cf.face.weight,
14318
- style: cf.face.style,
14319
- display,
14320
- unicodeRange: cf.face.unicodeRange
14321
- }));
14534
+ function familyToExportName(family) {
14535
+ return family.replace(/\s+/g, "_");
14322
14536
  }
14323
14537
  /**
14324
- * Generate @font-face descriptors for dev mode (CDN-pointing).
14538
+ * Generate the virtual module source for `@timber/fonts/google`.
14325
14539
  *
14326
- * In dev mode, we query the Google Fonts API but use the CDN URLs
14327
- * directly instead of downloading. This avoids the download/cache
14328
- * step during `vite dev`.
14540
+ * The transform hook replaces real call sites at build time, so this
14541
+ * module only matters as a runtime fallback. We export a Proxy default
14542
+ * that handles any font name plus named exports for known families
14543
+ * (for tree-shaking).
14329
14544
  */
14330
- function generateDevFontFaces(faces, display) {
14331
- return faces.map((face) => ({
14332
- family: face.family,
14333
- src: `url('${face.url}') format('woff2')`,
14334
- weight: face.weight,
14335
- style: face.style,
14336
- display,
14337
- unicodeRange: face.unicodeRange
14338
- }));
14545
+ function generateGoogleVirtualModule(fonts) {
14546
+ const families = /* @__PURE__ */ new Set();
14547
+ for (const font of fonts) if (font.provider === "google") families.add(font.family);
14548
+ const lines = [
14549
+ "// Auto-generated virtual module: @timber/fonts/google",
14550
+ "// Each export is a font loader function that returns a FontResult.",
14551
+ "",
14552
+ "function createFontResult(family, config) {",
14553
+ " return {",
14554
+ " className: `timber-font-${family.toLowerCase().replace(/\\s+/g, \"-\")}`,",
14555
+ " style: { fontFamily: family },",
14556
+ " variable: config?.variable,",
14557
+ " };",
14558
+ "}",
14559
+ "",
14560
+ "export default new Proxy({}, {",
14561
+ " get(_, prop) {",
14562
+ " if (typeof prop === \"string\") {",
14563
+ " return (config) => createFontResult(prop.replace(/_/g, \" \"), config);",
14564
+ " }",
14565
+ " }",
14566
+ "});"
14567
+ ];
14568
+ for (const family of families) {
14569
+ const exportName = familyToExportName(family);
14570
+ lines.push("");
14571
+ lines.push(`export function ${exportName}(config) {`);
14572
+ lines.push(` return createFontResult('${family}', config);`);
14573
+ lines.push("}");
14574
+ }
14575
+ return lines.join("\n");
14339
14576
  }
14340
14577
  /**
14341
- * Resolve dev-mode font faces for an extracted font.
14578
+ * Generate the virtual module source for `@timber/fonts/local`.
14342
14579
  *
14343
- * Fetches the CSS from Google Fonts API and returns FontFaceDescriptors
14344
- * pointing to CDN URLs. No files are downloaded.
14580
+ * Like the google virtual module, this is a runtime fallback. The
14581
+ * transform hook normally replaces `localFont(...)` calls with static
14582
+ * `FontResult` objects at build time.
14345
14583
  */
14346
- async function resolveDevFontFaces(font) {
14347
- return generateDevFontFaces(filterBySubsets(await fetchGoogleFontsCss(buildGoogleFontsUrl(font)), font.subsets), font.display);
14584
+ function generateLocalVirtualModule() {
14585
+ return [
14586
+ "// Auto-generated virtual module: @timber/fonts/local",
14587
+ "",
14588
+ "export default function localFont(config) {",
14589
+ " const family = config?.family || \"Local Font\";",
14590
+ " return {",
14591
+ " className: `timber-font-${family.toLowerCase().replace(/\\s+/g, \"-\")}`,",
14592
+ " style: { fontFamily: family },",
14593
+ " variable: config?.variable,",
14594
+ " };",
14595
+ "}"
14596
+ ].join("\n");
14348
14597
  }
14349
14598
  //#endregion
14350
- //#region src/plugins/fonts.ts
14351
- var VIRTUAL_GOOGLE = "@timber/fonts/google";
14352
- var VIRTUAL_LOCAL = "@timber/fonts/local";
14353
- var RESOLVED_GOOGLE = "\0@timber/fonts/google";
14354
- var RESOLVED_LOCAL = "\0@timber/fonts/local";
14599
+ //#region src/fonts/transform.ts
14355
14600
  /**
14356
- * Virtual side-effect module that registers font CSS on globalThis.
14357
- *
14358
- * When a file calls localFont() or a Google font function, the transform
14359
- * hook injects `import 'virtual:timber-font-css-register'` into that file.
14360
- * This virtual module sets `globalThis.__timber_font_css` with the combined
14361
- * @font-face CSS. The RSC entry reads it at render time to inline a <style> tag.
14362
- *
14363
- * This approach avoids timing issues because:
14364
- * 1. The font file is in the RSC module graph (imported by layout.tsx)
14365
- * 2. The side-effect import is added to the font file during transform
14366
- * 3. When layout.tsx is loaded, fonts.ts runs → side-effect module runs → globalThis is set
14367
- * 4. RSC entry renders → reads globalThis → inlines <style>
14601
+ * Regex that matches imports from either `@timber/fonts/google` or
14602
+ * `next/font/google` (which the shims plugin resolves to the same virtual
14603
+ * module). The transform hook still needs to recognise both spellings in
14604
+ * source code.
14368
14605
  */
14369
- var VIRTUAL_FONT_CSS_REGISTER = "virtual:timber-font-css-register";
14370
- var RESOLVED_FONT_CSS_REGISTER = "\0virtual:timber-font-css-register";
14371
- /**
14372
- * Convert a font family name to a PascalCase export name.
14373
- * e.g. "JetBrains Mono" → "JetBrains_Mono"
14374
- */
14375
- function familyToExportName(family) {
14376
- return family.replace(/\s+/g, "_");
14377
- }
14606
+ var GOOGLE_FONT_IMPORT_RE = /import\s*\{([^}]+)\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"]/g;
14378
14607
  /**
14379
14608
  * Convert a font family name to a scoped class name.
14380
14609
  * e.g. "JetBrains Mono" → "timber-font-jetbrains-mono"
@@ -14382,66 +14611,53 @@ function familyToExportName(family) {
14382
14611
  function familyToClassName(family) {
14383
14612
  return `timber-font-${family.toLowerCase().replace(/\s+/g, "-")}`;
14384
14613
  }
14385
- /**
14386
- * Generate a unique font ID from family + config hash.
14387
- */
14388
- function generateFontId(family, config) {
14389
- const weights = normalizeToArray(config.weight);
14390
- const styles = normalizeToArray(config.style);
14391
- const subsets = config.subsets ?? ["latin"];
14392
- const display = config.display ?? "swap";
14393
- return `${family.toLowerCase()}-${weights.join(",")}-${styles.join(",")}-${subsets.join(",")}-${display}`;
14394
- }
14395
- /**
14396
- * Normalize a string or string array to an array.
14397
- */
14398
- function normalizeToArray(value) {
14614
+ /** Normalize a string or string array to an array (default `['400']`). */
14615
+ function normalizeWeightArray(value) {
14399
14616
  if (!value) return ["400"];
14400
14617
  return Array.isArray(value) ? value : [value];
14401
14618
  }
14402
- /**
14403
- * Normalize style to an array.
14404
- */
14619
+ /** Normalize style to an array (default `['normal']`). */
14405
14620
  function normalizeStyleArray(value) {
14406
14621
  if (!value) return ["normal"];
14407
14622
  return Array.isArray(value) ? value : [value];
14408
14623
  }
14409
14624
  /**
14410
- * Extract static font config from a font function call in source code.
14411
- *
14412
- * Parses patterns like:
14413
- * const inter = Inter({ subsets: ['latin'], weight: '400', display: 'swap', variable: '--font-sans' })
14625
+ * Generate a unique font ID from family + config hash.
14414
14626
  *
14415
- * Returns null if the call cannot be statically analyzed.
14627
+ * The ID intentionally includes every property that affects the rendered
14628
+ * `@font-face` output (`weight`, `style`, `subsets`, `display`) so that an
14629
+ * HMR edit changing any of those produces a new ID and the registry is
14630
+ * forced to re-emit fresh CSS.
14631
+ */
14632
+ function generateFontId(family, config) {
14633
+ const weights = normalizeWeightArray(config.weight);
14634
+ const styles = normalizeStyleArray(config.style);
14635
+ const subsets = config.subsets ?? ["latin"];
14636
+ const display = config.display ?? "swap";
14637
+ return `${family.toLowerCase()}-${weights.join(",")}-${styles.join(",")}-${subsets.join(",")}-${display}`;
14638
+ }
14639
+ /**
14640
+ * Extract static font config from a font function call source.
14416
14641
  *
14417
- * Uses acorn AST parsing for robust handling of comments, trailing commas,
14418
- * and multi-line configs.
14642
+ * Returns `null` if the call cannot be statically analysed.
14419
14643
  */
14420
14644
  function extractFontConfig(callSource) {
14421
14645
  return extractFontConfigAst(callSource);
14422
14646
  }
14423
14647
  /**
14424
- * Detect if a source file contains dynamic/computed font function calls
14425
- * that cannot be statically analyzed.
14426
- *
14427
- * Returns the offending expression if found, null if all calls are static.
14428
- *
14429
- * Uses acorn AST parsing for accurate detection.
14648
+ * Detect dynamic/computed font function calls that cannot be statically
14649
+ * analysed. Returns the offending expression text, or `null` if all calls
14650
+ * are static.
14430
14651
  */
14431
14652
  function detectDynamicFontCall(source, importedNames) {
14432
14653
  return detectDynamicFontCallAst(source, importedNames);
14433
14654
  }
14434
14655
  /**
14435
- * Regex that matches imports from either `@timber/fonts/google` or `next/font/google`.
14436
- * The shims plugin resolves `next/font/google` to the same virtual module,
14437
- * but the source code still contains the original import specifier.
14438
- */
14439
- var GOOGLE_FONT_IMPORT_RE = /import\s*\{([^}]+)\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"]/g;
14440
- /**
14441
- * Parse the original (remote) font family names from imports.
14656
+ * Parse Google font imports into a `localName familyName` map.
14442
14657
  *
14443
- * Returns a map of local namefamily name.
14444
- * e.g. { Inter: 'Inter', JetBrains_Mono: 'JetBrains Mono' }
14658
+ * e.g. `import { JetBrains_Mono as Mono }` `{ Mono: 'JetBrains Mono' }`.
14659
+ * The original (pre-`as`) name is converted from PascalCase-with-underscores
14660
+ * to the human-readable family name by replacing `_` with a space.
14445
14661
  */
14446
14662
  function parseGoogleFontFamilies(source) {
14447
14663
  const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, "g");
@@ -14460,121 +14676,69 @@ function parseGoogleFontFamilies(source) {
14460
14676
  return families;
14461
14677
  }
14462
14678
  /**
14463
- * Generate the virtual module source for `@timber/fonts/google`.
14464
- *
14465
- * Each Google Font family gets a named export that returns a FontResult.
14466
- * In this base implementation, the functions return static data.
14467
- * The Google Fonts download task (timber-nk5) will add real font file URLs.
14468
- */
14469
- function generateGoogleVirtualModule(registry) {
14470
- const families = /* @__PURE__ */ new Set();
14471
- for (const font of registry.values()) if (font.provider === "google") families.add(font.family);
14472
- const lines = [
14473
- "// Auto-generated virtual module: @timber/fonts/google",
14474
- "// Each export is a font loader function that returns a FontResult.",
14475
- ""
14476
- ];
14477
- lines.push("function createFontResult(family, config) {");
14478
- lines.push(" return {");
14479
- lines.push(" className: `timber-font-${family.toLowerCase().replace(/\\s+/g, \"-\")}`,");
14480
- lines.push(" style: { fontFamily: family },");
14481
- lines.push(" variable: config?.variable,");
14482
- lines.push(" };");
14483
- lines.push("}");
14484
- lines.push("");
14485
- lines.push("export default new Proxy({}, {");
14486
- lines.push(" get(_, prop) {");
14487
- lines.push(" if (typeof prop === \"string\") {");
14488
- lines.push(" return (config) => createFontResult(prop.replace(/_/g, \" \"), config);");
14489
- lines.push(" }");
14490
- lines.push(" }");
14491
- lines.push("});");
14492
- for (const family of families) {
14493
- const exportName = familyToExportName(family);
14494
- lines.push("");
14495
- lines.push(`export function ${exportName}(config) {`);
14496
- lines.push(` return createFontResult('${family}', config);`);
14497
- lines.push("}");
14498
- }
14499
- return lines.join("\n");
14500
- }
14501
- /**
14502
- * Generate the virtual module source for `@timber/fonts/local`.
14503
- */
14504
- function generateLocalVirtualModule() {
14505
- return [
14506
- "// Auto-generated virtual module: @timber/fonts/local",
14507
- "",
14508
- "export default function localFont(config) {",
14509
- " const family = config?.family || \"Local Font\";",
14510
- " return {",
14511
- " className: `timber-font-${family.toLowerCase().replace(/\\s+/g, \"-\")}`,",
14512
- " style: { fontFamily: family },",
14513
- " variable: config?.variable,",
14514
- " };",
14515
- "}"
14516
- ].join("\n");
14517
- }
14518
- /**
14519
- * Generate CSS for a single extracted font.
14520
- *
14521
- * Includes @font-face rules (for local and Google fonts), fallback @font-face,
14522
- * and the scoped class rule.
14523
- *
14524
- * For Google fonts, pass the resolved FontFaceDescriptor[] from either
14525
- * `generateProductionFontFaces()` (production) or `resolveDevFontFaces()` (dev).
14526
- */
14527
- function generateFontCss(font, googleFaces) {
14528
- const cssParts = [];
14529
- if (font.provider === "local" && font.localSources) {
14530
- const faceCss = generateFontFaces(generateLocalFontFaces(font.family, font.localSources, font.display));
14531
- if (faceCss) cssParts.push(faceCss);
14532
- }
14533
- if (font.provider === "google" && googleFaces && googleFaces.length > 0) {
14534
- const faceCss = generateFontFaces(googleFaces);
14535
- if (faceCss) cssParts.push(faceCss);
14536
- }
14537
- const fallbackCss = generateFallbackCss(font.family);
14538
- if (fallbackCss) cssParts.push(fallbackCss);
14539
- if (font.variable) cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
14540
- else cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
14541
- return cssParts.join("\n\n");
14542
- }
14543
- /**
14544
- * Generate the CSS output for all extracted fonts.
14545
- *
14546
- * Includes @font-face rules for local and Google fonts, fallback @font-face
14547
- * rules, and scoped classes.
14548
- *
14549
- * `googleFontFacesMap` provides pre-resolved FontFaceDescriptor[] for each
14550
- * Google font ID (keyed by ExtractedFont.id).
14551
- */
14552
- function generateAllFontCss(registry, googleFontFacesMap) {
14553
- const cssParts = [];
14554
- for (const font of registry.values()) {
14555
- const googleFaces = googleFontFacesMap?.get(font.id);
14556
- cssParts.push(generateFontCss(font, googleFaces));
14557
- }
14558
- return cssParts.join("\n\n");
14559
- }
14560
- /**
14561
14679
  * Parse the local name used for the default import of `@timber/fonts/local`.
14562
14680
  *
14563
- * Handles:
14681
+ * Handles either spelling and arbitrary local names:
14564
14682
  * import localFont from '@timber/fonts/local'
14565
- * import myLoader from '@timber/fonts/local'
14683
+ * import myLoader from 'next/font/local'
14566
14684
  */
14567
14685
  function parseLocalFontImportName(source) {
14568
14686
  const match = source.match(/import\s+(\w+)\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"]/);
14569
14687
  return match ? match[1] : null;
14570
14688
  }
14689
+ /** Build the static `FontResult` literal we substitute for a font call. */
14690
+ function buildFontResultLiteral(extracted) {
14691
+ return extracted.variable ? `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" }, variable: "${extracted.variable}" }` : `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" } }`;
14692
+ }
14571
14693
  /**
14572
- * Transform local font calls in source code.
14573
- *
14574
- * Finds `localFont({ ... })` calls, extracts the config,
14575
- * registers the font, and replaces the call with a static FontResult.
14694
+ * Find Google font calls in `originalCode`, register them in the pipeline,
14695
+ * and replace each call site in `transformedCode` with a static FontResult.
14576
14696
  */
14577
- function transformLocalFonts(transformedCode, originalCode, importerId, registry, emitError) {
14697
+ function transformGoogleFonts(transformedCode, originalCode, importerId, pipeline, emitError) {
14698
+ const families = parseGoogleFontFamilies(originalCode);
14699
+ if (families.size === 0) return transformedCode;
14700
+ const dynamicCall = detectDynamicFontCall(originalCode, [...families.keys()]);
14701
+ if (dynamicCall) emitError(`Font function calls must be statically analyzable. Found dynamic call: ${dynamicCall}. Pass a literal object with string/array values instead.`);
14702
+ for (const [localName, family] of families) {
14703
+ const callPattern = new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`, "g");
14704
+ let callMatch;
14705
+ while ((callMatch = callPattern.exec(originalCode)) !== null) {
14706
+ const varName = callMatch[1];
14707
+ const configSource = callMatch[2];
14708
+ const fullMatch = callMatch[0];
14709
+ const config = extractFontConfig(`(${configSource})`);
14710
+ if (!config) emitError(`Could not statically analyze font config for ${family}. Ensure all config values are string literals or arrays of string literals.`);
14711
+ const fontId = generateFontId(family, config);
14712
+ const className = familyToClassName(family);
14713
+ const fontStack = buildFontStack(family);
14714
+ const display = config.display ?? "swap";
14715
+ const extracted = {
14716
+ id: fontId,
14717
+ family,
14718
+ provider: "google",
14719
+ weights: normalizeWeightArray(config.weight),
14720
+ styles: normalizeStyleArray(config.style),
14721
+ subsets: config.subsets ?? ["latin"],
14722
+ display,
14723
+ variable: config.variable,
14724
+ className,
14725
+ fontFamily: fontStack,
14726
+ importer: importerId
14727
+ };
14728
+ pipeline.pruneFor(importerId, family);
14729
+ pipeline.register(extracted);
14730
+ const replacement = `const ${varName} = ${buildFontResultLiteral(extracted)}`;
14731
+ transformedCode = transformedCode.replace(fullMatch, replacement);
14732
+ }
14733
+ }
14734
+ transformedCode = transformedCode.replace(/import\s*\{[^}]+\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"];?\s*\n?/g, "");
14735
+ return transformedCode;
14736
+ }
14737
+ /**
14738
+ * Find local font calls in `originalCode`, register them in the pipeline,
14739
+ * and replace each call site in `transformedCode` with a static FontResult.
14740
+ */
14741
+ function transformLocalFonts(transformedCode, originalCode, importerId, pipeline, emitError) {
14578
14742
  const localName = parseLocalFontImportName(originalCode);
14579
14743
  if (!localName) return transformedCode;
14580
14744
  const dynamicCall = detectDynamicFontCall(originalCode, [localName]);
@@ -14586,30 +14750,185 @@ function transformLocalFonts(transformedCode, originalCode, importerId, registry
14586
14750
  const configSource = callMatch[2];
14587
14751
  const fullMatch = callMatch[0];
14588
14752
  const config = extractLocalFontConfigAst(`(${configSource})`);
14589
- if (!config) {
14590
- emitError("Could not statically analyze local font config. Ensure src is a string or array of { path, weight?, style? } objects.");
14591
- return transformedCode;
14592
- }
14753
+ if (!config) emitError("Could not statically analyze local font config. Ensure src is a string or array of { path, weight?, style? } objects.");
14593
14754
  const extracted = processLocalFont(config, importerId);
14594
- registry.set(extracted.id, extracted);
14595
- const replacement = `const ${varName} = ${extracted.variable ? `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" }, variable: "${extracted.variable}" }` : `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" } }`}`;
14755
+ pipeline.pruneFor(importerId, extracted.family);
14756
+ pipeline.register(extracted);
14757
+ const replacement = `const ${varName} = ${buildFontResultLiteral(extracted)}`;
14596
14758
  transformedCode = transformedCode.replace(fullMatch, replacement);
14597
14759
  }
14598
14760
  transformedCode = transformedCode.replace(/import\s+\w+\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"];?\s*\n?/g, "");
14599
14761
  return transformedCode;
14600
14762
  }
14601
14763
  /**
14764
+ * Run the timber-fonts transform pass on a single source file.
14765
+ *
14766
+ * Returns the rewritten code (with font calls inlined and the side-effect
14767
+ * `virtual:timber-font-css-register` import prepended) or `null` if the
14768
+ * file does not import from any timber-fonts virtual module and therefore
14769
+ * needs no transformation.
14770
+ */
14771
+ function runFontsTransform(code, id, pipeline, emitError) {
14772
+ if (id.startsWith("\0") || id.includes("node_modules")) return null;
14773
+ const hasGoogleImport = code.includes("@timber/fonts/google") || code.includes("@timber-js/app/fonts/google") || code.includes("next/font/google");
14774
+ const hasLocalImport = code.includes("@timber/fonts/local") || code.includes("@timber-js/app/fonts/local") || code.includes("next/font/local");
14775
+ if (!hasGoogleImport && !hasLocalImport) return null;
14776
+ let transformedCode = code;
14777
+ if (hasGoogleImport) transformedCode = transformGoogleFonts(transformedCode, code, id, pipeline, emitError);
14778
+ if (hasLocalImport) transformedCode = transformLocalFonts(transformedCode, code, id, pipeline, emitError);
14779
+ if (transformedCode !== code) {
14780
+ if (pipeline.size() > 0) transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
14781
+ return {
14782
+ code: transformedCode,
14783
+ map: null
14784
+ };
14785
+ }
14786
+ return null;
14787
+ }
14788
+ //#endregion
14789
+ //#region src/fonts/dev-middleware.ts
14790
+ var FONT_MIME_TYPES = {
14791
+ woff2: "font/woff2",
14792
+ woff: "font/woff",
14793
+ ttf: "font/ttf",
14794
+ otf: "font/otf",
14795
+ eot: "application/vnd.ms-fontopen"
14796
+ };
14797
+ /**
14798
+ * Wire the timber-fonts dev middleware onto a Vite dev server.
14799
+ *
14800
+ * Returns synchronously after registering the middleware. The pipeline is
14801
+ * captured by reference so that fonts registered after `configureServer`
14802
+ * runs (during transform) are still resolvable.
14803
+ */
14804
+ function installFontDevMiddleware(server, pipeline) {
14805
+ server.middlewares.use((req, res, next) => {
14806
+ const url = req.url;
14807
+ if (!url || !url.startsWith("/_timber/fonts/")) return next();
14808
+ const requestedFilename = url.slice(15);
14809
+ if (requestedFilename.includes("..") || requestedFilename.includes("/")) {
14810
+ res.statusCode = 400;
14811
+ res.end("Bad request");
14812
+ return;
14813
+ }
14814
+ for (const font of pipeline.fonts()) {
14815
+ if (font.provider !== "local" || !font.localSources) continue;
14816
+ for (const src of font.localSources) {
14817
+ if ((src.path.split("/").pop() ?? "") !== requestedFilename) continue;
14818
+ const absolutePath = normalize(resolve(src.path));
14819
+ if (!existsSync(absolutePath)) {
14820
+ res.statusCode = 404;
14821
+ res.end("Not found");
14822
+ return;
14823
+ }
14824
+ const data = readFileSync(absolutePath);
14825
+ const ext = absolutePath.split(".").pop()?.toLowerCase();
14826
+ res.setHeader("Content-Type", FONT_MIME_TYPES[ext ?? ""] ?? "application/octet-stream");
14827
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
14828
+ res.setHeader("Access-Control-Allow-Origin", "*");
14829
+ res.end(data);
14830
+ return;
14831
+ }
14832
+ }
14833
+ next();
14834
+ });
14835
+ }
14836
+ //#endregion
14837
+ //#region src/fonts/bundle.ts
14838
+ /**
14839
+ * Build-output emission for the timber-fonts plugin.
14840
+ *
14841
+ * Splits the `generateBundle` hook into pure helpers so the plugin shell
14842
+ * can stay focused on Vite wiring. The functions here are deliberately
14843
+ * Rollup-agnostic — they take an `EmitFile` callback so the plugin can
14844
+ * pass `this.emitFile` from the `generateBundle` context.
14845
+ *
14846
+ * Design doc: 24-fonts.md
14847
+ */
14848
+ /** Group cached Google Font binaries by lowercase family name. */
14849
+ function groupCachedFontsByFamily(cachedFonts) {
14850
+ const cachedByFamily = /* @__PURE__ */ new Map();
14851
+ for (const cf of cachedFonts) {
14852
+ const key = cf.face.family.toLowerCase();
14853
+ const arr = cachedByFamily.get(key) ?? [];
14854
+ arr.push(cf);
14855
+ cachedByFamily.set(key, arr);
14856
+ }
14857
+ return cachedByFamily;
14858
+ }
14859
+ /**
14860
+ * Emit cached Google Font binaries and local font files into the build
14861
+ * output under `_timber/fonts/`. Local files missing on disk produce a
14862
+ * warning rather than an error so the build still succeeds.
14863
+ *
14864
+ * Cached Google Font binaries are deduplicated by `hashedFilename` so
14865
+ * each unique file is written exactly once even when multiple font
14866
+ * entries share a family (and therefore share `CachedFont` references).
14867
+ */
14868
+ function emitFontAssets(pipeline, emitFile, warn) {
14869
+ for (const cf of pipeline.uniqueCachedFiles()) emitFile({
14870
+ type: "asset",
14871
+ fileName: `_timber/fonts/${cf.hashedFilename}`,
14872
+ source: cf.data
14873
+ });
14874
+ for (const font of pipeline.fonts()) {
14875
+ if (font.provider !== "local" || !font.localSources) continue;
14876
+ for (const src of font.localSources) {
14877
+ const absolutePath = normalize(resolve(src.path));
14878
+ if (!existsSync(absolutePath)) {
14879
+ warn(`Local font file not found: ${absolutePath}`);
14880
+ continue;
14881
+ }
14882
+ const basename = src.path.split("/").pop() ?? src.path;
14883
+ const data = readFileSync(absolutePath);
14884
+ emitFile({
14885
+ type: "asset",
14886
+ fileName: `_timber/fonts/${basename}`,
14887
+ source: data
14888
+ });
14889
+ }
14890
+ }
14891
+ }
14892
+ /**
14893
+ * Populate `buildManifest.fonts` with `ManifestFontEntry[]` keyed by the
14894
+ * importer module path (relative to the project root, matching how Vite's
14895
+ * `manifest.json` keys css/js).
14896
+ *
14897
+ * Google fonts use the content-hashed filenames produced during
14898
+ * `buildStart`. Local fonts use the source basename.
14899
+ */
14900
+ function writeFontManifest(pipeline, buildManifest, rootDir) {
14901
+ const fontsByImporter = /* @__PURE__ */ new Map();
14902
+ for (const entry of pipeline.entries()) {
14903
+ const manifestEntries = fontsByImporter.get(entry.importer) ?? [];
14904
+ if (entry.provider === "local" && entry.localSources) for (const src of entry.localSources) {
14905
+ const filename = src.path.split("/").pop() ?? src.path;
14906
+ const format = inferFontFormat(src.path);
14907
+ manifestEntries.push({
14908
+ href: `/_timber/fonts/${filename}`,
14909
+ format,
14910
+ crossOrigin: "anonymous"
14911
+ });
14912
+ }
14913
+ else if (entry.cachedFiles) for (const cf of entry.cachedFiles) manifestEntries.push({
14914
+ href: `/_timber/fonts/${cf.hashedFilename}`,
14915
+ format: "woff2",
14916
+ crossOrigin: "anonymous"
14917
+ });
14918
+ fontsByImporter.set(entry.importer, manifestEntries);
14919
+ }
14920
+ for (const [importer, entries] of fontsByImporter) {
14921
+ const relativePath = importer.startsWith(rootDir) ? importer.slice(rootDir.length + 1) : importer;
14922
+ buildManifest.fonts[relativePath] = entries;
14923
+ }
14924
+ }
14925
+ //#endregion
14926
+ //#region src/plugins/fonts.ts
14927
+ /**
14602
14928
  * Create the timber-fonts Vite plugin.
14603
14929
  */
14604
14930
  function timberFonts(ctx) {
14605
- const registry = /* @__PURE__ */ new Map();
14606
- /** Fonts downloaded during buildStart (production only). */
14607
- let cachedFonts = [];
14608
- /**
14609
- * Pre-resolved @font-face descriptors for Google fonts, keyed by font ID.
14610
- * Populated in buildStart (production) or lazily in load (dev).
14611
- */
14612
- const googleFontFacesMap = /* @__PURE__ */ new Map();
14931
+ const pipeline = new FontPipeline();
14613
14932
  return {
14614
14933
  name: "timber-fonts",
14615
14934
  resolveId(id) {
@@ -14619,200 +14938,50 @@ function timberFonts(ctx) {
14619
14938
  if (stripped.startsWith("/") || stripped.startsWith("\\")) cleanId = stripped.slice(1);
14620
14939
  else cleanId = stripped;
14621
14940
  }
14622
- if (cleanId === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
14623
- if (cleanId === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
14624
- if (cleanId === VIRTUAL_FONT_CSS_REGISTER) return RESOLVED_FONT_CSS_REGISTER;
14941
+ if (cleanId === "@timber/fonts/google") return RESOLVED_GOOGLE;
14942
+ if (cleanId === "@timber/fonts/local") return RESOLVED_LOCAL;
14943
+ if (cleanId === "virtual:timber-font-css-register") return RESOLVED_FONT_CSS_REGISTER;
14625
14944
  return null;
14626
14945
  },
14627
14946
  async load(id) {
14628
- if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(registry);
14629
- if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
14630
- if (id === RESOLVED_FONT_CSS_REGISTER) {
14631
- if (ctx.dev) {
14632
- const googleFonts = [...registry.values()].filter((f) => f.provider === "google");
14633
- for (const font of googleFonts) if (!googleFontFacesMap.has(font.id)) try {
14947
+ if (id === "\0@timber/fonts/google") return generateGoogleVirtualModule(pipeline.fonts());
14948
+ if (id === "\0@timber/fonts/local") return generateLocalVirtualModule();
14949
+ if (id === "\0virtual:timber-font-css-register") {
14950
+ if (ctx.dev) for (const font of pipeline.googleFonts()) {
14951
+ if (pipeline.hasFaces(font.id)) continue;
14952
+ try {
14634
14953
  const faces = await resolveDevFontFaces(font);
14635
- googleFontFacesMap.set(font.id, faces);
14954
+ pipeline.attachFaces(font.id, faces);
14636
14955
  } catch (e) {
14637
14956
  const msg = e instanceof Error ? e.message : String(e);
14638
14957
  console.warn(`[timber-fonts] Failed to resolve Google font "${font.family}": ${msg}. Will retry on next request.`);
14639
14958
  }
14640
14959
  }
14641
- const css = generateAllFontCss(registry, googleFontFacesMap);
14642
- return `globalThis.__timber_font_css = ${JSON.stringify(css)};`;
14960
+ return `globalThis.__timber_font_css = ${JSON.stringify(pipeline.getCss())};`;
14643
14961
  }
14644
14962
  return null;
14645
14963
  },
14646
14964
  configureServer(server) {
14647
- server.middlewares.use((req, res, next) => {
14648
- const url = req.url;
14649
- if (!url || !url.startsWith("/_timber/fonts/")) return next();
14650
- const requestedFilename = url.slice(15);
14651
- if (requestedFilename.includes("..") || requestedFilename.includes("/")) {
14652
- res.statusCode = 400;
14653
- res.end("Bad request");
14654
- return;
14655
- }
14656
- for (const font of registry.values()) {
14657
- if (font.provider !== "local" || !font.localSources) continue;
14658
- for (const src of font.localSources) if ((src.path.split("/").pop() ?? "") === requestedFilename) {
14659
- const absolutePath = normalize(resolve(src.path));
14660
- if (!existsSync(absolutePath)) {
14661
- res.statusCode = 404;
14662
- res.end("Not found");
14663
- return;
14664
- }
14665
- const data = readFileSync(absolutePath);
14666
- const ext = absolutePath.split(".").pop()?.toLowerCase();
14667
- res.setHeader("Content-Type", {
14668
- woff2: "font/woff2",
14669
- woff: "font/woff",
14670
- ttf: "font/ttf",
14671
- otf: "font/otf",
14672
- eot: "application/vnd.ms-fontopen"
14673
- }[ext ?? ""] ?? "application/octet-stream");
14674
- res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
14675
- res.setHeader("Access-Control-Allow-Origin", "*");
14676
- res.end(data);
14677
- return;
14678
- }
14679
- }
14680
- next();
14681
- });
14965
+ installFontDevMiddleware(server, pipeline);
14682
14966
  },
14683
14967
  async buildStart() {
14684
14968
  if (ctx.dev) return;
14685
- const googleFonts = [...registry.values()].filter((f) => f.provider === "google");
14969
+ const googleFonts = [...pipeline.googleFonts()];
14686
14970
  if (googleFonts.length === 0) return;
14687
- cachedFonts = await downloadAndCacheFonts(googleFonts, ctx.root);
14688
- const cachedByFamily = /* @__PURE__ */ new Map();
14689
- for (const cf of cachedFonts) {
14690
- const key = cf.face.family.toLowerCase();
14691
- const arr = cachedByFamily.get(key) ?? [];
14692
- arr.push(cf);
14693
- cachedByFamily.set(key, arr);
14694
- }
14971
+ const cachedByFamily = groupCachedFontsByFamily(await downloadAndCacheFonts(googleFonts, ctx.root));
14695
14972
  for (const font of googleFonts) {
14696
- const faces = generateProductionFontFaces(cachedByFamily.get(font.family.toLowerCase()) ?? [], font.display);
14697
- googleFontFacesMap.set(font.id, faces);
14973
+ const familyCached = cachedByFamily.get(font.family.toLowerCase()) ?? [];
14974
+ pipeline.attachCachedFiles(font.id, familyCached);
14975
+ pipeline.attachFaces(font.id, generateProductionFontFaces(familyCached, font.display));
14698
14976
  }
14699
14977
  },
14700
14978
  transform(code, id) {
14701
- if (id.startsWith("\0") || id.includes("node_modules")) return null;
14702
- const hasGoogleImport = code.includes("@timber/fonts/google") || code.includes("@timber-js/app/fonts/google") || code.includes("next/font/google");
14703
- const hasLocalImport = code.includes("@timber/fonts/local") || code.includes("@timber-js/app/fonts/local") || code.includes("next/font/local");
14704
- if (!hasGoogleImport && !hasLocalImport) return null;
14705
- let transformedCode = code;
14706
- if (hasGoogleImport) {
14707
- const families = parseGoogleFontFamilies(code);
14708
- if (families.size > 0) {
14709
- const dynamicCall = detectDynamicFontCall(code, [...families.keys()]);
14710
- if (dynamicCall) this.error(`Font function calls must be statically analyzable. Found dynamic call: ${dynamicCall}. Pass a literal object with string/array values instead.`);
14711
- for (const [localName, family] of families) {
14712
- const callPattern = new RegExp(`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`, "g");
14713
- let callMatch;
14714
- while ((callMatch = callPattern.exec(code)) !== null) {
14715
- const varName = callMatch[1];
14716
- const configSource = callMatch[2];
14717
- const fullMatch = callMatch[0];
14718
- const config = extractFontConfig(`(${configSource})`);
14719
- if (!config) {
14720
- this.error(`Could not statically analyze font config for ${family}. Ensure all config values are string literals or arrays of string literals.`);
14721
- return null;
14722
- }
14723
- const fontId = generateFontId(family, config);
14724
- const className = familyToClassName(family);
14725
- const fontStack = buildFontStack(family);
14726
- const display = config.display ?? "swap";
14727
- const extracted = {
14728
- id: fontId,
14729
- family,
14730
- provider: "google",
14731
- weights: normalizeToArray(config.weight),
14732
- styles: normalizeStyleArray(config.style),
14733
- subsets: config.subsets ?? ["latin"],
14734
- display,
14735
- variable: config.variable,
14736
- className,
14737
- fontFamily: fontStack,
14738
- importer: id
14739
- };
14740
- registry.set(fontId, extracted);
14741
- const replacement = `const ${varName} = ${config.variable ? `{ className: "${className}", style: { fontFamily: "${fontStack}" }, variable: "${config.variable}" }` : `{ className: "${className}", style: { fontFamily: "${fontStack}" } }`}`;
14742
- transformedCode = transformedCode.replace(fullMatch, replacement);
14743
- }
14744
- }
14745
- transformedCode = transformedCode.replace(/import\s*\{[^}]+\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"];?\s*\n?/g, "");
14746
- }
14747
- }
14748
- if (hasLocalImport) transformedCode = transformLocalFonts(transformedCode, code, id, registry, this.error.bind(this));
14749
- if (transformedCode !== code) {
14750
- if (registry.size > 0) transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
14751
- return {
14752
- code: transformedCode,
14753
- map: null
14754
- };
14755
- }
14756
- return null;
14979
+ return runFontsTransform(code, id, pipeline, (msg) => this.error(msg));
14757
14980
  },
14758
14981
  generateBundle() {
14759
- for (const cf of cachedFonts) this.emitFile({
14760
- type: "asset",
14761
- fileName: `_timber/fonts/${cf.hashedFilename}`,
14762
- source: cf.data
14763
- });
14764
- for (const font of registry.values()) {
14765
- if (font.provider !== "local" || !font.localSources) continue;
14766
- for (const src of font.localSources) {
14767
- const absolutePath = normalize(resolve(src.path));
14768
- if (!existsSync(absolutePath)) {
14769
- this.warn(`Local font file not found: ${absolutePath}`);
14770
- continue;
14771
- }
14772
- const basename = src.path.split("/").pop() ?? src.path;
14773
- const data = readFileSync(absolutePath);
14774
- this.emitFile({
14775
- type: "asset",
14776
- fileName: `_timber/fonts/${basename}`,
14777
- source: data
14778
- });
14779
- }
14780
- }
14982
+ emitFontAssets(pipeline, (asset) => this.emitFile(asset), (msg) => this.warn(msg));
14781
14983
  if (!ctx.buildManifest) return;
14782
- const cachedByFamily = /* @__PURE__ */ new Map();
14783
- for (const cf of cachedFonts) {
14784
- const key = cf.face.family.toLowerCase();
14785
- const arr = cachedByFamily.get(key) ?? [];
14786
- arr.push(cf);
14787
- cachedByFamily.set(key, arr);
14788
- }
14789
- const fontsByImporter = /* @__PURE__ */ new Map();
14790
- for (const font of registry.values()) {
14791
- const entries = fontsByImporter.get(font.importer) ?? [];
14792
- if (font.provider === "local" && font.localSources) for (const src of font.localSources) {
14793
- const filename = src.path.split("/").pop() ?? src.path;
14794
- const format = inferFontFormat(src.path);
14795
- entries.push({
14796
- href: `/_timber/fonts/${filename}`,
14797
- format,
14798
- crossOrigin: "anonymous"
14799
- });
14800
- }
14801
- else {
14802
- const familyKey = font.family.toLowerCase();
14803
- const familyCached = cachedByFamily.get(familyKey) ?? [];
14804
- for (const cf of familyCached) entries.push({
14805
- href: `/_timber/fonts/${cf.hashedFilename}`,
14806
- format: "woff2",
14807
- crossOrigin: "anonymous"
14808
- });
14809
- }
14810
- fontsByImporter.set(font.importer, entries);
14811
- }
14812
- for (const [importer, entries] of fontsByImporter) {
14813
- const relativePath = importer.startsWith(ctx.root) ? importer.slice(ctx.root.length + 1) : importer;
14814
- ctx.buildManifest.fonts[relativePath] = entries;
14815
- }
14984
+ writeFontManifest(pipeline, ctx.buildManifest, ctx.root);
14816
14985
  }
14817
14986
  };
14818
14987
  }