@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.
Files changed (56) hide show
  1. package/dist/client/index.d.ts +44 -1
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js.map +1 -1
  4. package/dist/client/link.d.ts +7 -44
  5. package/dist/client/link.d.ts.map +1 -1
  6. package/dist/config-types.d.ts +39 -0
  7. package/dist/config-types.d.ts.map +1 -1
  8. package/dist/fonts/bundle.d.ts +48 -0
  9. package/dist/fonts/bundle.d.ts.map +1 -0
  10. package/dist/fonts/dev-middleware.d.ts +22 -0
  11. package/dist/fonts/dev-middleware.d.ts.map +1 -0
  12. package/dist/fonts/pipeline.d.ts +138 -0
  13. package/dist/fonts/pipeline.d.ts.map +1 -0
  14. package/dist/fonts/transform.d.ts +72 -0
  15. package/dist/fonts/transform.d.ts.map +1 -0
  16. package/dist/fonts/types.d.ts +45 -1
  17. package/dist/fonts/types.d.ts.map +1 -1
  18. package/dist/fonts/virtual-modules.d.ts +59 -0
  19. package/dist/fonts/virtual-modules.d.ts.map +1 -0
  20. package/dist/index.js +773 -574
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugins/dev-error-overlay.d.ts +1 -0
  23. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  24. package/dist/plugins/entries.d.ts.map +1 -1
  25. package/dist/plugins/fonts.d.ts +16 -83
  26. package/dist/plugins/fonts.d.ts.map +1 -1
  27. package/dist/server/action-client.d.ts +8 -0
  28. package/dist/server/action-client.d.ts.map +1 -1
  29. package/dist/server/action-handler.d.ts +7 -0
  30. package/dist/server/action-handler.d.ts.map +1 -1
  31. package/dist/server/index.js +158 -2
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/route-matcher.d.ts +7 -0
  34. package/dist/server/route-matcher.d.ts.map +1 -1
  35. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  36. package/dist/server/sensitive-fields.d.ts +74 -0
  37. package/dist/server/sensitive-fields.d.ts.map +1 -0
  38. package/package.json +1 -1
  39. package/src/client/index.ts +77 -1
  40. package/src/client/link.tsx +15 -65
  41. package/src/config-types.ts +39 -0
  42. package/src/fonts/bundle.ts +142 -0
  43. package/src/fonts/dev-middleware.ts +74 -0
  44. package/src/fonts/pipeline.ts +275 -0
  45. package/src/fonts/transform.ts +353 -0
  46. package/src/fonts/types.ts +50 -1
  47. package/src/fonts/virtual-modules.ts +159 -0
  48. package/src/plugins/dev-error-overlay.ts +47 -3
  49. package/src/plugins/entries.ts +37 -0
  50. package/src/plugins/fonts.ts +102 -704
  51. package/src/plugins/routing.ts +6 -5
  52. package/src/server/action-client.ts +34 -4
  53. package/src/server/action-handler.ts +32 -2
  54. package/src/server/route-matcher.ts +7 -0
  55. package/src/server/rsc-entry/index.ts +19 -3
  56. 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
+ }