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

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