@timber-js/app 0.1.1 → 0.1.2
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/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timber-fonts — Vite sub-plugin for build-time font processing.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Virtual module resolution for `@timber/fonts/google` and `@timber/fonts/local`
|
|
6
|
+
* - Static analysis of font function calls during `transform`
|
|
7
|
+
* - @font-face CSS generation and scoped class output
|
|
8
|
+
* - Size-adjusted fallback font generation
|
|
9
|
+
*
|
|
10
|
+
* Does NOT handle (separate tasks):
|
|
11
|
+
* - Google Fonts downloading/caching (timber-nk5)
|
|
12
|
+
* - Build manifest / Early Hints integration (timber-qnx)
|
|
13
|
+
*
|
|
14
|
+
* Design doc: 24-fonts.md
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Plugin } from 'vite';
|
|
18
|
+
import type { PluginContext } from '#/index.js';
|
|
19
|
+
import type { ExtractedFont, GoogleFontConfig } from '#/fonts/types.js';
|
|
20
|
+
import type { ManifestFontEntry } from '#/server/build-manifest.js';
|
|
21
|
+
import { generateVariableClass, generateFontFamilyClass } from '#/fonts/css.js';
|
|
22
|
+
import { generateFallbackCss, buildFontStack } from '#/fonts/fallbacks.js';
|
|
23
|
+
import { processLocalFont } from '#/fonts/local.js';
|
|
24
|
+
import { inferFontFormat } from '#/fonts/local.js';
|
|
25
|
+
import { downloadAndCacheFonts, type CachedFont } from '#/fonts/google.js';
|
|
26
|
+
import {
|
|
27
|
+
extractFontConfigAst,
|
|
28
|
+
extractLocalFontConfigAst,
|
|
29
|
+
detectDynamicFontCallAst,
|
|
30
|
+
} from '#/fonts/ast.js';
|
|
31
|
+
|
|
32
|
+
const VIRTUAL_GOOGLE = '@timber/fonts/google';
|
|
33
|
+
const VIRTUAL_LOCAL = '@timber/fonts/local';
|
|
34
|
+
const RESOLVED_GOOGLE = '\0@timber/fonts/google';
|
|
35
|
+
const RESOLVED_LOCAL = '\0@timber/fonts/local';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Registry of fonts extracted during transform.
|
|
39
|
+
* Keyed by a unique font ID derived from family + config.
|
|
40
|
+
*/
|
|
41
|
+
export type FontRegistry = Map<string, ExtractedFont>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Convert a font family name to a PascalCase export name.
|
|
45
|
+
* e.g. "JetBrains Mono" → "JetBrains_Mono"
|
|
46
|
+
*/
|
|
47
|
+
function familyToExportName(family: string): string {
|
|
48
|
+
return family.replace(/\s+/g, '_');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert a font family name to a scoped class name.
|
|
53
|
+
* e.g. "JetBrains Mono" → "timber-font-jetbrains-mono"
|
|
54
|
+
*/
|
|
55
|
+
function familyToClassName(family: string): string {
|
|
56
|
+
return `timber-font-${family.toLowerCase().replace(/\s+/g, '-')}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate a unique font ID from family + config hash.
|
|
61
|
+
*/
|
|
62
|
+
function generateFontId(family: string, config: GoogleFontConfig): string {
|
|
63
|
+
const weights = normalizeToArray(config.weight);
|
|
64
|
+
const styles = normalizeToArray(config.style);
|
|
65
|
+
const subsets = config.subsets ?? ['latin'];
|
|
66
|
+
return `${family.toLowerCase()}-${weights.join(',')}-${styles.join(',')}-${subsets.join(',')}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Normalize a string or string array to an array.
|
|
71
|
+
*/
|
|
72
|
+
function normalizeToArray(value: string | string[] | undefined): string[] {
|
|
73
|
+
if (!value) return ['400'];
|
|
74
|
+
return Array.isArray(value) ? value : [value];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize style to an array.
|
|
79
|
+
*/
|
|
80
|
+
function normalizeStyleArray(value: string | string[] | undefined): string[] {
|
|
81
|
+
if (!value) return ['normal'];
|
|
82
|
+
return Array.isArray(value) ? value : [value];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Extract static font config from a font function call in source code.
|
|
87
|
+
*
|
|
88
|
+
* Parses patterns like:
|
|
89
|
+
* const inter = Inter({ subsets: ['latin'], weight: '400', display: 'swap', variable: '--font-sans' })
|
|
90
|
+
*
|
|
91
|
+
* Returns null if the call cannot be statically analyzed.
|
|
92
|
+
*
|
|
93
|
+
* Uses acorn AST parsing for robust handling of comments, trailing commas,
|
|
94
|
+
* and multi-line configs.
|
|
95
|
+
*/
|
|
96
|
+
export function extractFontConfig(callSource: string): GoogleFontConfig | null {
|
|
97
|
+
return extractFontConfigAst(callSource);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detect if a source file contains dynamic/computed font function calls
|
|
102
|
+
* that cannot be statically analyzed.
|
|
103
|
+
*
|
|
104
|
+
* Returns the offending expression if found, null if all calls are static.
|
|
105
|
+
*
|
|
106
|
+
* Uses acorn AST parsing for accurate detection.
|
|
107
|
+
*/
|
|
108
|
+
export function detectDynamicFontCall(source: string, importedNames: string[]): string | null {
|
|
109
|
+
return detectDynamicFontCallAst(source, importedNames);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Regex that matches imports from either `@timber/fonts/google` or `next/font/google`.
|
|
114
|
+
* The shims plugin resolves `next/font/google` to the same virtual module,
|
|
115
|
+
* but the source code still contains the original import specifier.
|
|
116
|
+
*/
|
|
117
|
+
const GOOGLE_FONT_IMPORT_RE =
|
|
118
|
+
/import\s*\{([^}]+)\}\s*from\s*['"](?:@timber\/fonts\/google|next\/font\/google)['"]/g;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Parse import specifiers from a source file that imports from
|
|
122
|
+
* `@timber/fonts/google` or `next/font/google`.
|
|
123
|
+
*
|
|
124
|
+
* Returns the list of imported font names (e.g. ['Inter', 'JetBrains_Mono']).
|
|
125
|
+
*/
|
|
126
|
+
export function parseGoogleFontImports(source: string): string[] {
|
|
127
|
+
const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
|
|
128
|
+
const names: string[] = [];
|
|
129
|
+
|
|
130
|
+
let match;
|
|
131
|
+
while ((match = importPattern.exec(source)) !== null) {
|
|
132
|
+
const specifiers = match[1]
|
|
133
|
+
.split(',')
|
|
134
|
+
.map((s) => s.trim())
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
for (const spec of specifiers) {
|
|
137
|
+
// Handle `Inter as MyInter` — we want the local name
|
|
138
|
+
const parts = spec.split(/\s+as\s+/);
|
|
139
|
+
names.push(parts[parts.length - 1].trim());
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return names;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse the original (remote) font family names from imports.
|
|
148
|
+
*
|
|
149
|
+
* Returns a map of local name → family name.
|
|
150
|
+
* e.g. { Inter: 'Inter', JetBrains_Mono: 'JetBrains Mono' }
|
|
151
|
+
*/
|
|
152
|
+
export function parseGoogleFontFamilies(source: string): Map<string, string> {
|
|
153
|
+
const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
|
|
154
|
+
const families = new Map<string, 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
|
+
const parts = spec.split(/\s+as\s+/);
|
|
164
|
+
const originalName = parts[0].trim();
|
|
165
|
+
const localName = parts[parts.length - 1].trim();
|
|
166
|
+
// Convert export name back to family name: JetBrains_Mono → JetBrains Mono
|
|
167
|
+
const family = originalName.replace(/_/g, ' ');
|
|
168
|
+
families.set(localName, family);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return families;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Generate the virtual module source for `@timber/fonts/google`.
|
|
177
|
+
*
|
|
178
|
+
* Each Google Font family gets a named export that returns a FontResult.
|
|
179
|
+
* In this base implementation, the functions return static data.
|
|
180
|
+
* The Google Fonts download task (timber-nk5) will add real font file URLs.
|
|
181
|
+
*/
|
|
182
|
+
function generateGoogleVirtualModule(registry: FontRegistry): string {
|
|
183
|
+
// Collect unique families from the registry
|
|
184
|
+
const families = new Set<string>();
|
|
185
|
+
for (const font of registry.values()) {
|
|
186
|
+
if (font.provider === 'google') families.add(font.family);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const lines = [
|
|
190
|
+
'// Auto-generated virtual module: @timber/fonts/google',
|
|
191
|
+
'// Each export is a font loader function that returns a FontResult.',
|
|
192
|
+
'',
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// If no fonts registered yet, export a generic loader for any font
|
|
196
|
+
// This is the initial load — the transform hook will process actual calls
|
|
197
|
+
lines.push('function createFontResult(family, config) {');
|
|
198
|
+
lines.push(' return {');
|
|
199
|
+
lines.push(' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,');
|
|
200
|
+
lines.push(' style: { fontFamily: family },');
|
|
201
|
+
lines.push(' variable: config?.variable,');
|
|
202
|
+
lines.push(' };');
|
|
203
|
+
lines.push('}');
|
|
204
|
+
lines.push('');
|
|
205
|
+
|
|
206
|
+
// Export a Proxy-based default that handles any font name import
|
|
207
|
+
lines.push('export default new Proxy({}, {');
|
|
208
|
+
lines.push(' get(_, prop) {');
|
|
209
|
+
lines.push(' if (typeof prop === "string") {');
|
|
210
|
+
lines.push(' return (config) => createFontResult(prop.replace(/_/g, " "), config);');
|
|
211
|
+
lines.push(' }');
|
|
212
|
+
lines.push(' }');
|
|
213
|
+
lines.push('});');
|
|
214
|
+
|
|
215
|
+
// Also export known families as named exports for tree-shaking
|
|
216
|
+
for (const family of families) {
|
|
217
|
+
const exportName = familyToExportName(family);
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push(`export function ${exportName}(config) {`);
|
|
220
|
+
lines.push(` return createFontResult('${family}', config);`);
|
|
221
|
+
lines.push('}');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return lines.join('\n');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Generate the virtual module source for `@timber/fonts/local`.
|
|
229
|
+
*/
|
|
230
|
+
function generateLocalVirtualModule(): string {
|
|
231
|
+
return [
|
|
232
|
+
'// Auto-generated virtual module: @timber/fonts/local',
|
|
233
|
+
'',
|
|
234
|
+
'export default function localFont(config) {',
|
|
235
|
+
' const family = config?.family || "Local Font";',
|
|
236
|
+
' return {',
|
|
237
|
+
' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
|
|
238
|
+
' style: { fontFamily: family },',
|
|
239
|
+
' variable: config?.variable,',
|
|
240
|
+
' };',
|
|
241
|
+
'}',
|
|
242
|
+
].join('\n');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generate the CSS output for all extracted fonts.
|
|
247
|
+
*
|
|
248
|
+
* Includes @font-face rules, fallback @font-face rules, and scoped classes.
|
|
249
|
+
*/
|
|
250
|
+
export function generateAllFontCss(registry: FontRegistry): string {
|
|
251
|
+
const cssParts: string[] = [];
|
|
252
|
+
|
|
253
|
+
for (const font of registry.values()) {
|
|
254
|
+
// Generate fallback @font-face if metrics are available
|
|
255
|
+
const fallbackCss = generateFallbackCss(font.family);
|
|
256
|
+
if (fallbackCss) cssParts.push(fallbackCss);
|
|
257
|
+
|
|
258
|
+
// Generate scoped class
|
|
259
|
+
if (font.variable) {
|
|
260
|
+
cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
|
|
261
|
+
} else {
|
|
262
|
+
cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return cssParts.join('\n\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Parse the local name used for the default import of `@timber/fonts/local`.
|
|
271
|
+
*
|
|
272
|
+
* Handles:
|
|
273
|
+
* import localFont from '@timber/fonts/local'
|
|
274
|
+
* import myLoader from '@timber/fonts/local'
|
|
275
|
+
*/
|
|
276
|
+
export function parseLocalFontImportName(source: string): string | null {
|
|
277
|
+
const match = source.match(
|
|
278
|
+
/import\s+(\w+)\s+from\s*['"](?:@timber\/fonts\/local|next\/font\/local)['"]/
|
|
279
|
+
);
|
|
280
|
+
return match ? match[1] : null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Transform local font calls in source code.
|
|
285
|
+
*
|
|
286
|
+
* Finds `localFont({ ... })` calls, extracts the config,
|
|
287
|
+
* registers the font, and replaces the call with a static FontResult.
|
|
288
|
+
*/
|
|
289
|
+
function transformLocalFonts(
|
|
290
|
+
transformedCode: string,
|
|
291
|
+
originalCode: string,
|
|
292
|
+
importerId: string,
|
|
293
|
+
registry: FontRegistry,
|
|
294
|
+
emitError: (msg: string) => void
|
|
295
|
+
): string {
|
|
296
|
+
const localName = parseLocalFontImportName(originalCode);
|
|
297
|
+
if (!localName) return transformedCode;
|
|
298
|
+
|
|
299
|
+
// Check for dynamic calls
|
|
300
|
+
const dynamicCall = detectDynamicFontCall(originalCode, [localName]);
|
|
301
|
+
if (dynamicCall) {
|
|
302
|
+
emitError(
|
|
303
|
+
`Font function calls must be statically analyzable. ` +
|
|
304
|
+
`Found dynamic call: ${dynamicCall}. ` +
|
|
305
|
+
`Pass a literal object with string/array values instead.`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Find all calls: const varName = localFont({ ... })
|
|
310
|
+
const callPattern = new RegExp(
|
|
311
|
+
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
312
|
+
'g'
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
let callMatch;
|
|
316
|
+
while ((callMatch = callPattern.exec(originalCode)) !== null) {
|
|
317
|
+
const varName = callMatch[1];
|
|
318
|
+
const configSource = callMatch[2];
|
|
319
|
+
const fullMatch = callMatch[0];
|
|
320
|
+
|
|
321
|
+
const config = extractLocalFontConfigAst(`(${configSource})`);
|
|
322
|
+
if (!config) {
|
|
323
|
+
emitError(
|
|
324
|
+
`Could not statically analyze local font config. ` +
|
|
325
|
+
`Ensure src is a string or array of { path, weight?, style? } objects.`
|
|
326
|
+
);
|
|
327
|
+
return transformedCode;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const extracted = processLocalFont(config, importerId);
|
|
331
|
+
registry.set(extracted.id, extracted);
|
|
332
|
+
|
|
333
|
+
const resultObj = extracted.variable
|
|
334
|
+
? `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" }, variable: "${extracted.variable}" }`
|
|
335
|
+
: `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" } }`;
|
|
336
|
+
|
|
337
|
+
const replacement = `const ${varName} = ${resultObj}`;
|
|
338
|
+
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Remove the import statement
|
|
342
|
+
transformedCode = transformedCode.replace(
|
|
343
|
+
/import\s+\w+\s+from\s*['"](?:@timber\/fonts\/local|next\/font\/local)['"];?\s*\n?/g,
|
|
344
|
+
''
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
return transformedCode;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Create the timber-fonts Vite plugin.
|
|
352
|
+
*/
|
|
353
|
+
export function timberFonts(ctx: PluginContext): Plugin {
|
|
354
|
+
const registry: FontRegistry = new Map();
|
|
355
|
+
/** Fonts downloaded during buildStart (production only). */
|
|
356
|
+
let cachedFonts: CachedFont[] = [];
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
name: 'timber-fonts',
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Resolve `@timber/fonts/google` and `@timber/fonts/local` to virtual modules.
|
|
363
|
+
*/
|
|
364
|
+
resolveId(id: string) {
|
|
365
|
+
if (id === VIRTUAL_GOOGLE) return RESOLVED_GOOGLE;
|
|
366
|
+
if (id === VIRTUAL_LOCAL) return RESOLVED_LOCAL;
|
|
367
|
+
return null;
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Return generated source for font virtual modules.
|
|
372
|
+
*/
|
|
373
|
+
load(id: string) {
|
|
374
|
+
if (id === RESOLVED_GOOGLE) return generateGoogleVirtualModule(registry);
|
|
375
|
+
if (id === RESOLVED_LOCAL) return generateLocalVirtualModule();
|
|
376
|
+
return null;
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Download and cache Google Fonts during production builds.
|
|
381
|
+
*
|
|
382
|
+
* In dev mode this is a no-op — fonts point to the Google CDN.
|
|
383
|
+
* The registry is populated by the transform hook which runs before
|
|
384
|
+
* buildStart in the build pipeline, so all fonts are known here.
|
|
385
|
+
*/
|
|
386
|
+
async buildStart() {
|
|
387
|
+
if (ctx.dev) return;
|
|
388
|
+
|
|
389
|
+
const googleFonts = [...registry.values()].filter((f) => f.provider === 'google');
|
|
390
|
+
if (googleFonts.length === 0) return;
|
|
391
|
+
|
|
392
|
+
cachedFonts = await downloadAndCacheFonts(googleFonts, ctx.root);
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Scan source files for font function calls and extract static config.
|
|
397
|
+
*
|
|
398
|
+
* When a file imports from `@timber/fonts/google`, we:
|
|
399
|
+
* 1. Parse the import specifiers to get font family names
|
|
400
|
+
* 2. Find each font function call and extract its config
|
|
401
|
+
* 3. Validate that all calls are statically analyzable
|
|
402
|
+
* 4. Register extracted fonts in the registry
|
|
403
|
+
* 5. Replace the function call with a static FontResult object
|
|
404
|
+
*/
|
|
405
|
+
transform(code: string, id: string) {
|
|
406
|
+
// Skip virtual modules and node_modules
|
|
407
|
+
if (id.startsWith('\0') || id.includes('node_modules')) return null;
|
|
408
|
+
|
|
409
|
+
const hasGoogleImport =
|
|
410
|
+
code.includes('@timber/fonts/google') || code.includes('next/font/google');
|
|
411
|
+
const hasLocalImport =
|
|
412
|
+
code.includes('@timber/fonts/local') || code.includes('next/font/local');
|
|
413
|
+
if (!hasGoogleImport && !hasLocalImport) return null;
|
|
414
|
+
|
|
415
|
+
let transformedCode = code;
|
|
416
|
+
|
|
417
|
+
// ── Google font transform ──────────────────────────────────────────
|
|
418
|
+
if (hasGoogleImport) {
|
|
419
|
+
const families = parseGoogleFontFamilies(code);
|
|
420
|
+
if (families.size > 0) {
|
|
421
|
+
const importedNames = [...families.keys()];
|
|
422
|
+
|
|
423
|
+
const dynamicCall = detectDynamicFontCall(code, importedNames);
|
|
424
|
+
if (dynamicCall) {
|
|
425
|
+
this.error(
|
|
426
|
+
`Font function calls must be statically analyzable. ` +
|
|
427
|
+
`Found dynamic call: ${dynamicCall}. ` +
|
|
428
|
+
`Pass a literal object with string/array values instead.`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
for (const [localName, family] of families) {
|
|
433
|
+
const callPattern = new RegExp(
|
|
434
|
+
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
435
|
+
'g'
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
let callMatch;
|
|
439
|
+
while ((callMatch = callPattern.exec(code)) !== null) {
|
|
440
|
+
const varName = callMatch[1];
|
|
441
|
+
const configSource = callMatch[2];
|
|
442
|
+
const fullMatch = callMatch[0];
|
|
443
|
+
|
|
444
|
+
const config = extractFontConfig(`(${configSource})`);
|
|
445
|
+
if (!config) {
|
|
446
|
+
this.error(
|
|
447
|
+
`Could not statically analyze font config for ${family}. ` +
|
|
448
|
+
`Ensure all config values are string literals or arrays of string literals.`
|
|
449
|
+
);
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const fontId = generateFontId(family, config);
|
|
454
|
+
const className = familyToClassName(family);
|
|
455
|
+
const fontStack = buildFontStack(family);
|
|
456
|
+
const display = config.display ?? 'swap';
|
|
457
|
+
|
|
458
|
+
const extracted: ExtractedFont = {
|
|
459
|
+
id: fontId,
|
|
460
|
+
family,
|
|
461
|
+
provider: 'google',
|
|
462
|
+
weights: normalizeToArray(config.weight),
|
|
463
|
+
styles: normalizeStyleArray(config.style),
|
|
464
|
+
subsets: config.subsets ?? ['latin'],
|
|
465
|
+
display,
|
|
466
|
+
variable: config.variable,
|
|
467
|
+
className,
|
|
468
|
+
fontFamily: fontStack,
|
|
469
|
+
importer: id,
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
registry.set(fontId, extracted);
|
|
473
|
+
|
|
474
|
+
const resultObj = config.variable
|
|
475
|
+
? `{ className: "${className}", style: { fontFamily: "${fontStack}" }, variable: "${config.variable}" }`
|
|
476
|
+
: `{ className: "${className}", style: { fontFamily: "${fontStack}" } }`;
|
|
477
|
+
|
|
478
|
+
const replacement = `const ${varName} = ${resultObj}`;
|
|
479
|
+
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
transformedCode = transformedCode.replace(
|
|
484
|
+
/import\s*\{[^}]+\}\s*from\s*['"](?:@timber\/fonts\/google|next\/font\/google)['"];?\s*\n?/g,
|
|
485
|
+
''
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ── Local font transform ───────────────────────────────────────────
|
|
491
|
+
if (hasLocalImport) {
|
|
492
|
+
transformedCode = transformLocalFonts(
|
|
493
|
+
transformedCode,
|
|
494
|
+
code,
|
|
495
|
+
id,
|
|
496
|
+
registry,
|
|
497
|
+
this.error.bind(this)
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (transformedCode !== code) {
|
|
502
|
+
return { code: transformedCode, map: null };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return null;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Emit font files and metadata into the build output.
|
|
510
|
+
*
|
|
511
|
+
* For Google fonts: emits the downloaded, content-hashed woff2 files
|
|
512
|
+
* and writes ManifestFontEntry arrays using real hashed URLs.
|
|
513
|
+
*
|
|
514
|
+
* For local fonts: emits entries using the source file paths.
|
|
515
|
+
*
|
|
516
|
+
* In dev mode the build manifest is null, so this is a no-op.
|
|
517
|
+
*/
|
|
518
|
+
generateBundle() {
|
|
519
|
+
// Emit cached Google Font files into the build output
|
|
520
|
+
for (const cf of cachedFonts) {
|
|
521
|
+
this.emitFile({
|
|
522
|
+
type: 'asset',
|
|
523
|
+
fileName: `_timber/fonts/${cf.hashedFilename}`,
|
|
524
|
+
source: cf.data,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!ctx.buildManifest) return;
|
|
529
|
+
|
|
530
|
+
// Build a lookup from font family → cached files for manifest entries
|
|
531
|
+
const cachedByFamily = new Map<string, CachedFont[]>();
|
|
532
|
+
for (const cf of cachedFonts) {
|
|
533
|
+
const key = cf.face.family.toLowerCase();
|
|
534
|
+
const arr = cachedByFamily.get(key) ?? [];
|
|
535
|
+
arr.push(cf);
|
|
536
|
+
cachedByFamily.set(key, arr);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const fontsByImporter = new Map<string, ManifestFontEntry[]>();
|
|
540
|
+
|
|
541
|
+
for (const font of registry.values()) {
|
|
542
|
+
const entries = fontsByImporter.get(font.importer) ?? [];
|
|
543
|
+
|
|
544
|
+
if (font.provider === 'local' && font.localSources) {
|
|
545
|
+
// Local fonts: one entry per source file
|
|
546
|
+
for (const src of font.localSources) {
|
|
547
|
+
const filename = src.path.split('/').pop() ?? src.path;
|
|
548
|
+
const format = inferFontFormat(src.path);
|
|
549
|
+
entries.push({
|
|
550
|
+
href: `/_timber/fonts/${filename}`,
|
|
551
|
+
format,
|
|
552
|
+
crossOrigin: 'anonymous',
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
// Google fonts: use real content-hashed URLs from cached downloads
|
|
557
|
+
const familyKey = font.family.toLowerCase();
|
|
558
|
+
const familyCached = cachedByFamily.get(familyKey) ?? [];
|
|
559
|
+
for (const cf of familyCached) {
|
|
560
|
+
entries.push({
|
|
561
|
+
href: `/_timber/fonts/${cf.hashedFilename}`,
|
|
562
|
+
format: 'woff2',
|
|
563
|
+
crossOrigin: 'anonymous',
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
fontsByImporter.set(font.importer, entries);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Normalize importer paths to be relative to project root (matching
|
|
572
|
+
// how Vite's manifest.json keys work for css/js).
|
|
573
|
+
for (const [importer, entries] of fontsByImporter) {
|
|
574
|
+
const relativePath = importer.startsWith(ctx.root)
|
|
575
|
+
? importer.slice(ctx.root.length + 1)
|
|
576
|
+
: importer;
|
|
577
|
+
ctx.buildManifest.fonts[relativePath] = entries;
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|