@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,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
+ }
@@ -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
- /** Internal representation of a font extracted during static analysis. */
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
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Virtual module source generators for the timber-fonts plugin.
3
+ *
4
+ * The plugin exposes three virtual modules:
5
+ *
6
+ * - `@timber/fonts/google` — exports Google font loader functions. The
7
+ * transform hook replaces actual call sites with static `FontResult`
8
+ * objects, so this module is only loaded as a fallback (e.g. when the
9
+ * transform hook hasn't run yet, or when an unknown font is referenced
10
+ * via the default export proxy).
11
+ * - `@timber/fonts/local` — exports `localFont()` as a fallback default.
12
+ * - `virtual:timber-font-css-register` — side-effect module that sets the
13
+ * combined `@font-face` CSS on `globalThis.__timber_font_css`. The RSC
14
+ * entry reads this at render time to inline a `<style>` tag.
15
+ *
16
+ * Design doc: 24-fonts.md
17
+ */
18
+
19
+ import type { ExtractedFont, FontFaceDescriptor } from './types.js';
20
+ import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
21
+ import { generateFallbackCss } from './fallbacks.js';
22
+ import { generateLocalFontFaces } from './local.js';
23
+
24
+ export const VIRTUAL_GOOGLE = '@timber/fonts/google';
25
+ export const VIRTUAL_LOCAL = '@timber/fonts/local';
26
+ export const RESOLVED_GOOGLE = '\0@timber/fonts/google';
27
+ export const RESOLVED_LOCAL = '\0@timber/fonts/local';
28
+
29
+ export const VIRTUAL_FONT_CSS_REGISTER = 'virtual:timber-font-css-register';
30
+ export const RESOLVED_FONT_CSS_REGISTER = '\0virtual:timber-font-css-register';
31
+
32
+ /**
33
+ * Convert a font family name to a PascalCase export name.
34
+ * e.g. "JetBrains Mono" → "JetBrains_Mono"
35
+ */
36
+ function familyToExportName(family: string): string {
37
+ return family.replace(/\s+/g, '_');
38
+ }
39
+
40
+ /**
41
+ * Generate the virtual module source for `@timber/fonts/google`.
42
+ *
43
+ * The transform hook replaces real call sites at build time, so this
44
+ * module only matters as a runtime fallback. We export a Proxy default
45
+ * that handles any font name plus named exports for known families
46
+ * (for tree-shaking).
47
+ */
48
+ export function generateGoogleVirtualModule(fonts: Iterable<ExtractedFont>): string {
49
+ const families = new Set<string>();
50
+ for (const font of fonts) {
51
+ if (font.provider === 'google') families.add(font.family);
52
+ }
53
+
54
+ const lines = [
55
+ '// Auto-generated virtual module: @timber/fonts/google',
56
+ '// Each export is a font loader function that returns a FontResult.',
57
+ '',
58
+ 'function createFontResult(family, config) {',
59
+ ' return {',
60
+ ' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
61
+ ' style: { fontFamily: family },',
62
+ ' variable: config?.variable,',
63
+ ' };',
64
+ '}',
65
+ '',
66
+ 'export default new Proxy({}, {',
67
+ ' get(_, prop) {',
68
+ ' if (typeof prop === "string") {',
69
+ ' return (config) => createFontResult(prop.replace(/_/g, " "), config);',
70
+ ' }',
71
+ ' }',
72
+ '});',
73
+ ];
74
+
75
+ for (const family of families) {
76
+ const exportName = familyToExportName(family);
77
+ lines.push('');
78
+ lines.push(`export function ${exportName}(config) {`);
79
+ lines.push(` return createFontResult('${family}', config);`);
80
+ lines.push('}');
81
+ }
82
+
83
+ return lines.join('\n');
84
+ }
85
+
86
+ /**
87
+ * Generate the virtual module source for `@timber/fonts/local`.
88
+ *
89
+ * Like the google virtual module, this is a runtime fallback. The
90
+ * transform hook normally replaces `localFont(...)` calls with static
91
+ * `FontResult` objects at build time.
92
+ */
93
+ export function generateLocalVirtualModule(): string {
94
+ return [
95
+ '// Auto-generated virtual module: @timber/fonts/local',
96
+ '',
97
+ 'export default function localFont(config) {',
98
+ ' const family = config?.family || "Local Font";',
99
+ ' return {',
100
+ ' className: `timber-font-${family.toLowerCase().replace(/\\s+/g, "-")}`,',
101
+ ' style: { fontFamily: family },',
102
+ ' variable: config?.variable,',
103
+ ' };',
104
+ '}',
105
+ ].join('\n');
106
+ }
107
+
108
+ /**
109
+ * Generate combined CSS for a single extracted font.
110
+ *
111
+ * Includes `@font-face` rules (local or Google), the size-adjusted fallback
112
+ * `@font-face`, and the scoped class rule. For Google fonts the resolved
113
+ * `FontFaceDescriptor[]` must be passed in (from `generateProductionFontFaces`
114
+ * in production or `resolveDevFontFaces` in dev).
115
+ */
116
+ export function generateFontCss(font: ExtractedFont, googleFaces?: FontFaceDescriptor[]): string {
117
+ const cssParts: string[] = [];
118
+
119
+ if (font.provider === 'local' && font.localSources) {
120
+ const faces = generateLocalFontFaces(font.family, font.localSources, font.display);
121
+ const faceCss = generateFontFaces(faces);
122
+ if (faceCss) cssParts.push(faceCss);
123
+ }
124
+
125
+ if (font.provider === 'google' && googleFaces && googleFaces.length > 0) {
126
+ const faceCss = generateFontFaces(googleFaces);
127
+ if (faceCss) cssParts.push(faceCss);
128
+ }
129
+
130
+ const fallbackCss = generateFallbackCss(font.family);
131
+ if (fallbackCss) cssParts.push(fallbackCss);
132
+
133
+ if (font.variable) {
134
+ cssParts.push(generateVariableClass(font.className, font.variable, font.fontFamily));
135
+ } else {
136
+ cssParts.push(generateFontFamilyClass(font.className, font.fontFamily));
137
+ }
138
+
139
+ return cssParts.join('\n\n');
140
+ }
141
+
142
+ /**
143
+ * Generate the CSS output for all extracted fonts.
144
+ *
145
+ * Includes `@font-face` rules for local and Google fonts, fallback rules,
146
+ * and scoped classes. `googleFontFacesMap` provides pre-resolved
147
+ * `FontFaceDescriptor[]` for each Google font (keyed by `ExtractedFont.id`).
148
+ */
149
+ export function generateAllFontCss(
150
+ registry: Map<string, ExtractedFont>,
151
+ googleFontFacesMap?: Map<string, FontFaceDescriptor[]>
152
+ ): string {
153
+ const cssParts: string[] = [];
154
+ for (const font of registry.values()) {
155
+ const googleFaces = googleFontFacesMap?.get(font.id);
156
+ cssParts.push(generateFontCss(font, googleFaces));
157
+ }
158
+ return cssParts.join('\n\n');
159
+ }
@@ -234,6 +234,49 @@ export function formatRscDebugContext(components: RscDebugComponentInfo[]): stri
234
234
  return lines.join('\n');
