@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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FontPipeline — owns all per-font state for the timber-fonts plugin.
|
|
3
|
+
*
|
|
4
|
+
* Before TIM-829, font state was spread across three parallel mutable maps:
|
|
5
|
+
*
|
|
6
|
+
* - `registry: Map<string, ExtractedFont>`
|
|
7
|
+
* - `googleFontFacesMap: Map<string, FontFaceDescriptor[]>`
|
|
8
|
+
* - `cachedFonts: CachedFont[]`
|
|
9
|
+
*
|
|
10
|
+
* Every plugin hook had to remember which map(s) it owned and which it
|
|
11
|
+
* read. The TIM-824 bug existed because `transform` could write to
|
|
12
|
+
* `registry` multiple times for the same `(importer, family)` pair without
|
|
13
|
+
* a corresponding cleanup of the entries that `buildStart` / `load` would
|
|
14
|
+
* later use. The fix in TIM-824 added prune calls in `transform` for the
|
|
15
|
+
* registry, but the parallel maps still had the same hazard latent in
|
|
16
|
+
* them: any future code path that ran `buildStart`-equivalent logic on
|
|
17
|
+
* HMR could re-introduce a stale-entry leak in a different map.
|
|
18
|
+
*
|
|
19
|
+
* TIM-829 collapses the three maps into a single `Map<string, FontEntry>`
|
|
20
|
+
* keyed by font ID. Mutation goes through a small set of methods that
|
|
21
|
+
* maintain the core invariant: **every piece of state belonging to a font
|
|
22
|
+
* lives on its `FontEntry`, and `pruneFor` removes the entry along with
|
|
23
|
+
* everything attached to it in one call.** No code path inside the plugin
|
|
24
|
+
* touches the underlying map directly.
|
|
25
|
+
*
|
|
26
|
+
* Design doc: 24-fonts.md
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ExtractedFont, FontEntry, FontFaceDescriptor } from './types.js';
|
|
30
|
+
import type { CachedFont } from './google.js';
|
|
31
|
+
import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
|
|
32
|
+
import { generateFallbackCss } from './fallbacks.js';
|
|
33
|
+
import { generateLocalFontFaces } from './local.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Backwards-compatible alias for the registry shape that the standalone
|
|
37
|
+
* `pruneRegistryEntries` and `generateAllFontCss` helpers operate on.
|
|
38
|
+
*
|
|
39
|
+
* The `FontPipeline` itself stores `Map<string, FontEntry>` internally and
|
|
40
|
+
* never exposes its underlying map. Tests and external callers that build
|
|
41
|
+
* their own font registries (e.g. fixtures in `tests/fonts-hmr-prune.test.ts`)
|
|
42
|
+
* still pass plain `Map<string, ExtractedFont>` instances to the standalone
|
|
43
|
+
* helpers, which is why the alias survives.
|
|
44
|
+
*/
|
|
45
|
+
export type FontRegistry = Map<string, ExtractedFont>;
|
|
46
|
+
|
|
47
|
+
export class FontPipeline {
|
|
48
|
+
/**
|
|
49
|
+
* The single per-font store. Keyed by `FontEntry.id` (which is itself
|
|
50
|
+
* derived from `family + weights + styles + subsets + display`, so any
|
|
51
|
+
* config change produces a new key).
|
|
52
|
+
*
|
|
53
|
+
* Marked `readonly` so the field reference can never be reassigned, but
|
|
54
|
+
* the map contents are mutable through the methods below.
|
|
55
|
+
*/
|
|
56
|
+
private readonly _entries = new Map<string, FontEntry>();
|
|
57
|
+
|
|
58
|
+
// ── Read-only accessors ─────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Number of registered fonts. */
|
|
61
|
+
size(): number {
|
|
62
|
+
return this._entries.size;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Iterate every entry as a `FontEntry` (with `faces` / `cachedFiles`). */
|
|
66
|
+
entries(): IterableIterator<FontEntry> {
|
|
67
|
+
return this._entries.values();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Iterate every entry typed as the narrower `ExtractedFont` shape. */
|
|
71
|
+
fonts(): IterableIterator<ExtractedFont> {
|
|
72
|
+
return this._entries.values();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Iterate every Google-provider entry. */
|
|
76
|
+
*googleFonts(): IterableIterator<FontEntry> {
|
|
77
|
+
for (const entry of this._entries.values()) {
|
|
78
|
+
if (entry.provider === 'google') yield entry;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Lookup an entry by font ID. */
|
|
83
|
+
getEntry(id: string): FontEntry | undefined {
|
|
84
|
+
return this._entries.get(id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True if `attachFaces()` has been called for `fontId` — even with an
|
|
89
|
+
* empty array. This intentionally treats an empty resolution as
|
|
90
|
+
* "already processed" so the dev `load()` path doesn't keep retrying
|
|
91
|
+
* `resolveDevFontFaces()` on every request when Google returns no
|
|
92
|
+
* matching faces for the requested subset(s). Mirrors the previous
|
|
93
|
+
* `Map.has()` semantics this method replaced. (Codex review on PR #596.)
|
|
94
|
+
*
|
|
95
|
+
* Failures inside `resolveDevFontFaces` do not call `attachFaces` at
|
|
96
|
+
* all, so they correctly stay retryable on the next request — see the
|
|
97
|
+
* TIM-636 comment in `plugins/fonts.ts`.
|
|
98
|
+
*/
|
|
99
|
+
hasFaces(fontId: string): boolean {
|
|
100
|
+
const entry = this._entries.get(fontId);
|
|
101
|
+
return entry?.faces !== undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* True if `attachCachedFiles()` has been called for `fontId` — even
|
|
106
|
+
* with an empty array. Same presence-vs-content rationale as
|
|
107
|
+
* `hasFaces`: an empty resolution is a deterministic outcome, not a
|
|
108
|
+
* "not yet processed" signal.
|
|
109
|
+
*/
|
|
110
|
+
hasCachedFiles(fontId: string): boolean {
|
|
111
|
+
const entry = this._entries.get(fontId);
|
|
112
|
+
return entry?.cachedFiles !== undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Mutators ────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Register an extracted font under its content-derived ID.
|
|
119
|
+
*
|
|
120
|
+
* If an entry with the same ID already exists it is replaced wholesale,
|
|
121
|
+
* dropping any previously-attached `faces` and `cachedFiles`. Callers
|
|
122
|
+
* must call `pruneFor(importer, family)` first to ensure that any
|
|
123
|
+
* previous registration from the same `(importer, family)` pair has been
|
|
124
|
+
* dropped (TIM-824).
|
|
125
|
+
*/
|
|
126
|
+
register(extracted: ExtractedFont): void {
|
|
127
|
+
// Wholesale replace — never merge faces/cachedFiles from a previous
|
|
128
|
+
// registration. Re-registration with the same ID always represents the
|
|
129
|
+
// newest authoritative config and must start from a clean slate.
|
|
130
|
+
this._entries.set(extracted.id, { ...extracted });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Attach pre-resolved `@font-face` descriptors to a font entry. */
|
|
134
|
+
attachFaces(fontId: string, faces: FontFaceDescriptor[]): void {
|
|
135
|
+
const entry = this._entries.get(fontId);
|
|
136
|
+
if (!entry) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`[timber-fonts] attachFaces: no font entry registered for id "${fontId}". ` +
|
|
139
|
+
`Call register() first.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
entry.faces = faces;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Attach downloaded Google Font binaries to a font entry. */
|
|
146
|
+
attachCachedFiles(fontId: string, files: CachedFont[]): void {
|
|
147
|
+
const entry = this._entries.get(fontId);
|
|
148
|
+
if (!entry) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`[timber-fonts] attachCachedFiles: no font entry registered for id "${fontId}". ` +
|
|
151
|
+
`Call register() first.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
entry.cachedFiles = files;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Drop every registry entry that originated from `importer` and shares
|
|
159
|
+
* `family` (case-insensitive), **along with all of its attached state**.
|
|
160
|
+
*
|
|
161
|
+
* This is the single chokepoint that guarantees stale `@font-face`
|
|
162
|
+
* descriptors and orphaned cached binaries cannot leak across HMR edits.
|
|
163
|
+
* Because every piece of per-font state lives on the `FontEntry`,
|
|
164
|
+
* deleting the entry from the underlying map drops everything in one
|
|
165
|
+
* operation — no parallel maps to keep in sync.
|
|
166
|
+
*
|
|
167
|
+
* See TIM-824 (the original prune fix) and TIM-829 (single-store
|
|
168
|
+
* refactor that made this guarantee structural).
|
|
169
|
+
*/
|
|
170
|
+
pruneFor(importer: string, family: string): void {
|
|
171
|
+
const familyKey = family.toLowerCase();
|
|
172
|
+
for (const [id, entry] of this._entries) {
|
|
173
|
+
if (entry.importer === importer && entry.family.toLowerCase() === familyKey) {
|
|
174
|
+
this._entries.delete(id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Drop every registered font. Used by tests and rebuild flows. */
|
|
180
|
+
clear(): void {
|
|
181
|
+
this._entries.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Derived views ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Iterate every cached Google Font binary across all registered entries,
|
|
188
|
+
* deduplicated by `hashedFilename`. Multiple entries that share the same
|
|
189
|
+
* family hold references to the same `CachedFont` objects, so this
|
|
190
|
+
* iterator yields each unique binary exactly once.
|
|
191
|
+
*/
|
|
192
|
+
*uniqueCachedFiles(): IterableIterator<CachedFont> {
|
|
193
|
+
const seen = new Set<string>();
|
|
194
|
+
for (const entry of this._entries.values()) {
|
|
195
|
+
if (!entry.cachedFiles) continue;
|
|
196
|
+
for (const cf of entry.cachedFiles) {
|
|
197
|
+
if (seen.has(cf.hashedFilename)) continue;
|
|
198
|
+
seen.add(cf.hashedFilename);
|
|
199
|
+
yield cf;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate the combined CSS output for every registered font.
|
|
206
|
+
*
|
|
207
|
+
* Includes `@font-face` rules for local and Google fonts, fallback
|
|
208
|
+
* `@font-face` rules, and scoped class rules. Google fonts use the
|
|
209
|
+
* `faces` attached via `attachFaces()` (in `buildStart` for production
|
|
210
|
+
* or lazily in `load` for dev).
|
|
211
|
+
*/
|
|
212
|
+
getCss(): string {
|
|
213
|
+
const cssParts: string[] = [];
|
|
214
|
+
for (const entry of this._entries.values()) {
|
|
215
|
+
cssParts.push(renderEntryCss(entry));
|
|
216
|
+
}
|
|
217
|
+
return cssParts.join('\n\n');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Render the combined CSS for a single font entry.
|
|
223
|
+
*
|
|
224
|
+
* Mirrors the standalone `generateFontCss` helper exported from
|
|
225
|
+
* `virtual-modules.ts`, but reads `faces` straight off the entry instead
|
|
226
|
+
* of from a parallel map. Kept private to this module so the only public
|
|
227
|
+
* way to render a `FontEntry`'s CSS is through `FontPipeline.getCss()`.
|
|
228
|
+
*/
|
|
229
|
+
function renderEntryCss(entry: FontEntry): string {
|
|
230
|
+
const cssParts: string[] = [];
|
|
231
|
+
|
|
232
|
+
if (entry.provider === 'local' && entry.localSources) {
|
|
233
|
+
const faces = generateLocalFontFaces(entry.family, entry.localSources, entry.display);
|
|
234
|
+
const faceCss = generateFontFaces(faces);
|
|
235
|
+
if (faceCss) cssParts.push(faceCss);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (entry.provider === 'google' && entry.faces && entry.faces.length > 0) {
|
|
239
|
+
const faceCss = generateFontFaces(entry.faces);
|
|
240
|
+
if (faceCss) cssParts.push(faceCss);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const fallbackCss = generateFallbackCss(entry.family);
|
|
244
|
+
if (fallbackCss) cssParts.push(fallbackCss);
|
|
245
|
+
|
|
246
|
+
if (entry.variable) {
|
|
247
|
+
cssParts.push(generateVariableClass(entry.className, entry.variable, entry.fontFamily));
|
|
248
|
+
} else {
|
|
249
|
+
cssParts.push(generateFontFamilyClass(entry.className, entry.fontFamily));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return cssParts.join('\n\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Drop every registry entry that originated from `importer` and shares
|
|
257
|
+
* `family`. Standalone helper retained as a public export for the test
|
|
258
|
+
* suite (`tests/fonts-hmr-prune.test.ts`) and any external callers that
|
|
259
|
+
* still build their own `FontRegistry` maps.
|
|
260
|
+
*
|
|
261
|
+
* Prefer `FontPipeline#pruneFor` inside the plugin itself — it also drops
|
|
262
|
+
* attached `faces` and `cachedFiles` in the same operation.
|
|
263
|
+
*/
|
|
264
|
+
export function pruneRegistryEntries(
|
|
265
|
+
registry: FontRegistry,
|
|
266
|
+
importer: string,
|
|
267
|
+
family: string
|
|
268
|
+
): void {
|
|
269
|
+
const familyKey = family.toLowerCase();
|
|
270
|
+
for (const [id, font] of registry) {
|
|
271
|
+
if (font.importer === importer && font.family.toLowerCase() === familyKey) {
|
|
272
|
+
registry.delete(id);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform-hook logic for the timber-fonts plugin.
|
|
3
|
+
*
|
|
4
|
+
* Scans source files for font function calls (`Inter({...})`,
|
|
5
|
+
* `localFont({...})`), validates that they are statically analysable,
|
|
6
|
+
* registers the extracted fonts in the `FontPipeline`, and rewrites the
|
|
7
|
+
* call sites to static `FontResult` literals so the runtime never has to
|
|
8
|
+
* evaluate the font function.
|
|
9
|
+
*
|
|
10
|
+
* Design doc: 24-fonts.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtractedFont, GoogleFontConfig } from './types.js';
|
|
14
|
+
import { buildFontStack } from './fallbacks.js';
|
|
15
|
+
import { processLocalFont } from './local.js';
|
|
16
|
+
import {
|
|
17
|
+
extractFontConfigAst,
|
|
18
|
+
extractLocalFontConfigAst,
|
|
19
|
+
detectDynamicFontCallAst,
|
|
20
|
+
} from './ast.js';
|
|
21
|
+
import type { FontPipeline } from './pipeline.js';
|
|
22
|
+
import { VIRTUAL_FONT_CSS_REGISTER } from './virtual-modules.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Regex that matches imports from either `@timber/fonts/google` or
|
|
26
|
+
* `next/font/google` (which the shims plugin resolves to the same virtual
|
|
27
|
+
* module). The transform hook still needs to recognise both spellings in
|
|
28
|
+
* source code.
|
|
29
|
+
*/
|
|
30
|
+
const GOOGLE_FONT_IMPORT_RE =
|
|
31
|
+
/import\s*\{([^}]+)\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"]/g;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert a font family name to a scoped class name.
|
|
35
|
+
* e.g. "JetBrains Mono" → "timber-font-jetbrains-mono"
|
|
36
|
+
*/
|
|
37
|
+
function familyToClassName(family: string): string {
|
|
38
|
+
return `timber-font-${family.toLowerCase().replace(/\s+/g, '-')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Normalize a string or string array to an array (default `['400']`). */
|
|
42
|
+
function normalizeWeightArray(value: string | string[] | undefined): string[] {
|
|
43
|
+
if (!value) return ['400'];
|
|
44
|
+
return Array.isArray(value) ? value : [value];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalize style to an array (default `['normal']`). */
|
|
48
|
+
function normalizeStyleArray(value: string | string[] | undefined): string[] {
|
|
49
|
+
if (!value) return ['normal'];
|
|
50
|
+
return Array.isArray(value) ? value : [value];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate a unique font ID from family + config hash.
|
|
55
|
+
*
|
|
56
|
+
* The ID intentionally includes every property that affects the rendered
|
|
57
|
+
* `@font-face` output (`weight`, `style`, `subsets`, `display`) so that an
|
|
58
|
+
* HMR edit changing any of those produces a new ID and the registry is
|
|
59
|
+
* forced to re-emit fresh CSS.
|
|
60
|
+
*/
|
|
61
|
+
export function generateFontId(family: string, config: GoogleFontConfig): string {
|
|
62
|
+
const weights = normalizeWeightArray(config.weight);
|
|
63
|
+
const styles = normalizeStyleArray(config.style);
|
|
64
|
+
const subsets = config.subsets ?? ['latin'];
|
|
65
|
+
const display = config.display ?? 'swap';
|
|
66
|
+
return `${family.toLowerCase()}-${weights.join(',')}-${styles.join(',')}-${subsets.join(',')}-${display}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract static font config from a font function call source.
|
|
71
|
+
*
|
|
72
|
+
* Returns `null` if the call cannot be statically analysed.
|
|
73
|
+
*/
|
|
74
|
+
export function extractFontConfig(callSource: string): GoogleFontConfig | null {
|
|
75
|
+
return extractFontConfigAst(callSource);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Detect dynamic/computed font function calls that cannot be statically
|
|
80
|
+
* analysed. Returns the offending expression text, or `null` if all calls
|
|
81
|
+
* are static.
|
|
82
|
+
*/
|
|
83
|
+
export function detectDynamicFontCall(source: string, importedNames: string[]): string | null {
|
|
84
|
+
return detectDynamicFontCallAst(source, importedNames);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse the local names imported from `@timber/fonts/google` /
|
|
89
|
+
* `next/font/google`. e.g. `import { Inter, JetBrains_Mono as Mono }` →
|
|
90
|
+
* `['Inter', 'Mono']`.
|
|
91
|
+
*/
|
|
92
|
+
export function parseGoogleFontImports(source: string): string[] {
|
|
93
|
+
const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
|
|
94
|
+
const names: string[] = [];
|
|
95
|
+
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = importPattern.exec(source)) !== null) {
|
|
98
|
+
const specifiers = match[1]
|
|
99
|
+
.split(',')
|
|
100
|
+
.map((s) => s.trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
for (const spec of specifiers) {
|
|
103
|
+
// Handle `Inter as MyInter` — we want the local name
|
|
104
|
+
const parts = spec.split(/\s+as\s+/);
|
|
105
|
+
names.push(parts[parts.length - 1].trim());
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return names;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Parse Google font imports into a `localName → familyName` map.
|
|
114
|
+
*
|
|
115
|
+
* e.g. `import { JetBrains_Mono as Mono }` → `{ Mono: 'JetBrains Mono' }`.
|
|
116
|
+
* The original (pre-`as`) name is converted from PascalCase-with-underscores
|
|
117
|
+
* to the human-readable family name by replacing `_` with a space.
|
|
118
|
+
*/
|
|
119
|
+
export function parseGoogleFontFamilies(source: string): Map<string, string> {
|
|
120
|
+
const importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
|
|
121
|
+
const families = new Map<string, string>();
|
|
122
|
+
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = importPattern.exec(source)) !== null) {
|
|
125
|
+
const specifiers = match[1]
|
|
126
|
+
.split(',')
|
|
127
|
+
.map((s) => s.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
for (const spec of specifiers) {
|
|
130
|
+
const parts = spec.split(/\s+as\s+/);
|
|
131
|
+
const originalName = parts[0].trim();
|
|
132
|
+
const localName = parts[parts.length - 1].trim();
|
|
133
|
+
// Convert export name back to family name: JetBrains_Mono → JetBrains Mono
|
|
134
|
+
const family = originalName.replace(/_/g, ' ');
|
|
135
|
+
families.set(localName, family);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return families;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse the local name used for the default import of `@timber/fonts/local`.
|
|
144
|
+
*
|
|
145
|
+
* Handles either spelling and arbitrary local names:
|
|
146
|
+
* import localFont from '@timber/fonts/local'
|
|
147
|
+
* import myLoader from 'next/font/local'
|
|
148
|
+
*/
|
|
149
|
+
export function parseLocalFontImportName(source: string): string | null {
|
|
150
|
+
const match = source.match(
|
|
151
|
+
/import\s+(\w+)\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"]/
|
|
152
|
+
);
|
|
153
|
+
return match ? match[1] : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Build the static `FontResult` literal we substitute for a font call. */
|
|
157
|
+
function buildFontResultLiteral(extracted: ExtractedFont): string {
|
|
158
|
+
return extracted.variable
|
|
159
|
+
? `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" }, variable: "${extracted.variable}" }`
|
|
160
|
+
: `{ className: "${extracted.className}", style: { fontFamily: "${extracted.fontFamily}" } }`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Type for the Vite plugin's `this.error` callback. */
|
|
164
|
+
type EmitError = (msg: string) => never;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Find Google font calls in `originalCode`, register them in the pipeline,
|
|
168
|
+
* and replace each call site in `transformedCode` with a static FontResult.
|
|
169
|
+
*/
|
|
170
|
+
function transformGoogleFonts(
|
|
171
|
+
transformedCode: string,
|
|
172
|
+
originalCode: string,
|
|
173
|
+
importerId: string,
|
|
174
|
+
pipeline: FontPipeline,
|
|
175
|
+
emitError: EmitError
|
|
176
|
+
): string {
|
|
177
|
+
const families = parseGoogleFontFamilies(originalCode);
|
|
178
|
+
if (families.size === 0) return transformedCode;
|
|
179
|
+
|
|
180
|
+
const importedNames = [...families.keys()];
|
|
181
|
+
|
|
182
|
+
const dynamicCall = detectDynamicFontCall(originalCode, importedNames);
|
|
183
|
+
if (dynamicCall) {
|
|
184
|
+
emitError(
|
|
185
|
+
`Font function calls must be statically analyzable. ` +
|
|
186
|
+
`Found dynamic call: ${dynamicCall}. ` +
|
|
187
|
+
`Pass a literal object with string/array values instead.`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const [localName, family] of families) {
|
|
192
|
+
const callPattern = new RegExp(
|
|
193
|
+
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
194
|
+
'g'
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
let callMatch;
|
|
198
|
+
while ((callMatch = callPattern.exec(originalCode)) !== null) {
|
|
199
|
+
const varName = callMatch[1];
|
|
200
|
+
const configSource = callMatch[2];
|
|
201
|
+
const fullMatch = callMatch[0];
|
|
202
|
+
|
|
203
|
+
const config = extractFontConfig(`(${configSource})`);
|
|
204
|
+
if (!config) {
|
|
205
|
+
emitError(
|
|
206
|
+
`Could not statically analyze font config for ${family}. ` +
|
|
207
|
+
`Ensure all config values are string literals or arrays of string literals.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const fontId = generateFontId(family, config!);
|
|
212
|
+
const className = familyToClassName(family);
|
|
213
|
+
const fontStack = buildFontStack(family);
|
|
214
|
+
const display = config!.display ?? 'swap';
|
|
215
|
+
|
|
216
|
+
const extracted: ExtractedFont = {
|
|
217
|
+
id: fontId,
|
|
218
|
+
family,
|
|
219
|
+
provider: 'google',
|
|
220
|
+
weights: normalizeWeightArray(config!.weight),
|
|
221
|
+
styles: normalizeStyleArray(config!.style),
|
|
222
|
+
subsets: config!.subsets ?? ['latin'],
|
|
223
|
+
display,
|
|
224
|
+
variable: config!.variable,
|
|
225
|
+
className,
|
|
226
|
+
fontFamily: fontStack,
|
|
227
|
+
importer: importerId,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
pipeline.pruneFor(importerId, family);
|
|
231
|
+
pipeline.register(extracted);
|
|
232
|
+
|
|
233
|
+
const replacement = `const ${varName} = ${buildFontResultLiteral(extracted)}`;
|
|
234
|
+
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Strip the import statement; the values it referred to are now inlined.
|
|
239
|
+
transformedCode = transformedCode.replace(
|
|
240
|
+
/import\s*\{[^}]+\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"];?\s*\n?/g,
|
|
241
|
+
''
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return transformedCode;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Find local font calls in `originalCode`, register them in the pipeline,
|
|
249
|
+
* and replace each call site in `transformedCode` with a static FontResult.
|
|
250
|
+
*/
|
|
251
|
+
function transformLocalFonts(
|
|
252
|
+
transformedCode: string,
|
|
253
|
+
originalCode: string,
|
|
254
|
+
importerId: string,
|
|
255
|
+
pipeline: FontPipeline,
|
|
256
|
+
emitError: EmitError
|
|
257
|
+
): string {
|
|
258
|
+
const localName = parseLocalFontImportName(originalCode);
|
|
259
|
+
if (!localName) return transformedCode;
|
|
260
|
+
|
|
261
|
+
const dynamicCall = detectDynamicFontCall(originalCode, [localName]);
|
|
262
|
+
if (dynamicCall) {
|
|
263
|
+
emitError(
|
|
264
|
+
`Font function calls must be statically analyzable. ` +
|
|
265
|
+
`Found dynamic call: ${dynamicCall}. ` +
|
|
266
|
+
`Pass a literal object with string/array values instead.`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const callPattern = new RegExp(
|
|
271
|
+
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
272
|
+
'g'
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
let callMatch;
|
|
276
|
+
while ((callMatch = callPattern.exec(originalCode)) !== null) {
|
|
277
|
+
const varName = callMatch[1];
|
|
278
|
+
const configSource = callMatch[2];
|
|
279
|
+
const fullMatch = callMatch[0];
|
|
280
|
+
|
|
281
|
+
const config = extractLocalFontConfigAst(`(${configSource})`);
|
|
282
|
+
if (!config) {
|
|
283
|
+
emitError(
|
|
284
|
+
`Could not statically analyze local font config. ` +
|
|
285
|
+
`Ensure src is a string or array of { path, weight?, style? } objects.`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const extracted = processLocalFont(config!, importerId);
|
|
290
|
+
pipeline.pruneFor(importerId, extracted.family);
|
|
291
|
+
pipeline.register(extracted);
|
|
292
|
+
|
|
293
|
+
const replacement = `const ${varName} = ${buildFontResultLiteral(extracted)}`;
|
|
294
|
+
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Strip the import statement; the values it referred to are now inlined.
|
|
298
|
+
transformedCode = transformedCode.replace(
|
|
299
|
+
/import\s+\w+\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"];?\s*\n?/g,
|
|
300
|
+
''
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
return transformedCode;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Run the timber-fonts transform pass on a single source file.
|
|
308
|
+
*
|
|
309
|
+
* Returns the rewritten code (with font calls inlined and the side-effect
|
|
310
|
+
* `virtual:timber-font-css-register` import prepended) or `null` if the
|
|
311
|
+
* file does not import from any timber-fonts virtual module and therefore
|
|
312
|
+
* needs no transformation.
|
|
313
|
+
*/
|
|
314
|
+
export function runFontsTransform(
|
|
315
|
+
code: string,
|
|
316
|
+
id: string,
|
|
317
|
+
pipeline: FontPipeline,
|
|
318
|
+
emitError: EmitError
|
|
319
|
+
): { code: string; map: null } | null {
|
|
320
|
+
// Skip virtual modules and node_modules
|
|
321
|
+
if (id.startsWith('\0') || id.includes('node_modules')) return null;
|
|
322
|
+
|
|
323
|
+
const hasGoogleImport =
|
|
324
|
+
code.includes('@timber/fonts/google') ||
|
|
325
|
+
code.includes('@timber-js/app/fonts/google') ||
|
|
326
|
+
code.includes('next/font/google');
|
|
327
|
+
const hasLocalImport =
|
|
328
|
+
code.includes('@timber/fonts/local') ||
|
|
329
|
+
code.includes('@timber-js/app/fonts/local') ||
|
|
330
|
+
code.includes('next/font/local');
|
|
331
|
+
if (!hasGoogleImport && !hasLocalImport) return null;
|
|
332
|
+
|
|
333
|
+
let transformedCode = code;
|
|
334
|
+
|
|
335
|
+
if (hasGoogleImport) {
|
|
336
|
+
transformedCode = transformGoogleFonts(transformedCode, code, id, pipeline, emitError);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (hasLocalImport) {
|
|
340
|
+
transformedCode = transformLocalFonts(transformedCode, code, id, pipeline, emitError);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (transformedCode !== code) {
|
|
344
|
+
// Inject side-effect import that registers font CSS on globalThis.
|
|
345
|
+
// The RSC entry reads globalThis.__timber_font_css to inline a <style> tag.
|
|
346
|
+
if (pipeline.size() > 0) {
|
|
347
|
+
transformedCode = `import '${VIRTUAL_FONT_CSS_REGISTER}';\n` + transformedCode;
|
|
348
|
+
}
|
|
349
|
+
return { code: transformedCode, map: null };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return null;
|
|
353
|
+
}
|
package/src/fonts/types.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* Design doc: 24-fonts.md
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// `CachedFont` lives in `google.ts`. Importing it here is type-only so
|
|
11
|
+
// there is no runtime dependency cycle — TypeScript erases the import.
|
|
12
|
+
import type { CachedFont } from './google.js';
|
|
13
|
+
|
|
10
14
|
/** Configuration passed to a Google font function (e.g. `Inter({ ... })`). */
|
|
11
15
|
export interface GoogleFontConfig {
|
|
12
16
|
weight?: string | string[];
|
|
@@ -47,7 +51,16 @@ export interface FontResult {
|
|
|
47
51
|
variable?: string;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Internal representation of a font extracted during static analysis.
|
|
56
|
+
*
|
|
57
|
+
* `ExtractedFont` is the **registration input** to `FontPipeline.register()`.
|
|
58
|
+
* Once a font has been registered the pipeline stores a `FontEntry` (which
|
|
59
|
+
* extends `ExtractedFont` with optional resolved `@font-face` descriptors
|
|
60
|
+
* and cached binary files). External code that needs to model a font that
|
|
61
|
+
* is not yet inside a pipeline (e.g. test fixtures) should use
|
|
62
|
+
* `ExtractedFont` directly.
|
|
63
|
+
*/
|
|
51
64
|
export interface ExtractedFont {
|
|
52
65
|
/** Unique identifier for this font instance (e.g. `inter-400-normal-latin`). */
|
|
53
66
|
id: string;
|
|
@@ -86,3 +99,39 @@ export interface FontFaceDescriptor {
|
|
|
86
99
|
display?: string;
|
|
87
100
|
unicodeRange?: string;
|
|
88
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The unified per-font store entry held inside `FontPipeline`.
|
|
105
|
+
*
|
|
106
|
+
* Combines what used to live in three parallel maps:
|
|
107
|
+
*
|
|
108
|
+
* - `registry: Map<string, ExtractedFont>` → the base extracted config
|
|
109
|
+
* - `googleFontFacesMap: Map<string, FontFaceDescriptor[]>` → `faces`
|
|
110
|
+
* - `cachedFonts: CachedFont[]` (flat, family-grouped) → `cachedFiles`
|
|
111
|
+
*
|
|
112
|
+
* `pruneFor` drops the entry **and** all of its attached state in one
|
|
113
|
+
* operation, so future code paths cannot reintroduce a TIM-824-style
|
|
114
|
+
* desync between parallel maps.
|
|
115
|
+
*
|
|
116
|
+
* See TIM-829.
|
|
117
|
+
*/
|
|
118
|
+
export interface FontEntry extends ExtractedFont {
|
|
119
|
+
/**
|
|
120
|
+
* Pre-resolved `@font-face` descriptors. Populated in `buildStart`
|
|
121
|
+
* (production) or lazily in `load` (dev). For local fonts the descriptors
|
|
122
|
+
* are derived from the source list at CSS-generation time and are not
|
|
123
|
+
* stored here.
|
|
124
|
+
*/
|
|
125
|
+
faces?: FontFaceDescriptor[];
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Downloaded Google Font binaries that back this entry's `@font-face`
|
|
129
|
+
* declarations. Empty for local fonts and in dev mode (where Google fonts
|
|
130
|
+
* are served straight from the CDN).
|
|
131
|
+
*
|
|
132
|
+
* Multiple entries that share the same `family` will hold references to
|
|
133
|
+
* the same `CachedFont` objects. The bundle emitter dedupes by
|
|
134
|
+
* `hashedFilename` so each binary is written exactly once.
|
|
135
|
+
*/
|
|
136
|
+
cachedFiles?: CachedFont[];
|
|
137
|
+
}
|