@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.
- 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/config-validation.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 +753 -575
- 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/config-validation.ts +7 -3
- 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
|
@@ -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
|
-
|
|
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/
|
|
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
|
-
});
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
14299
|
-
|
|
14300
|
-
|
|
14301
|
-
|
|
14302
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
}));
|
|
14543
|
+
function familyToExportName(family) {
|
|
14544
|
+
return family.replace(/\s+/g, "_");
|
|
14322
14545
|
}
|
|
14323
14546
|
/**
|
|
14324
|
-
* Generate
|
|
14547
|
+
* Generate the virtual module source for `@timber/fonts/google`.
|
|
14325
14548
|
*
|
|
14326
|
-
*
|
|
14327
|
-
*
|
|
14328
|
-
*
|
|
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
|
|
14331
|
-
|
|
14332
|
-
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
|
|
14336
|
-
|
|
14337
|
-
|
|
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
|
-
*
|
|
14587
|
+
* Generate the virtual module source for `@timber/fonts/local`.
|
|
14342
14588
|
*
|
|
14343
|
-
*
|
|
14344
|
-
*
|
|
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
|
-
|
|
14347
|
-
return
|
|
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/
|
|
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
|
-
*
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
14425
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
14444
|
-
*
|
|
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 '
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
14595
|
-
|
|
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
|
|
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 ===
|
|
14623
|
-
if (cleanId ===
|
|
14624
|
-
if (cleanId ===
|
|
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 ===
|
|
14629
|
-
if (id ===
|
|
14630
|
-
if (id ===
|
|
14631
|
-
if (ctx.dev) {
|
|
14632
|
-
|
|
14633
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = [...
|
|
14978
|
+
const googleFonts = [...pipeline.googleFonts()];
|
|
14686
14979
|
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
|
-
}
|
|
14980
|
+
const cachedByFamily = groupCachedFontsByFamily(await downloadAndCacheFonts(googleFonts, ctx.root));
|
|
14695
14981
|
for (const font of googleFonts) {
|
|
14696
|
-
const
|
|
14697
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|