235
235
  }
236
236
 
237
+ // ─── Module Runner Offset ───────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Dynamically compute the line offset that Vite's module runner adds
241
+ * when wrapping modules in an async function.
242
+ *
243
+ * Vite's `calculateOffsetOnce()` uses the same technique: create a new
244
+ * AsyncFunction, throw from line 1, and check where the engine reports
245
+ * the error. The difference between the reported line and 1 is the offset.
246
+ *
247
+ * This is engine-dependent (currently 2 on Node 18-22) and could change
248
+ * in future Node.js or V8 versions. Computing it at runtime ensures we
249
+ * always match the actual behavior.
250
+ */
251
+ let _cachedOffset: number | null = null;
252
+
253
+ export function calculateModuleRunnerOffset(): number {
254
+ if (_cachedOffset !== null) return _cachedOffset;
255
+
256
+ try {
257
+ // Parse the AsyncFunction source to count wrapper lines before the body.
258
+ // AsyncFunction.toString() returns something like:
259
+ // "async function anonymous(\n) {\nBODY\n}"
260
+ // The number of newlines before BODY is the offset Vite's module runner adds.
261
+ // eslint-disable-next-line no-new-func -- intentional: mirrors Vite's technique
262
+ const AsyncFunction = async function () {}.constructor as typeof Function;
263
+ const wrapper = new AsyncFunction('BODY');
264
+ const src = wrapper.toString();
265
+ const bodyIndex = src.indexOf('BODY');
266
+ if (bodyIndex === -1) {
267
+ _cachedOffset = 2; // fallback
268
+ return _cachedOffset;
269
+ }
270
+ const beforeBody = src.slice(0, bodyIndex);
271
+ const newlineCount = (beforeBody.match(/\n/g) || []).length;
272
+ _cachedOffset = newlineCount;
273
+ } catch {
274
+ _cachedOffset = 2; // fallback to known-good value
275
+ }
276
+
277
+ return _cachedOffset;
278
+ }
279
+
237
280
  // ─── Stack Trace Source-Mapping ──────────────────────────────────────────────
