@timber-js/app 0.2.0-alpha.86 → 0.2.0-alpha.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.d.ts +44 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +7 -44
- package/dist/client/link.d.ts.map +1 -1
- package/dist/config-types.d.ts +39 -0
- package/dist/config-types.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +48 -0
- package/dist/fonts/bundle.d.ts.map +1 -0
- package/dist/fonts/dev-middleware.d.ts +22 -0
- package/dist/fonts/dev-middleware.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +138 -0
- package/dist/fonts/pipeline.d.ts.map +1 -0
- package/dist/fonts/transform.d.ts +72 -0
- package/dist/fonts/transform.d.ts.map +1 -0
- package/dist/fonts/types.d.ts +45 -1
- package/dist/fonts/types.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +59 -0
- package/dist/fonts/virtual-modules.d.ts.map +1 -0
- package/dist/index.js +773 -574
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-error-overlay.d.ts +1 -0
- package/dist/plugins/dev-error-overlay.d.ts.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 +1 -1
- package/src/client/index.ts +77 -1
- package/src/client/link.tsx +15 -65
- package/src/config-types.ts +39 -0
- package/src/fonts/bundle.ts +142 -0
- package/src/fonts/dev-middleware.ts +74 -0
- package/src/fonts/pipeline.ts +275 -0
- package/src/fonts/transform.ts +353 -0
- package/src/fonts/types.ts +50 -1
- package/src/fonts/virtual-modules.ts +159 -0
- package/src/plugins/dev-error-overlay.ts +47 -3
- 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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-output emission for the timber-fonts plugin.
|
|
3
|
+
*
|
|
4
|
+
* Splits the `generateBundle` hook into pure helpers so the plugin shell
|
|
5
|
+
* can stay focused on Vite wiring. The functions here are deliberately
|
|
6
|
+
* Rollup-agnostic — they take an `EmitFile` callback so the plugin can
|
|
7
|
+
* pass `this.emitFile` from the `generateBundle` context.
|
|
8
|
+
*
|
|
9
|
+
* Design doc: 24-fonts.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
|
+
import { resolve, normalize } from 'node:path';
|
|
14
|
+
import type { ManifestFontEntry, BuildManifest } from '../server/build-manifest.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Minimal shape of the asset descriptor accepted by Rollup's `emitFile`.
|
|
18
|
+
* Declared inline rather than importing from `rollup` to keep `bundle.ts`
|
|
19
|
+
* Rollup-agnostic and avoid pulling the type into the build graph.
|
|
20
|
+
*/
|
|
21
|
+
interface EmittedAsset {
|
|
22
|
+
type: 'asset';
|
|
23
|
+
fileName: string;
|
|
24
|
+
source: string | Uint8Array;
|
|
25
|
+
}
|
|
26
|
+
import type { FontPipeline } from './pipeline.js';
|
|
27
|
+
import type { CachedFont } from './google.js';
|
|
28
|
+
import { inferFontFormat } from './local.js';
|
|
29
|
+
|
|
30
|
+
type EmitFile = (asset: EmittedAsset) => string;
|
|
31
|
+
type EmitWarning = (msg: string) => void;
|
|
32
|
+
|
|
33
|
+
/** Group cached Google Font binaries by lowercase family name. */
|
|
34
|
+
export function groupCachedFontsByFamily(
|
|
35
|
+
cachedFonts: readonly CachedFont[]
|
|
36
|
+
): Map<string, CachedFont[]> {
|
|
37
|
+
const cachedByFamily = new Map<string, CachedFont[]>();
|
|
38
|
+
for (const cf of cachedFonts) {
|
|
39
|
+
const key = cf.face.family.toLowerCase();
|
|
40
|
+
const arr = cachedByFamily.get(key) ?? [];
|
|
41
|
+
arr.push(cf);
|
|
42
|
+
cachedByFamily.set(key, arr);
|
|
43
|
+
}
|
|
44
|
+
return cachedByFamily;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Emit cached Google Font binaries and local font files into the build
|
|
49
|
+
* output under `_timber/fonts/`. Local files missing on disk produce a
|
|
50
|
+
* warning rather than an error so the build still succeeds.
|
|
51
|
+
*
|
|
52
|
+
* Cached Google Font binaries are deduplicated by `hashedFilename` so
|
|
53
|
+
* each unique file is written exactly once even when multiple font
|
|
54
|
+
* entries share a family (and therefore share `CachedFont` references).
|
|
55
|
+
*/
|
|
56
|
+
export function emitFontAssets(
|
|
57
|
+
pipeline: FontPipeline,
|
|
58
|
+
emitFile: EmitFile,
|
|
59
|
+
warn: EmitWarning
|
|
60
|
+
): void {
|
|
61
|
+
// Cached Google Font binaries (content-hashed, deduped by filename)
|
|
62
|
+
for (const cf of pipeline.uniqueCachedFiles()) {
|
|
63
|
+
emitFile({
|
|
64
|
+
type: 'asset',
|
|
65
|
+
fileName: `_timber/fonts/${cf.hashedFilename}`,
|
|
66
|
+
source: cf.data,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Local font files (emitted by basename)
|
|
71
|
+
for (const font of pipeline.fonts()) {
|
|
72
|
+
if (font.provider !== 'local' || !font.localSources) continue;
|
|
73
|
+
for (const src of font.localSources) {
|
|
74
|
+
const absolutePath = normalize(resolve(src.path));
|
|
75
|
+
if (!existsSync(absolutePath)) {
|
|
76
|
+
warn(`Local font file not found: ${absolutePath}`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const basename = src.path.split('/').pop() ?? src.path;
|
|
80
|
+
const data = readFileSync(absolutePath);
|
|
81
|
+
emitFile({
|
|
82
|
+
type: 'asset',
|
|
83
|
+
fileName: `_timber/fonts/${basename}`,
|
|
84
|
+
source: data,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Populate `buildManifest.fonts` with `ManifestFontEntry[]` keyed by the
|
|
92
|
+
* importer module path (relative to the project root, matching how Vite's
|
|
93
|
+
* `manifest.json` keys css/js).
|
|
94
|
+
*
|
|
95
|
+
* Google fonts use the content-hashed filenames produced during
|
|
96
|
+
* `buildStart`. Local fonts use the source basename.
|
|
97
|
+
*/
|
|
98
|
+
export function writeFontManifest(
|
|
99
|
+
pipeline: FontPipeline,
|
|
100
|
+
buildManifest: BuildManifest,
|
|
101
|
+
rootDir: string
|
|
102
|
+
): void {
|
|
103
|
+
const fontsByImporter = new Map<string, ManifestFontEntry[]>();
|
|
104
|
+
|
|
105
|
+
for (const entry of pipeline.entries()) {
|
|
106
|
+
const manifestEntries = fontsByImporter.get(entry.importer) ?? [];
|
|
107
|
+
|
|
108
|
+
if (entry.provider === 'local' && entry.localSources) {
|
|
109
|
+
for (const src of entry.localSources) {
|
|
110
|
+
const filename = src.path.split('/').pop() ?? src.path;
|
|
111
|
+
const format = inferFontFormat(src.path);
|
|
112
|
+
manifestEntries.push({
|
|
113
|
+
href: `/_timber/fonts/${filename}`,
|
|
114
|
+
format,
|
|
115
|
+
crossOrigin: 'anonymous',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} else if (entry.cachedFiles) {
|
|
119
|
+
// Google fonts: use the per-entry cached files attached during
|
|
120
|
+
// buildStart. Each entry owns its own cached binaries (TIM-829),
|
|
121
|
+
// so we no longer need a family-grouped lookup.
|
|
122
|
+
for (const cf of entry.cachedFiles) {
|
|
123
|
+
manifestEntries.push({
|
|
124
|
+
href: `/_timber/fonts/${cf.hashedFilename}`,
|
|
125
|
+
format: 'woff2',
|
|
126
|
+
crossOrigin: 'anonymous',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fontsByImporter.set(entry.importer, manifestEntries);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Normalize importer paths to be relative to project root (matching how
|
|
135
|
+
// Vite's manifest.json keys work for css/js).
|
|
136
|
+
for (const [importer, entries] of fontsByImporter) {
|
|
137
|
+
const relativePath = importer.startsWith(rootDir)
|
|
138
|
+
? importer.slice(rootDir.length + 1)
|
|
139
|
+
: importer;
|
|
140
|
+
buildManifest.fonts[relativePath] = entries;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-mode middleware that serves local font binaries under
|
|
3
|
+
* `/_timber/fonts/<basename>`.
|
|
4
|
+
*
|
|
5
|
+
* Only files registered in the FontPipeline are served, and only by
|
|
6
|
+
* basename — directory traversal and path separators in the requested
|
|
7
|
+
* filename are rejected up front. Font CSS is no longer served here; it
|
|
8
|
+
* goes through Vite's CSS pipeline via virtual modules.
|
|
9
|
+
*
|
|
10
|
+
* Design doc: 24-fonts.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ViteDevServer } from 'vite';
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
15
|
+
import { resolve, normalize } from 'node:path';
|
|
16
|
+
import type { FontPipeline } from './pipeline.js';
|
|
17
|
+
|
|
18
|
+
const FONT_MIME_TYPES: Record<string, string> = {
|
|
19
|
+
woff2: 'font/woff2',
|
|
20
|
+
woff: 'font/woff',
|
|
21
|
+
ttf: 'font/ttf',
|
|
22
|
+
otf: 'font/otf',
|
|
23
|
+
eot: 'application/vnd.ms-fontopen',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wire the timber-fonts dev middleware onto a Vite dev server.
|
|
28
|
+
*
|
|
29
|
+
* Returns synchronously after registering the middleware. The pipeline is
|
|
30
|
+
* captured by reference so that fonts registered after `configureServer`
|
|
31
|
+
* runs (during transform) are still resolvable.
|
|
32
|
+
*/
|
|
33
|
+
export function installFontDevMiddleware(server: ViteDevServer, pipeline: FontPipeline): void {
|
|
34
|
+
server.middlewares.use((req, res, next) => {
|
|
35
|
+
const url = req.url;
|
|
36
|
+
if (!url || !url.startsWith('/_timber/fonts/')) return next();
|
|
37
|
+
|
|
38
|
+
const requestedFilename = url.slice('/_timber/fonts/'.length);
|
|
39
|
+
// Reject path traversal attempts and any subdirectory access. We only
|
|
40
|
+
// serve flat basenames out of the registry.
|
|
41
|
+
if (requestedFilename.includes('..') || requestedFilename.includes('/')) {
|
|
42
|
+
res.statusCode = 400;
|
|
43
|
+
res.end('Bad request');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Find the matching font file in the registry. We iterate every local
|
|
48
|
+
// font's source list and match by basename. The set is small (one per
|
|
49
|
+
// localFont() call) so this is cheap.
|
|
50
|
+
for (const font of pipeline.fonts()) {
|
|
51
|
+
if (font.provider !== 'local' || !font.localSources) continue;
|
|
52
|
+
for (const src of font.localSources) {
|
|
53
|
+
const basename = src.path.split('/').pop() ?? '';
|
|
54
|
+
if (basename !== requestedFilename) continue;
|
|
55
|
+
|
|
56
|
+
const absolutePath = normalize(resolve(src.path));
|
|
57
|
+
if (!existsSync(absolutePath)) {
|
|
58
|
+
res.statusCode = 404;
|
|
59
|
+
res.end('Not found');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const data = readFileSync(absolutePath);
|
|
63
|
+
const ext = absolutePath.split('.').pop()?.toLowerCase();
|
|
64
|
+
res.setHeader('Content-Type', FONT_MIME_TYPES[ext ?? ''] ?? 'application/octet-stream');
|
|
65
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
66
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
67
|
+
res.end(data);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
next();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|