@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/src/plugins/fonts.ts
CHANGED
|
@@ -1,438 +1,82 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* timber-fonts — Vite sub-plugin for build-time font processing.
|
|
2
|
+
* timber-fonts — Vite sub-plugin shell for build-time font processing.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - @font-face CSS generation and scoped class output
|
|
8
|
-
* - Size-adjusted fallback font generation
|
|
4
|
+
* This file is intentionally thin: it wires the Vite plugin hooks to the
|
|
5
|
+
* pipeline + per-concern modules under `../fonts/`. All real logic lives
|
|
6
|
+
* in:
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
8
|
+
* - `../fonts/pipeline.ts` — `FontPipeline` (state, mutators)
|
|
9
|
+
* - `../fonts/transform.ts` — transform-hook logic, parsing helpers
|
|
10
|
+
* - `../fonts/dev-middleware.ts` — dev font binary server
|
|
11
|
+
* - `../fonts/virtual-modules.ts` — virtual module ID constants + source
|
|
12
|
+
*
|
|
13
|
+
* The public exports below are the stable API that the test suite and
|
|
14
|
+
* other parts of the framework import. Anything new should be added to
|
|
15
|
+
* one of the per-concern modules and re-exported here only if external
|
|
16
|
+
* consumers need it.
|
|
13
17
|
*
|
|
14
18
|
* Design doc: 24-fonts.md
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
import type { Plugin, ViteDevServer } from 'vite';
|
|
18
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
19
|
-
import { resolve, normalize } from 'node:path';
|
|
20
22
|
import type { PluginContext } from '../plugin-context.js';
|
|
21
|
-
import type { ExtractedFont, GoogleFontConfig } from '../fonts/types.js';
|
|
22
|
-
import type { ManifestFontEntry } from '../server/build-manifest.js';
|
|
23
|
-
import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from '../fonts/css.js';
|
|
24
|
-
import { generateFallbackCss, buildFontStack } from '../fonts/fallbacks.js';
|
|
25
|
-
import { processLocalFont, generateLocalFontFaces } from '../fonts/local.js';
|
|
26
|
-
import { inferFontFormat } from '../fonts/local.js';
|
|
27
23
|
import {
|
|
28
24
|
downloadAndCacheFonts,
|
|
29
25
|
generateProductionFontFaces,
|
|
30
26
|
resolveDevFontFaces,
|
|
31
|
-
type CachedFont,
|
|
32
27
|
} from '../fonts/google.js';
|
|
33
|
-
import
|
|
28
|
+
import { FontPipeline } from '../fonts/pipeline.js';
|
|
29
|
+
import { runFontsTransform } from '../fonts/transform.js';
|
|
30
|
+
import { installFontDevMiddleware } from '../fonts/dev-middleware.js';
|
|
31
|
+
import { emitFontAssets, writeFontManifest, groupCachedFontsByFamily } from '../fonts/bundle.js';
|
|
34
32
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Registry of fonts extracted during transform.
|
|
64
|
-
* Keyed by a unique font ID derived from family + config.
|
|
65
|
-
*/
|
|
66
|
-
export type FontRegistry = Map<string, ExtractedFont>;
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Convert a font family name to a PascalCase export name.
|
|
70
|
-
* e.g. "JetBrains Mono" → "JetBrains_Mono"
|
|
71
|
-
*/
|
|
72
|
-
function familyToExportName(family: string): string {
|
|
73
|
-
return family.replace(/\s+/g, '_');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Convert a font family name to a scoped class name.
|
|
78
|
-
* e.g. "JetBrains Mono" → "timber-font-jetbrains-mono"
|
|
79
|
-
*/
|
|
80
|
-
function familyToClassName(family: string): string {
|
|
81
|
-
return `timber-font-${family.toLowerCase().replace(/\s+/g, '-')}`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Generate a unique font ID from family + config hash.
|
|
86
|
-
*/
|
|
87
|
-
export function generateFontId(family: string, config: GoogleFontConfig): string {
|
|
88
|
-
const weights = normalizeToArray(config.weight);
|
|
89
|
-
const styles = normalizeToArray(config.style);
|
|
90
|
-
const subsets = config.subsets ?? ['latin'];
|
|
91
|
-
const display = config.display ?? 'swap';
|
|
92
|
-
return `${family.toLowerCase()}-${weights.join(',')}-${styles.join(',')}-${subsets.join(',')}-${display}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Normalize a string or string array to an array.
|
|
97
|
-
*/
|
|
98
|
-
function normalizeToArray(value: string | string[] | undefined): string[] {
|
|
99
|
-
if (!value) return ['400'];
|
|
100
|
-
return Array.isArray(value) ? value : [value];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Normalize style to an array.
|
|
105
|
-
*/
|
|
106
|
-
function normalizeStyleArray(value: string | string[] | undefined): string[] {
|
|
107
|
-
if (!value) return ['normal'];
|
|
108
|
-
return Array.isArray(value) ? value : [value];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Extract static font config from a font function call in source code.
|
|
113
|
-
*
|
|
114
|
-
* Parses patterns like:
|
|
115
|
-
* const inter = Inter({ subsets: ['latin'], weight: '400', display: 'swap', variable: '--font-sans' })
|
|
116
|
-
*
|
|
117
|
-
* Returns null if the call cannot be statically analyzed.
|
|
118
|
-
*
|
|
119
|
-
* Uses acorn AST parsing for robust handling of comments, trailing commas,
|
|
120
|
-
* and multi-line configs.
|
|
121
|
-
*/
|
|
122
|
-
export function extractFontConfig(callSource: string): GoogleFontConfig | null {
|
|
123
|
-
return extractFontConfigAst(callSource);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Detect if a source file contains dynamic/computed font function calls
|
|
128
|
-
* that cannot be statically analyzed.
|
|
129
|
-
*
|
|
130
|
-
* Returns the offending expression if found, null if all calls are static.
|
|
131
|
-
*
|
|
132
|
-
* Uses acorn AST parsing for accurate detection.
|
|
133
|
-
*/
|
|
134
|
-
export function detectDynamicFontCall(source: string, importedNames: string[]): string | null {
|
|
135
|
-
return detectDynamicFontCallAst(source, importedNames);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Regex that matches imports from either `@timber/fonts/google` or `next/font/google`.
|
|
140
|
-
* The shims plugin resolves `next/font/google` to the same virtual module,
|
|
141
|
-
* but the source code still contains the original import specifier.
|
|
142
|
-
*/
|
|
143
|
-
const GOOGLE_FONT_IMPORT_RE =
|
|
144
|
-
/import\s*\{([^}]+)\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"]/g;
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Parse import specifiers from a source file that imports from
|
|
148
|
-
* `@timber/fonts/google` or `next/font/google`.
|
|
149
|
-
*
|
|
150
|
-
* Returns the list of imported font names (e.g. ['Inter', 'JetBrains_Mono']).
|
|
151
|
-
*/
|
|
152
|
-
export function parseGoogleFontImports(source: string): string[] {
|
|
153
|
-
const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
|
|
154
|
-
const names: string[] = [];
|
|
155
|
-
|
|
156
|
-
let match;
|
|
157
|
-
while ((match = importPattern.exec(source)) !== null) {
|
|
158
|
-
const specifiers = match[1]
|
|
159
|
-
.split(',')
|
|
160
|
-
.map((s) => s.trim())
|
|
161
|
-
.filter(Boolean);
|
|
162
|
-
for (const spec of specifiers) {
|
|
163
|
-
// Handle `Inter as MyInter` — we want the local name
|
|
164
|
-
const parts = spec.split(/\s+as\s+/);
|
|
165
|
-
names.push(parts[parts.length - 1].trim());
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return names;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Parse the original (remote) font family names from imports.
|
|
174
|
-
*
|
|
175
|
-
* Returns a map of local name → family name.
|
|
176
|
-
* e.g. { Inter: 'Inter', JetBrains_Mono: 'JetBrains Mono' }
|
|
177
|
-
*/
|
|
178
|
-
export function parseGoogleFontFamilies(source: string): Map<string, string> {
|
|
179
|
-
const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
|
|
180
|
-
const families = new Map<string, string>();
|
|
181
|
-
|
|
182
|
-
let match;
|
|
183
|
-
while ((match = importPattern.exec(source)) !== null) {
|
|
184
|
-
const specifiers = match[1]
|
|
185
|
-
.split(',')
|
|
186
|
-
.map((s) => s.trim())
|
|
187
|
-
.filter(Boolean);
|
|
188
|
-
for (const spec of specifiers) {
|
|
189
|
-
const parts = spec.split(/\s+as\s+/);
|
|
190
|
-
const originalName = parts[0].trim();
|
|
191
|
-
const localName = parts[parts.length - 1].trim();
|
|
192
|
-
// Convert export name back to family name: JetBrains_Mono → JetBrains Mono
|
|
193
|
-
const family = originalName.replace(/_/g, ' ');
|
|
194
|
-
families.set(localName, family);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
return families;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Generate the virtual module source for `@timber/fonts/google`.
|
|
203
|
-
*
|
|
204
|
-
* Each Google Font family gets a named export that returns a FontResult.
|
|
205
|
-
* In this base implementation, the functions return static data.
|
|
206
|
-
* The Google Fonts download task (timber-nk5) will add real font file URLs.
|
|
207
|
-
*/
|
|
208
|
-
function generateGoogleVirtualModule(registry: FontRegistry): string {
|
|
209
|
-
// Collect unique families from the registry
|
|
210
|
-
const families = new Set<string>();
|
|
211
|
-
for (const font of registry.values()) {
|
|
212
|
-
if (font.provider === 'google') families.add(font.family);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const lines = [
|
|
216
|
-
'// Auto-generated virtual module: @timber/fonts/google',
|
|
217
|
-
'// Each export is a font loader function that returns a FontResult.',
|
|
218
|
-
'',
|
|
219
|
-
];
|
|
220
|
-
|
|
221
|
-
// If no fonts registered yet, export a generic loader for any font
|
|
222
|
-
// This is the initial load — the transform hook will process actual calls
|
|
223
|
-
lines.push('function createFontResult(family, config) {');
|
|
224
|
-
lines.push(' return {');
|
|
225
|
-
lines.push(' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,');
|
|
226
|
-
lines.push(' style: { fontFamily: family },');
|
|
227
|
-
lines.push(' variable: config?.variable,');
|
|
228
|
-
lines.push(' };');
|
|
229
|
-
lines.push('}');
|
|
230
|
-
lines.push('');
|
|
231
|
-
|
|
232
|
-
// Export a Proxy-based default that handles any font name import
|
|
233
|
-
lines.push('export default new Proxy({}, {');
|
|
234
|
-
lines.push(' get(_, prop) {');
|
|
235
|
-
lines.push(' if (typeof prop === "string") {');
|
|
236
|
-
lines.push(' return (config) => createFontResult(prop.replace(/_/g, " "), config);');
|
|
237
|
-
lines.push(' }');
|
|
238
|
-
lines.push(' }');
|
|
239
|
-
lines.push('});');
|
|
240
|
-
|
|
241
|
-
// Also export known families as named exports for tree-shaking
|
|
242
|
-
for (const family of families) {
|
|
243
|
-
const exportName = familyToExportName(family);
|
|
244
|
-
lines.push('');
|
|
245
|
-
lines.push(`export function ${exportName}(config) {`);
|
|
246
|
-
lines.push(` return createFontResult('${family}', config);`);
|
|
247
|
-
lines.push('}');
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
return lines.join('\n');
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Generate the virtual module source for `@timber/fonts/local`.
|
|
255
|
-
*/
|
|
256
|
-
function generateLocalVirtualModule(): string {
|
|
257
|
-
return [
|
|
258
|
-
'// Auto-generated virtual module: @timber/fonts/local',
|
|
259
|
-
'',
|
|
260
|
-
'export default function localFont(config) {',
|
|
261
|
-
' const family = config?.family || "Local Font";',
|
|
262
|
-
' return {',
|
|
263
|
-
' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
|
|
264
|
-
' style: { fontFamily: family },',
|
|
265
|
-
' variable: config?.variable,',
|
|
266
|
-
' };',
|
|
267
|
-
'}',
|
|
268
|
-
].join('\n');
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Generate CSS for a single extracted font.
|
|
273
|
-
*
|
|
274
|
-
* Includes @font-face rules (for local and Google fonts), fallback @font-face,
|
|
275
|
-
* and the scoped class rule.
|
|
276
|
-
*
|
|
277
|
-
* For Google fonts, pass the resolved FontFaceDescriptor[] from either
|
|
278
|
-
* `generateProductionFontFaces()` (production) or `resolveDevFontFaces()` (dev).
|
|
279
|
-
*/
|
|
280
|
-
export function generateFontCss(font: ExtractedFont, googleFaces?: FontFaceDescriptor[]): string {
|
|
281
|
-
const cssParts: string[] = [];
|
|
282
|
-
|
|
283
|
-
if (font.provider === 'local' && font.localSources) {
|
|
284
|
-
const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
|
|
285
|
-
const faceCss = generateFontFaces(faces);
|
|
286
|
-
if (faceCss) cssParts.push(faceCss);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (font.provider === 'google' && googleFaces && googleFaces.length > 0) {
|
|
290
|
-
const faceCss = generateFontFaces(googleFaces);
|
|
291
|
-
if (faceCss) cssParts.push(faceCss);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const fallbackCss = generateFallbackCss(font.family);
|
|
295
|
-
if (fallbackCss) cssParts.push(fallbackCss);
|
|
296
|
-
|
|
297
|
-
if (font.variable) {
|
|
298
|
-
cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
|
|
299
|
-
} else {
|
|
300
|
-
cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
return cssParts.join('\n\n');
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Generate the CSS output for all extracted fonts.
|
|
308
|
-
*
|
|
309
|
-
* Includes @font-face rules for local and Google fonts, fallback @font-face
|
|
310
|
-
* rules, and scoped classes.
|
|
311
|
-
*
|
|
312
|
-
* `googleFontFacesMap` provides pre-resolved FontFaceDescriptor[] for each
|
|
313
|
-
* Google font ID (keyed by ExtractedFont.id).
|
|
314
|
-
*/
|
|
315
|
-
export function generateAllFontCss(
|
|
316
|
-
registry: FontRegistry,
|
|
317
|
-
googleFontFacesMap?: Map<string, FontFaceDescriptor[]>
|
|
318
|
-
): string {
|
|
319
|
-
const cssParts: string[] = [];
|
|
320
|
-
for (const font of registry.values()) {
|
|
321
|
-
const googleFaces = googleFontFacesMap?.get(font.id);
|
|
322
|
-
cssParts.push(generateFontCss(font, googleFaces));
|
|
323
|
-
}
|
|
324
|
-
return cssParts.join('\n\n');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Parse the local name used for the default import of `@timber/fonts/local`.
|
|
329
|
-
*
|
|
330
|
-
* Handles:
|
|
331
|
-
* import localFont from '@timber/fonts/local'
|
|
332
|
-
* import myLoader from '@timber/fonts/local'
|
|
333
|
-
*/
|
|
334
|
-
export function parseLocalFontImportName(source: string): string | null {
|
|
335
|
-
const match = source.match(
|
|
336
|
-
/import\s+(\w+)\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"]/
|
|
337
|
-
);
|
|
338
|
-
return match ? match[1] : null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/**
|
|
342
|
-
* Transform local font calls in source code.
|
|
343
|
-
*
|
|
344
|
-
* Finds `localFont({ ... })` calls, extracts the config,
|
|
345
|
-
* registers the font, and replaces the call with a static FontResult.
|
|
346
|
-
*/
|
|
347
|
-
function transformLocalFonts(
|
|
348
|
-
transformedCode: string,
|
|
349
|
-
originalCode: string,
|
|
350
|
-
importerId: string,
|
|
351
|
-
registry: FontRegistry,
|
|
352
|
-
emitError: (msg: string) => void
|
|
353
|
-
): string {
|
|
354
|
-
const localName = parseLocalFontImportName(originalCode);
|
|
355
|
-
if (!localName) return transformedCode;
|
|
356
|
-
|
|
357
|
-
// Check for dynamic calls
|
|
358
|
-
const dynamicCall = detectDynamicFontCall(originalCode, [localName]);
|
|
359
|
-
if (dynamicCall) {
|
|
360
|
-
emitError(
|
|
361
|
-
`Font function calls must be statically analyzable. ` +
|
|
362
|
-
`Found dynamic call: ${dynamicCall}. ` +
|
|
363
|
-
`Pass a literal object with string/array values instead.`
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Find all calls: const varName = localFont({ ... })
|
|
368
|
-
const callPattern = new RegExp(
|
|
369
|
-
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
370
|
-
'g'
|
|
371
|
-
);
|
|
372
|
-
|
|
373
|
-
let callMatch;
|
|
374
|
-
while ((callMatch = callPattern.exec(originalCode)) !== null) {
|
|
375
|
-
const varName = callMatch[1];
|
|
376
|
-
const configSource = callMatch[2];
|
|
377
|
-
const fullMatch = callMatch[0];
|
|
378
|
-
|
|
379
|
-
const config = extractLocalFontConfigAst(`(${configSource})`);
|
|
380
|
-
if (!config) {
|
|
381
|
-
emitError(
|
|
382
|
-
`Could not statically analyze local font config. ` +
|
|
383
|
-
`Ensure src is a string or array of { path, weight?, style? } objects.`
|
|
384
|
-
);
|
|
385
|
-
return transformedCode;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const extracted = processLocalFont(config, importerId);
|
|
389
|
-
registry.set(extracted.id, extracted);
|
|
390
|
-
|
|
391
|
-
const resultObj = extracted.variable
|
|
392
|
-
? `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" }, variable: "${extracted.variable}" }`
|
|
393
|
-
: `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" } }`;
|
|
394
|
-
|
|
395
|
-
const replacement = `const ${varName} = ${resultObj}`;
|
|
396
|
-
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Remove the import statement
|
|
400
|
-
transformedCode = transformedCode.replace(
|
|
401
|
-
/import\s+\w+\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"];?\s*\n?/g,
|
|
402
|
-
''
|
|
403
|
-
);
|
|
404
|
-
|
|
405
|
-
return transformedCode;
|
|
406
|
-
}
|
|
33
|
+
RESOLVED_GOOGLE,
|
|
34
|
+
RESOLVED_LOCAL,
|
|
35
|
+
RESOLVED_FONT_CSS_REGISTER,
|
|
36
|
+
VIRTUAL_GOOGLE,
|
|
37
|
+
VIRTUAL_LOCAL,
|
|
38
|
+
VIRTUAL_FONT_CSS_REGISTER,
|
|
39
|
+
generateGoogleVirtualModule,
|
|
40
|
+
generateLocalVirtualModule,
|
|
41
|
+
} from '../fonts/virtual-modules.js';
|
|
42
|
+
|
|
43
|
+
// ── Public re-exports ────────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// These exports are part of the stable surface area imported by tests and
|
|
46
|
+
// downstream consumers. Do NOT change their names or signatures without
|
|
47
|
+
// updating every caller. New helpers should be added to the per-concern
|
|
48
|
+
// module first and re-exported here only if external code needs them.
|
|
49
|
+
|
|
50
|
+
export { FontPipeline, pruneRegistryEntries, type FontRegistry } from '../fonts/pipeline.js';
|
|
51
|
+
export {
|
|
52
|
+
generateFontId,
|
|
53
|
+
extractFontConfig,
|
|
54
|
+
detectDynamicFontCall,
|
|
55
|
+
parseGoogleFontImports,
|
|
56
|
+
parseGoogleFontFamilies,
|
|
57
|
+
parseLocalFontImportName,
|
|
58
|
+
} from '../fonts/transform.js';
|
|
59
|
+
export { generateFontCss, generateAllFontCss } from '../fonts/virtual-modules.js';
|
|
407
60
|
|
|
408
61
|
/**
|
|
409
62
|
* Create the timber-fonts Vite plugin.
|
|
410
63
|
*/
|
|
411
64
|
export function timberFonts(ctx: PluginContext): Plugin {
|
|
412
|
-
const
|
|
413
|
-
/** Fonts downloaded during buildStart (production only). */
|
|
414
|
-
let cachedFonts: CachedFont[] = [];
|
|
415
|
-
/**
|
|
416
|
-
* Pre-resolved @font-face descriptors for Google fonts, keyed by font ID.
|
|
417
|
-
* Populated in buildStart (production) or lazily in load (dev).
|
|
418
|
-
*/
|
|
419
|
-
const googleFontFacesMap = new Map<string, FontFaceDescriptor[]>();
|
|
65
|
+
const pipeline = new FontPipeline();
|
|
420
66
|
|
|
421
67
|
return {
|
|
422
68
|
name: 'timber-fonts',
|
|
423
69
|
|
|
424
70
|
/**
|
|
425
|
-
* Resolve `@timber/fonts/google`, `@timber/fonts/local`,
|
|
426
|
-
*
|
|
71
|
+
* Resolve `@timber/fonts/google`, `@timber/fonts/local`, and
|
|
72
|
+
* `virtual:timber-font-css-register` virtual modules.
|
|
427
73
|
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
74
|
+
* Strips the `\0` prefix added by the RSC plugin's re-imports and the
|
|
75
|
+
* absolute root prefix added by SSR build entries before comparing
|
|
76
|
+
* against the public IDs.
|
|
431
77
|
*/
|
|
432
78
|
resolveId(id: string) {
|
|
433
|
-
// Strip \0 prefix (RSC plugin re-imports)
|
|
434
79
|
let cleanId = id.startsWith('\0') ? id.slice(1) : id;
|
|
435
|
-
// Strip root prefix (SSR build entries)
|
|
436
80
|
if (cleanId.startsWith(ctx.root)) {
|
|
437
81
|
const stripped = cleanId.slice(ctx.root.length);
|
|
438
82
|
if (stripped.startsWith('/') || stripped.startsWith('\\')) {
|
|
@@ -451,351 +95,105 @@ export function timberFonts(ctx: PluginContext): Plugin {
|
|
|
451
95
|
/**
|
|
452
96
|
* Return generated source for font virtual modules.
|
|
453
97
|
*
|
|
454
|
-
* `virtual:timber-font-css`
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
98
|
+
* `virtual:timber-font-css-register` is a side-effect module that sets
|
|
99
|
+
* the combined `@font-face` CSS on `globalThis.__timber_font_css`. The
|
|
100
|
+
* RSC entry imports it transitively via the layout file and reads
|
|
101
|
+
* `globalThis.__timber_font_css` at render time to inline a `<style>`
|
|
102
|
+
* tag. In dev mode we also lazily resolve Google `@font-face`
|
|
103
|
+
* descriptors from the CDN here, since `buildStart` is a no-op in dev.
|
|
458
104
|
*/
|
|
459
105
|
async load(id: string) {
|
|
460
|
-
if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(
|
|
106
|
+
if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(pipeline.fonts());
|
|
461
107
|
if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
|
|
462
108
|
|
|
463
109
|
if (id === RESOLVED_FONT_CSS_REGISTER) {
|
|
464
|
-
// In dev mode, resolve Google font faces from CDN on demand.
|
|
465
110
|
if (ctx.dev) {
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
);
|
|
480
|
-
}
|
|
111
|
+
for (const font of pipeline.googleFonts()) {
|
|
112
|
+
if (pipeline.hasFaces(font.id)) continue;
|
|
113
|
+
try {
|
|
114
|
+
const faces = await resolveDevFontFaces(font);
|
|
115
|
+
pipeline.attachFaces(font.id, faces);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// Fail gracefully — fonts fall back to system fonts. Don't
|
|
118
|
+
// cache the failure so transient errors (network blips, rate
|
|
119
|
+
// limits) are retried on the next request. See TIM-636.
|
|
120
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
121
|
+
console.warn(
|
|
122
|
+
`[timber-fonts] Failed to resolve Google font "${font.family}": ${msg}. Will retry on next request.`
|
|
123
|
+
);
|
|
481
124
|
}
|
|
482
125
|
}
|
|
483
126
|
}
|
|
484
127
|
|
|
485
|
-
|
|
486
|
-
// Side-effect module: sets font CSS on globalThis for the RSC entry to read.
|
|
487
|
-
return `globalThis.__timber_font_css = ${JSON.stringify(css)};`;
|
|
128
|
+
return `globalThis.__timber_font_css = ${JSON.stringify(pipeline.getCss())};`;
|
|
488
129
|
}
|
|
489
130
|
return null;
|
|
490
131
|
},
|
|
491
132
|
|
|
492
133
|
/**
|
|
493
|
-
* Serve local font files
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* - `/_timber/fonts/fonts.css` — combined @font-face + scoped class CSS
|
|
497
|
-
* - `/_timber/fonts/<filename>` — individual font files from the registry
|
|
498
|
-
*
|
|
499
|
-
* Only files registered in the font registry are served.
|
|
500
|
-
* Paths are validated to prevent directory traversal.
|
|
134
|
+
* Serve local font files in dev mode under `/_timber/fonts/<basename>`.
|
|
135
|
+
* Font CSS goes through Vite's CSS pipeline (virtual modules), not
|
|
136
|
+
* this middleware.
|
|
501
137
|
*/
|
|
502
138
|
configureServer(server: ViteDevServer) {
|
|
503
|
-
server
|
|
504
|
-
const url = req.url;
|
|
505
|
-
if (!url || !url.startsWith('/_timber/fonts/')) return next();
|
|
506
|
-
|
|
507
|
-
const requestedFilename = url.slice('/_timber/fonts/'.length);
|
|
508
|
-
// Reject path traversal attempts
|
|
509
|
-
if (requestedFilename.includes('..') || requestedFilename.includes('/')) {
|
|
510
|
-
res.statusCode = 400;
|
|
511
|
-
res.end('Bad request');
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Font CSS is now injected via Vite's CSS pipeline (virtual:timber-font-css modules).
|
|
516
|
-
// This middleware only serves font binary files (woff2, etc.).
|
|
517
|
-
|
|
518
|
-
// Find the matching font file in the registry
|
|
519
|
-
for (const font of registry.values()) {
|
|
520
|
-
if (font.provider !== 'local' || !font.localSources) continue;
|
|
521
|
-
for (const src of font.localSources) {
|
|
522
|
-
const basename = src.path.split('/').pop() ?? '';
|
|
523
|
-
if (basename === requestedFilename) {
|
|
524
|
-
const absolutePath = normalize(resolve(src.path));
|
|
525
|
-
if (!existsSync(absolutePath)) {
|
|
526
|
-
res.statusCode = 404;
|
|
527
|
-
res.end('Not found');
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
const data = readFileSync(absolutePath);
|
|
531
|
-
const ext = absolutePath.split('.').pop()?.toLowerCase();
|
|
532
|
-
const mimeMap: Record<string, string> = {
|
|
533
|
-
woff2: 'font/woff2',
|
|
534
|
-
woff: 'font/woff',
|
|
535
|
-
ttf: 'font/ttf',
|
|
536
|
-
otf: 'font/otf',
|
|
537
|
-
eot: 'application/vnd.ms-fontopen',
|
|
538
|
-
};
|
|
539
|
-
res.setHeader('Content-Type', mimeMap[ext ?? ''] ?? 'application/octet-stream');
|
|
540
|
-
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
541
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
542
|
-
res.end(data);
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
next();
|
|
549
|
-
});
|
|
139
|
+
installFontDevMiddleware(server, pipeline);
|
|
550
140
|
},
|
|
551
141
|
|
|
552
142
|
/**
|
|
553
143
|
* Download and cache Google Fonts during production builds.
|
|
554
144
|
*
|
|
555
|
-
* In dev mode this is a no-op — fonts point to the Google CDN
|
|
145
|
+
* In dev mode this is a no-op — fonts point to the Google CDN and are
|
|
146
|
+
* resolved lazily inside `load()` when the side-effect module runs.
|
|
556
147
|
* The registry is populated by the transform hook which runs before
|
|
557
|
-
* buildStart in the build pipeline, so all fonts are known here.
|
|
148
|
+
* `buildStart` in the build pipeline, so all fonts are known here.
|
|
558
149
|
*/
|
|
559
150
|
async buildStart() {
|
|
560
151
|
if (ctx.dev) return;
|
|
561
152
|
|
|
562
|
-
const googleFonts = [...
|
|
153
|
+
const googleFonts = [...pipeline.googleFonts()];
|
|
563
154
|
if (googleFonts.length === 0) return;
|
|
564
155
|
|
|
565
|
-
cachedFonts = await downloadAndCacheFonts(googleFonts, ctx.root);
|
|
566
|
-
|
|
567
|
-
// Build a family→CachedFont[] lookup, then generate production @font-face
|
|
568
|
-
// descriptors for each registered Google font.
|
|
569
|
-
const cachedByFamily = new Map<string, CachedFont[]>();
|
|
570
|
-
for (const cf of cachedFonts) {
|
|
571
|
-
const key = cf.face.family.toLowerCase();
|
|
572
|
-
const arr = cachedByFamily.get(key) ?? [];
|
|
573
|
-
arr.push(cf);
|
|
574
|
-
cachedByFamily.set(key, arr);
|
|
575
|
-
}
|
|
156
|
+
const cachedFonts = await downloadAndCacheFonts(googleFonts, ctx.root);
|
|
576
157
|
|
|
158
|
+
// Build a family→CachedFont[] lookup, then attach the cached files
|
|
159
|
+
// and resolved @font-face descriptors to each registered Google
|
|
160
|
+
// font. Multiple fonts that share a family hold references to the
|
|
161
|
+
// same `CachedFont` objects; the bundle emitter dedupes by
|
|
162
|
+
// `hashedFilename` so each binary is written to disk exactly once.
|
|
163
|
+
const cachedByFamily = groupCachedFontsByFamily(cachedFonts);
|
|
577
164
|
for (const font of googleFonts) {
|
|
578
165
|
const familyCached = cachedByFamily.get(font.family.toLowerCase()) ?? [];
|
|
579
|
-
|
|
580
|
-
|
|
166
|
+
pipeline.attachCachedFiles(font.id, familyCached);
|
|
167
|
+
pipeline.attachFaces(font.id, generateProductionFontFaces(familyCached, font.display));
|
|
581
168
|
}
|
|
582
169
|
},
|
|
583
170
|
|
|
584
171
|
/**
|
|
585
172
|
* Scan source files for font function calls and extract static config.
|
|
586
173
|
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* 2. Find each font function call and extract its config
|
|
590
|
-
* 3. Validate that all calls are statically analyzable
|
|
591
|
-
* 4. Register extracted fonts in the registry
|
|
592
|
-
* 5. Replace the function call with a static FontResult object
|
|
174
|
+
* Delegates to `runFontsTransform`. Errors raised through the
|
|
175
|
+
* `this.error` callback are surfaced as Vite plugin errors.
|
|
593
176
|
*/
|
|
594
177
|
transform(code: string, id: string) {
|
|
595
|
-
|
|
596
|
-
if (id.startsWith('\0') || id.includes('node_modules')) return null;
|
|
597
|
-
|
|
598
|
-
const hasGoogleImport =
|
|
599
|
-
code.includes('@timber/fonts/google') ||
|
|
600
|
-
code.includes('@timber-js/app/fonts/google') ||
|
|
601
|
-
code.includes('next/font/google');
|
|
602
|
-
const hasLocalImport =
|
|
603
|
-
code.includes('@timber/fonts/local') ||
|
|
604
|
-
code.includes('@timber-js/app/fonts/local') ||
|
|
605
|
-
code.includes('next/font/local');
|
|
606
|
-
if (!hasGoogleImport && !hasLocalImport) return null;
|
|
607
|
-
|
|
608
|
-
let transformedCode = code;
|
|
609
|
-
|
|
610
|
-
// ── Google font transform ──────────────────────────────────────────
|
|
611
|
-
if (hasGoogleImport) {
|
|
612
|
-
const families = parseGoogleFontFamilies(code);
|
|
613
|
-
if (families.size > 0) {
|
|
614
|
-
const importedNames = [...families.keys()];
|
|
615
|
-
|
|
616
|
-
const dynamicCall = detectDynamicFontCall(code, importedNames);
|
|
617
|
-
if (dynamicCall) {
|
|
618
|
-
this.error(
|
|
619
|
-
`Font function calls must be statically analyzable. ` +
|
|
620
|
-
`Found dynamic call: ${dynamicCall}. ` +
|
|
621
|
-
`Pass a literal object with string/array values instead.`
|
|
622
|
-
);
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
for (const [localName, family] of families) {
|
|
626
|
-
const callPattern = new RegExp(
|
|
627
|
-
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
628
|
-
'g'
|
|
629
|
-
);
|
|
630
|
-
|
|
631
|
-
let callMatch;
|
|
632
|
-
while ((callMatch = callPattern.exec(code)) !== null) {
|
|
633
|
-
const varName = callMatch[1];
|
|
634
|
-
const configSource = callMatch[2];
|
|
635
|
-
const fullMatch = callMatch[0];
|
|
636
|
-
|
|
637
|
-
const config = extractFontConfig(`(${configSource})`);
|
|
638
|
-
if (!config) {
|
|
639
|
-
this.error(
|
|
640
|
-
`Could not statically analyze font config for ${family}. ` +
|
|
641
|
-
`Ensure all config values are string literals or arrays of string literals.`
|
|
642
|
-
);
|
|
643
|
-
return null;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const fontId = generateFontId(family, config);
|
|
647
|
-
const className = familyToClassName(family);
|
|
648
|
-
const fontStack = buildFontStack(family);
|
|
649
|
-
const display = config.display ?? 'swap';
|
|
650
|
-
|
|
651
|
-
const extracted: ExtractedFont = {
|
|
652
|
-
id: fontId,
|
|
653
|
-
family,
|
|
654
|
-
provider: 'google',
|
|
655
|
-
weights: normalizeToArray(config.weight),
|
|
656
|
-
styles: normalizeStyleArray(config.style),
|
|
657
|
-
subsets: config.subsets ?? ['latin'],
|
|
658
|
-
display,
|
|
659
|
-
variable: config.variable,
|
|
660
|
-
className,
|
|
661
|
-
fontFamily: fontStack,
|
|
662
|
-
importer: id,
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
registry.set(fontId, extracted);
|
|
666
|
-
|
|
667
|
-
const resultObj = config.variable
|
|
668
|
-
? `{ className: "${className}", style: { fontFamily: "${fontStack}" }, variable: "${config.variable}" }`
|
|
669
|
-
: `{ className: "${className}", style: { fontFamily: "${fontStack}" } }`;
|
|
670
|
-
|
|
671
|
-
const replacement = `const ${varName} = ${resultObj}`;
|
|
672
|
-
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
transformedCode = transformedCode.replace(
|
|
677
|
-
/import\s*\{[^}]+\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"];?\s*\n?/g,
|
|
678
|
-
''
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// ── Local font transform ───────────────────────────────────────────
|
|
684
|
-
if (hasLocalImport) {
|
|
685
|
-
transformedCode = transformLocalFonts(
|
|
686
|
-
transformedCode,
|
|
687
|
-
code,
|
|
688
|
-
id,
|
|
689
|
-
registry,
|
|
690
|
-
this.error.bind(this)
|
|
691
|
-
);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
if (transformedCode !== code) {
|
|
695
|
-
// Inject side-effect import that registers font CSS on globalThis.
|
|
696
|
-
// The RSC entry reads globalThis.__timber_font_css to inline a <style> tag.
|
|
697
|
-
if (registry.size > 0) {
|
|
698
|
-
transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
|
|
699
|
-
}
|
|
700
|
-
return { code: transformedCode, map: null };
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
return null;
|
|
178
|
+
return runFontsTransform(code, id, pipeline, (msg) => this.error(msg));
|
|
704
179
|
},
|
|
705
180
|
|
|
706
181
|
/**
|
|
707
|
-
* Emit font
|
|
708
|
-
*
|
|
709
|
-
* For Google fonts: emits the downloaded, content-hashed woff2 files
|
|
710
|
-
* and writes ManifestFontEntry arrays using real hashed URLs.
|
|
711
|
-
*
|
|
712
|
-
* For local fonts: emits entries using the source file paths.
|
|
182
|
+
* Emit font binaries and build-manifest entries.
|
|
713
183
|
*
|
|
714
|
-
*
|
|
184
|
+
* Google fonts use the content-hashed filenames produced during
|
|
185
|
+
* `buildStart`. Local fonts are emitted by basename. Font CSS is
|
|
186
|
+
* handled by Vite's CSS pipeline via virtual modules — we only emit
|
|
187
|
+
* binaries here.
|
|
715
188
|
*/
|
|
716
189
|
generateBundle() {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
this.emitFile(
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
source: cf.data,
|
|
723
|
-
});
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Emit local font files as assets
|
|
727
|
-
for (const font of registry.values()) {
|
|
728
|
-
if (font.provider !== 'local' || !font.localSources) continue;
|
|
729
|
-
for (const src of font.localSources) {
|
|
730
|
-
const absolutePath = normalize(resolve(src.path));
|
|
731
|
-
if (!existsSync(absolutePath)) {
|
|
732
|
-
this.warn(`Local font file not found: ${absolutePath}`);
|
|
733
|
-
continue;
|
|
734
|
-
}
|
|
735
|
-
const basename = src.path.split('/').pop() ?? src.path;
|
|
736
|
-
const data = readFileSync(absolutePath);
|
|
737
|
-
this.emitFile({
|
|
738
|
-
type: 'asset',
|
|
739
|
-
fileName: `_timber/fonts/${basename}`,
|
|
740
|
-
source: data,
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// Font CSS is emitted by Vite's CSS pipeline via virtual:timber-font-css modules.
|
|
746
|
-
// We only need to emit font binary files and update the build manifest here.
|
|
747
|
-
|
|
190
|
+
emitFontAssets(
|
|
191
|
+
pipeline,
|
|
192
|
+
(asset) => this.emitFile(asset),
|
|
193
|
+
(msg) => this.warn(msg)
|
|
194
|
+
);
|
|
748
195
|
if (!ctx.buildManifest) return;
|
|
749
|
-
|
|
750
|
-
// Build a lookup from font family → cached files for manifest entries
|
|
751
|
-
const cachedByFamily = new Map<string, CachedFont[]>();
|
|
752
|
-
for (const cf of cachedFonts) {
|
|
753
|
-
const key = cf.face.family.toLowerCase();
|
|
754
|
-
const arr = cachedByFamily.get(key) ?? [];
|
|
755
|
-
arr.push(cf);
|
|
756
|
-
cachedByFamily.set(key, arr);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
const fontsByImporter = new Map<string, ManifestFontEntry[]>();
|
|
760
|
-
|
|
761
|
-
for (const font of registry.values()) {
|
|
762
|
-
const entries = fontsByImporter.get(font.importer) ?? [];
|
|
763
|
-
|
|
764
|
-
if (font.provider === 'local' && font.localSources) {
|
|
765
|
-
// Local fonts: one entry per source file
|
|
766
|
-
for (const src of font.localSources) {
|
|
767
|
-
const filename = src.path.split('/').pop() ?? src.path;
|
|
768
|
-
const format = inferFontFormat(src.path);
|
|
769
|
-
entries.push({
|
|
770
|
-
href: `/_timber/fonts/${filename}`,
|
|
771
|
-
format,
|
|
772
|
-
crossOrigin: 'anonymous',
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
} else {
|
|
776
|
-
// Google fonts: use real content-hashed URLs from cached downloads
|
|
777
|
-
const familyKey = font.family.toLowerCase();
|
|
778
|
-
const familyCached = cachedByFamily.get(familyKey) ?? [];
|
|
779
|
-
for (const cf of familyCached) {
|
|
780
|
-
entries.push({
|
|
781
|
-
href: `/_timber/fonts/${cf.hashedFilename}`,
|
|
782
|
-
format: 'woff2',
|
|
783
|
-
crossOrigin: 'anonymous',
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
fontsByImporter.set(font.importer, entries);
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// Normalize importer paths to be relative to project root (matching
|
|
792
|
-
// how Vite's manifest.json keys work for css/js).
|
|
793
|
-
for (const [importer, entries] of fontsByImporter) {
|
|
794
|
-
const relativePath = importer.startsWith(ctx.root)
|
|
795
|
-
? importer.slice(ctx.root.length + 1)
|
|
796
|
-
: importer;
|
|
797
|
-
ctx.buildManifest.fonts[relativePath] = entries;
|
|
798
|
-
}
|
|
196
|
+
writeFontManifest(pipeline, ctx.buildManifest, ctx.root);
|
|
799
197
|
},
|
|
800
198
|
};
|
|
801
199
|
}
|