@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.
- package/LICENSE +8 -0
- package/dist/client/index.d.ts +44 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +7 -44
- package/dist/client/link.d.ts.map +1 -1
- package/dist/config-types.d.ts +39 -0
- package/dist/config-types.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +48 -0
- package/dist/fonts/bundle.d.ts.map +1 -0
- package/dist/fonts/dev-middleware.d.ts +22 -0
- package/dist/fonts/dev-middleware.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +138 -0
- package/dist/fonts/pipeline.d.ts.map +1 -0
- package/dist/fonts/transform.d.ts +72 -0
- package/dist/fonts/transform.d.ts.map +1 -0
- package/dist/fonts/types.d.ts +45 -1
- package/dist/fonts/types.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +59 -0
- package/dist/fonts/virtual-modules.d.ts.map +1 -0
- package/dist/index.js +742 -573
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +16 -83
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/server/action-client.d.ts +8 -0
- package/dist/server/action-client.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +7 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/index.js +158 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/route-matcher.d.ts +7 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/sensitive-fields.d.ts +74 -0
- package/dist/server/sensitive-fields.d.ts.map +1 -0
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/index.ts +77 -1
- package/src/client/link.tsx +15 -65
- package/src/config-types.ts +39 -0
- package/src/fonts/bundle.ts +142 -0
- package/src/fonts/dev-middleware.ts +74 -0
- package/src/fonts/pipeline.ts +275 -0
- package/src/fonts/transform.ts +353 -0
- package/src/fonts/types.ts +50 -1
- package/src/fonts/virtual-modules.ts +159 -0
- package/src/plugins/entries.ts +37 -0
- package/src/plugins/fonts.ts +102 -704
- package/src/plugins/routing.ts +6 -5
- package/src/server/action-client.ts +34 -4
- package/src/server/action-handler.ts +32 -2
- package/src/server/route-matcher.ts +7 -0
- package/src/server/rsc-entry/index.ts +19 -3
- 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/
|
|
14117
|
-
|
|
14118
|
-
|
|
14119
|
-
*
|
|
14120
|
-
*
|
|
14121
|
-
*
|
|
14122
|
-
*
|
|
14123
|
-
*
|
|
14124
|
-
*
|
|
14125
|
-
|
|
14126
|
-
|
|
14127
|
-
|
|
14128
|
-
|
|
14129
|
-
|
|
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
|
-
|
|
14210
|
-
|
|
14211
|
-
|
|
14212
|
-
|
|
14213
|
-
*/
|
|
14214
|
-
|
|
14215
|
-
|
|
14216
|
-
|
|
14217
|
-
|
|
14218
|
-
|
|
14219
|
-
|
|
14220
|
-
|
|
14221
|
-
|
|
14222
|
-
|
|
14223
|
-
|
|
14224
|
-
|
|
14225
|
-
|
|
14226
|
-
|
|
14227
|
-
*
|
|
14228
|
-
*
|
|
14229
|
-
*
|
|
14230
|
-
*
|
|
14231
|
-
|
|
14232
|
-
|
|
14233
|
-
|
|
14234
|
-
|
|
14235
|
-
|
|
14236
|
-
|
|
14237
|
-
|
|
14238
|
-
|
|
14239
|
-
|
|
14240
|
-
|
|
14241
|
-
|
|
14242
|
-
|
|
14243
|
-
|
|
14244
|
-
|
|
14245
|
-
|
|
14246
|
-
|
|
14247
|
-
|
|
14248
|
-
|
|
14249
|
-
|
|
14250
|
-
|
|
14251
|
-
|
|
14252
|
-
|
|
14253
|
-
*
|
|
14254
|
-
*
|
|
14255
|
-
*
|
|
14256
|
-
* -
|
|
14257
|
-
|
|
14258
|
-
|
|
14259
|
-
|
|
14260
|
-
|
|
14261
|
-
|
|
14262
|
-
|
|
14263
|
-
|
|
14264
|
-
|
|
14265
|
-
|
|
14266
|
-
|
|
14267
|
-
|
|
14268
|
-
|
|
14269
|
-
|
|
14270
|
-
|
|
14271
|
-
|
|
14272
|
-
|
|
14273
|
-
|
|
14274
|
-
|
|
14275
|
-
|
|
14276
|
-
|
|
14277
|
-
|
|
14278
|
-
|
|
14279
|
-
|
|
14280
|
-
|
|
14281
|
-
|
|
14282
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
14299
|
-
|
|
14300
|
-
|
|
14301
|
-
|
|
14302
|
-
|
|
14303
|
-
}
|
|
14304
|
-
|
|
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
|
-
*
|
|
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
|
|
14314
|
-
return
|
|
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
|
|
14538
|
+
* Generate the virtual module source for `@timber/fonts/google`.
|
|
14325
14539
|
*
|
|
14326
|
-
*
|
|
14327
|
-
*
|
|
14328
|
-
*
|
|
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
|
|
14331
|
-
|
|
14332
|
-
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
|
|
14336
|
-
|
|
14337
|
-
|
|
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
|
-
*
|
|
14578
|
+
* Generate the virtual module source for `@timber/fonts/local`.
|
|
14342
14579
|
*
|
|
14343
|
-
*
|
|
14344
|
-
*
|
|
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
|
-
|
|
14347
|
-
return
|
|
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/
|
|
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
|
-
*
|
|
14357
|
-
*
|
|
14358
|
-
*
|
|
14359
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
14425
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
14444
|
-
*
|
|
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 '
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
14595
|
-
|
|
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
|
|
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 ===
|
|
14623
|
-
if (cleanId ===
|
|
14624
|
-
if (cleanId ===
|
|
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 ===
|
|
14629
|
-
if (id ===
|
|
14630
|
-
if (id ===
|
|
14631
|
-
if (ctx.dev) {
|
|
14632
|
-
|
|
14633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = [...
|
|
14969
|
+
const googleFonts = [...pipeline.googleFonts()];
|
|
14686
14970
|
if (googleFonts.length === 0) return;
|
|
14687
|
-
|
|
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
|
|
14697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|