238
281
 
239
282
  /**
@@ -310,9 +353,10 @@ function rewriteStacktrace(
310
353
  const rawSourceMap = mod?.transformResult?.map;
311
354
  if (!rawSourceMap) return input;
312
355
 
313
- // Vite's module runner adds a 2-line offset for the async wrapper.
314
- // This matches Vite's internal `calculateOffsetOnce()` behavior.
315
- const OFFSET = 2;
356
+ // Vite's module runner wraps each module in an async function,
357
+ // adding N lines before the module body. The offset is computed
358
+ // dynamically to match the actual engine behavior (see TIM-783).
359
+ const OFFSET = calculateModuleRunnerOffset();
316
360
  const origLine = Number(lineStr) - OFFSET;
317
361
  const origCol = Number(colStr) - 1;
318
362
  if (origLine <= 0 || origCol < 0) return input;
@@ -98,6 +98,38 @@ function stripRootPrefix(id: string, root: string): string {
98
98
  *
99
99
  * Serializes output mode and feature flags for runtime consumption.
100
100
  */
101
+ /**
102
+ * Extract the JSON-serializable subset of `forms` config.
103
+ *
104
+ * Drops function-valued `stripSensitiveFields` with a build-time warning —
105
+ * functions cannot cross the JSON boundary to the runtime, so users must
106
+ * configure function predicates per-action via `createActionClient` instead.
107
+ */
108
+ function serializeFormsConfig(
109
+ forms: { stripSensitiveFields?: unknown } | undefined
110
+ ): { stripSensitiveFields?: boolean | readonly string[] } | undefined {
111
+ if (!forms) return undefined;
112
+ const opt = forms.stripSensitiveFields;
113
+ if (opt === undefined) return {};
114
+ if (typeof opt === 'boolean') return { stripSensitiveFields: opt };
115
+ if (Array.isArray(opt)) {
116
+ // Coerce to a string array and drop any non-string entries.
117
+ const names = opt.filter((v): v is string => typeof v === 'string');
118
+ return { stripSensitiveFields: names };
119
+ }
120
+ if (typeof opt === 'function') {
121
+ console.warn(
122
+ '[timber] forms.stripSensitiveFields was set to a function in timber.config.ts — ' +
123
+ 'this is not supported at the global level (functions cannot be serialized ' +
124
+ 'into the runtime config). Use a per-action override via ' +
125
+ '`createActionClient({ stripSensitiveFields: (name) => ... })` instead. ' +
126
+ 'The built-in deny-list will be used globally.'
127
+ );
128
+ return {};
129
+ }
130
+ return {};
131
+ }
132
+
101
133
  function generateConfigModule(ctx: PluginContext): string {
102
134
  const runtimeConfig = {
103
135
  output: ctx.config.output ?? 'server',
@@ -114,6 +146,11 @@ function generateConfigModule(ctx: PluginContext): string {
114
146
  // Auto-generated sitemap config — opt-in via timber.config.ts.
115
147
  // See design/16-metadata.md §"Auto-generated Sitemap"
116
148
  sitemap: ctx.config.sitemap,
149
+ // Forms config — only the serializable subset. `stripSensitiveFields`
150
+ // accepts boolean | string[] at the global level. Functions must be
151
+ // configured per-action via `createActionClient({ stripSensitiveFields })`
152
+ // because JSON.stringify would silently drop them here. See TIM-816.
153
+ forms: serializeFormsConfig(ctx.config.forms),
117
154
  // Per-build deployment ID for version skew detection (TIM-446).
118
155
  // Null in dev mode — HMR handles code updates without full reloads.
119
156
  deploymentId: ctx.deploymentId ?? null,