@timber-js/app 0.2.0-alpha.100 → 0.2.0-alpha.101

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 (46) hide show
  1. package/dist/_chunks/{actions-CQ8Z8VGL.js → actions-BsfxK6yu.js} +2 -2
  2. package/dist/_chunks/{actions-CQ8Z8VGL.js.map → actions-BsfxK6yu.js.map} +1 -1
  3. package/dist/_chunks/{chunk-BYIpzuS7.js → chunk-Cfpv9whK.js} +1 -8
  4. package/dist/_chunks/{define-CfBPoJb0.js → define-k32T6NL2.js} +2 -2
  5. package/dist/_chunks/{define-CfBPoJb0.js.map → define-k32T6NL2.js.map} +1 -1
  6. package/dist/_chunks/{navigation-root-BCYczjml.js → navigation-root-yx917rG0.js} +2 -2
  7. package/dist/_chunks/{navigation-root-BCYczjml.js.map → navigation-root-yx917rG0.js.map} +1 -1
  8. package/dist/_chunks/{use-segment-params-BkpKAQ7D.js → use-segment-params-DlsOX1YS.js} +2 -2
  9. package/dist/_chunks/{use-segment-params-BkpKAQ7D.js.map → use-segment-params-DlsOX1YS.js.map} +1 -1
  10. package/dist/client/index.js +4 -4
  11. package/dist/client/internal.d.ts +1 -1
  12. package/dist/client/internal.d.ts.map +1 -1
  13. package/dist/client/internal.js +3 -3
  14. package/dist/fonts/ast.d.ts +98 -6
  15. package/dist/fonts/ast.d.ts.map +1 -1
  16. package/dist/fonts/local.d.ts +1 -1
  17. package/dist/fonts/transform.d.ts +15 -1
  18. package/dist/fonts/transform.d.ts.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1481 -11536
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugins/dev-server.d.ts.map +1 -1
  23. package/dist/plugins/routing.d.ts.map +1 -1
  24. package/dist/plugins/static-build.d.ts +2 -23
  25. package/dist/plugins/static-build.d.ts.map +1 -1
  26. package/dist/segment-params/index.js +1 -1
  27. package/dist/server/index.js +1 -1
  28. package/dist/server/internal.js +1 -1
  29. package/package.json +2 -1
  30. package/src/client/internal.ts +1 -1
  31. package/src/fonts/ast.ts +488 -16
  32. package/src/fonts/local.ts +1 -1
  33. package/src/fonts/transform.ts +147 -236
  34. package/src/index.ts +14 -2
  35. package/src/plugins/dev-server.ts +8 -9
  36. package/src/plugins/routing.ts +1 -12
  37. package/src/plugins/static-build.ts +3 -83
  38. package/dist/plugins/server-action-exports.d.ts +0 -26
  39. package/dist/plugins/server-action-exports.d.ts.map +0 -1
  40. package/dist/routing/status-file-lint.d.ts +0 -34
  41. package/dist/routing/status-file-lint.d.ts.map +0 -1
  42. package/dist/utils/directive-parser.d.ts +0 -28
  43. package/dist/utils/directive-parser.d.ts.map +0 -1
  44. package/src/plugins/server-action-exports.ts +0 -220
  45. package/src/routing/status-file-lint.ts +0 -115
  46. package/src/utils/directive-parser.ts +0 -140
@@ -1 +1 @@
1
- {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/plugins/dev-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiC,MAAM,MAAM,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA8C1D;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAkE1D"}
1
+ {"version":3,"file":"dev-server.d.ts","sourceRoot":"","sources":["../../src/plugins/dev-server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiC,MAAM,MAAM,CAAC;AAGlE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA8C1D;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAiE1D"}
@@ -1 +1 @@
1
- {"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/plugins/routing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAiBlD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA0E1D,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAmLxD"}
1
+ {"version":3,"file":"routing.d.ts","sourceRoot":"","sources":["../../src/plugins/routing.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAclD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA0E1D,wBAAgB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CA2KxD"}
@@ -3,22 +3,18 @@
3
3
  *
4
4
  * When `output: 'static'` is set in timber.config.ts, this plugin:
5
5
  * 1. Validates that no dynamic APIs (getCookies(), getHeaders()) are used
6
- * 2. When client JavaScript is disabled, rejects 'use client' and 'use server' directives
7
- * 3. Coordinates build-time rendering of all pages
6
+ * 2. Coordinates build-time rendering of all pages
8
7
  *
9
8
  * Design doc: design/15-future-prerendering.md
10
9
  */
11
10
  import type { Plugin } from 'vite';
12
11
  import type { PluginContext } from '../plugin-context.js';
13
12
  export interface StaticValidationError {
14
- type: 'dynamic-api' | 'nojs-directive';
13
+ type: 'dynamic-api';
15
14
  file: string;
16
15
  message: string;
17
16
  line?: number;
18
17
  }
19
- export interface StaticOptions {
20
- clientJavascriptDisabled: boolean;
21
- }
22
18
  /**
23
19
  * Detect usage of dynamic per-request APIs (getCookies(), getHeaders())
24
20
  * that cannot work at build time in static mode.
@@ -26,23 +22,6 @@ export interface StaticOptions {
26
22
  * Returns an array of validation errors.
27
23
  */
28
24
  export declare function detectDynamicApis(code: string, fileId: string): StaticValidationError[];
29
- /**
30
- * Detect 'use client' and 'use server' directives using AST-based parsing.
31
- * When client JavaScript is disabled, both are hard build errors — no React
32
- * runtime or server actions are allowed in the output.
33
- *
34
- * When client JavaScript is enabled, these are allowed (client components
35
- * hydrate, server actions get extracted to API endpoints).
36
- */
37
- export declare function detectDirectives(code: string, fileId: string, options: StaticOptions): StaticValidationError[];
38
- /**
39
- * Run all static mode validations on a source file.
40
- *
41
- * Combines:
42
- * - Dynamic API detection (getCookies, getHeaders) — always in static mode
43
- * - Directive detection ('use client', 'use server') — only when client JS is disabled
44
- */
45
- export declare function validateStaticMode(code: string, fileId: string, options: StaticOptions): StaticValidationError[];
46
25
  /**
47
26
  * Create the timber-static-build Vite plugin.
48
27
  *
@@ -1 +1 @@
1
- {"version":3,"file":"static-build.d.ts","sourceRoot":"","sources":["../../src/plugins/static-build.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAO1D,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,aAAa,GAAG,gBAAgB,CAAC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,aAAa;IAC5B,wBAAwB,EAAE,OAAO,CAAC;CACnC;AAmBD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,qBAAqB,EAAE,CA2BvF;AAMD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,aAAa,GACrB,qBAAqB,EAAE,CA8BzB;AAMD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,aAAa,GACrB,qBAAqB,EAAE,CAOzB;AAMD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CA8C5D"}
1
+ {"version":3,"file":"static-build.d.ts","sourceRoot":"","sources":["../../src/plugins/static-build.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAM1D,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAmBD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,qBAAqB,EAAE,CA2BvF;AAMD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CA4C5D"}
@@ -1,2 +1,2 @@
1
- import { r as defineSegmentParams } from "../_chunks/define-CfBPoJb0.js";
1
+ import { r as defineSegmentParams } from "../_chunks/define-k32T6NL2.js";
2
2
  export { defineSegmentParams };
@@ -1,6 +1,6 @@
1
1
  import { a as getSpanId, d as withSpan, o as getTraceId, p as formFlashAls, t as addSpanEvent, v as isDebug, y as isDevMode } from "../_chunks/tracing-C8V-YGsP.js";
2
2
  import { t as formatSize } from "../_chunks/format-Bcn-Iv1x.js";
3
- import { E as getCookieJar, a as revalidateTag, b as getSegmentParams, c as DenySignal, f as deny, h as waitUntil, i as revalidatePath, l as RedirectSignal, m as redirectExternal, p as redirect, s as parseFormData, u as RedirectType, v as getHeaders } from "../_chunks/actions-CQ8Z8VGL.js";
3
+ import { E as getCookieJar, a as revalidateTag, b as getSegmentParams, c as DenySignal, f as deny, h as waitUntil, i as revalidatePath, l as RedirectSignal, m as redirectExternal, p as redirect, s as parseFormData, u as RedirectType, v as getHeaders } from "../_chunks/actions-BsfxK6yu.js";
4
4
  //#region src/server/sensitive-fields.ts
5
5
  /**
6
6
  * Sensitive field stripping — removes password/token/CVV-style fields
@@ -3,7 +3,7 @@ import { a as warnRedirectInSuspense, c as warnSuspenseWrappingChildren, i as wa
3
3
  import { n as classifyUrlSegment } from "../_chunks/segment-classify-BjfuctV2.js";
4
4
  import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-BU684ls2.js";
5
5
  import { a as logProxyError, c as logRequestReceived, d as logSwrRefetchFailed, f as logWaitUntilRejected, h as swallow, i as logMiddlewareShortCircuit, l as logRouteError, m as setLogger, n as logCacheMiss, o as logRenderError, p as logWaitUntilUnsupported, r as logMiddlewareError, s as logRequestCompleted, t as getLogger, u as logSlowRequest } from "../_chunks/logger-0m8MsKdc.js";
6
- import { C as setMutableCookieContext, D as getSetCookieHeaders, S as runWithRequestContext, T as getCookie, _ as getHeader, b as getSegmentParams, c as DenySignal, d as RenderError, g as applyRequestHeaderOverlay, l as RedirectSignal, n as executeAction, o as coerce, r as isRscActionRequest, t as buildNoJsResponse, w as setSegmentParams, x as markResponseFlushed, y as getSearchParams } from "../_chunks/actions-CQ8Z8VGL.js";
6
+ import { C as setMutableCookieContext, D as getSetCookieHeaders, S as runWithRequestContext, T as getCookie, _ as getHeader, b as getSegmentParams, c as DenySignal, d as RenderError, g as applyRequestHeaderOverlay, l as RedirectSignal, n as executeAction, o as coerce, r as isRscActionRequest, t as buildNoJsResponse, w as setSegmentParams, x as markResponseFlushed, y as getSearchParams } from "../_chunks/actions-BsfxK6yu.js";
7
7
  import "../client/error-boundary.js";
8
8
  import "../_chunks/segment-context-Dx_OizxD.js";
9
9
  import { readFile } from "node:fs/promises";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.100",
3
+ "version": "0.2.0-alpha.101",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -114,6 +114,7 @@
114
114
  "@opentelemetry/api": "^1.9.1",
115
115
  "@opentelemetry/context-async-hooks": "^2.6.1",
116
116
  "@opentelemetry/sdk-trace-base": "^2.6.1",
117
+ "magic-string": "^0.30.21",
117
118
  "nitro": "3.0.260311-beta"
118
119
  },
119
120
  "peerDependencies": {
@@ -39,7 +39,7 @@ export { HistoryStack } from './history.js';
39
39
  export type { HistoryEntry } from './history.js';
40
40
 
41
41
  // ── Params (internal setter + raw hooks) ─────────────────────────────────
42
- export { setCurrentParams, useSegmentParams } from './use-segment-params.js';
42
+ export { setCurrentParams } from './use-segment-params.js';
43
43
  export { useSearchParams } from './use-search-params.js';
44
44
 
45
45
  // ── Navigation context ───────────────────────────────────────────────────
package/src/fonts/ast.ts CHANGED
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Acorn-based AST utilities for extracting static font configs from source code.
2
+ * AST utilities for extracting static font configs from source code.
3
+ * Uses OXC via Vite's `parseAst` — handles JS/JSX/TS/TSX natively.
3
4
  *
4
- * Replaces the fragile regex-based extraction in fonts.ts and local.ts.
5
- * Uses acorn (already a Vite dependency) for robust parsing that handles
6
- * comments, trailing commas, multi-line configs, and other edge cases
7
- * the regex approach missed.
5
+ * Provides two tiers of API:
6
+ *
7
+ * 1. **Source-text helpers** (`extractFontConfigAst`, `extractLocalFontConfigAst`,
8
+ * `detectDynamicFontCallAst`) — parse a snippet and return extracted config.
9
+ * Still used by tests and callers that have only a string.
10
+ *
11
+ * 2. **AST-node helpers** (`extractGoogleFontConfigFromNode`,
12
+ * `extractLocalFontConfigFromNode`, `findFontCallSites`) — operate on
13
+ * nodes from an already-parsed Program so the transform hook can share
14
+ * one parse with all analysis steps.
8
15
  *
9
16
  * Design doc: 24-fonts.md §"Step 1: Static Analysis"
10
17
  */
11
18
 
12
- import { parse } from 'acorn';
19
+ import { parseAst as viteParseAst } from 'vite';
13
20
  import type { GoogleFontConfig } from './types.js';
14
21
  import type { LocalFontConfig, LocalFontSrc } from './types.js';
15
22
 
@@ -46,20 +53,34 @@ interface IdentifierNode extends AstNode {
46
53
  name: string;
47
54
  }
48
55
 
56
+ // ── Offset → line/column helper ──────────────────────────────────────────────
57
+
58
+ /** Convert a 0-based byte offset into a 1-based line and 0-based column. */
59
+ function offsetToLineColumn(source: string, offset: number): { line: number; column: number } {
60
+ let line = 1;
61
+ let lastNewline = -1;
62
+ for (let i = 0; i < offset; i++) {
63
+ if (source[i] === '\n') {
64
+ line++;
65
+ lastNewline = i;
66
+ }
67
+ }
68
+ return { line, column: offset - lastNewline - 1 };
69
+ }
70
+
49
71
  // ── Core AST extraction helpers ──────────────────────────────────────────────
50
72
 
51
73
  /**
52
74
  * Parse a JavaScript expression string into an AST node.
53
75
  *
54
- * Wraps the expression in parens so acorn treats it as an expression statement.
76
+ * Wraps the expression in parens so OXC (via vite.parseAst) treats it as an expression statement.
55
77
  * Returns null if parsing fails.
56
78
  */
57
79
  function parseExpression(source: string): AstNode | null {
58
80
  try {
59
- const ast = parse(`(${source})`, {
60
- ecmaVersion: 'latest',
61
- sourceType: 'module',
62
- }) as unknown as { body: Array<{ expression: AstNode }> };
81
+ const ast = viteParseAst(`(${source})`, { lang: 'tsx' }) as unknown as {
82
+ body: Array<{ expression: AstNode }>;
83
+ };
63
84
  return ast.body[0]?.expression ?? null;
64
85
  } catch {
65
86
  return null;
@@ -293,7 +314,7 @@ export function extractLocalFontConfigAst(callSource: string): LocalFontConfig |
293
314
  * Detect if source code contains dynamic/computed font function calls
294
315
  * that cannot be statically analyzed.
295
316
  *
296
- * Uses acorn to parse the full source and inspect CallExpression nodes.
317
+ * Uses OXC (via vite.parseAst) to parse the full source and inspect CallExpression nodes.
297
318
  * Returns the offending expression string if found, null if all calls are static.
298
319
  */
299
320
  export function detectDynamicFontCallAst(source: string, importedNames: string[]): string | null {
@@ -301,10 +322,7 @@ export function detectDynamicFontCallAst(source: string, importedNames: string[]
301
322
 
302
323
  let ast;
303
324
  try {
304
- ast = parse(source, {
305
- ecmaVersion: 'latest',
306
- sourceType: 'module',
307
- }) as unknown as { body: AstNode[] };
325
+ ast = viteParseAst(source, { lang: 'tsx' }) as unknown as { body: AstNode[] };
308
326
  } catch {
309
327
  // If we can't parse the source at all, fall back to no detection
310
328
  return null;
@@ -357,3 +375,457 @@ function walkForDynamicCalls(node: AstNode, names: Set<string>, source: string):
357
375
 
358
376
  return null;
359
377
  }
378
+
379
+ // ── AST-node-level config extraction ─────────────────────────────────────────
380
+ //
381
+ // These work on an already-parsed ObjectExpression node so the transform
382
+ // hook can share one OXC (via vite.parseAst) parse of the full source file with all analysis
383
+ // steps (TIM-827).
384
+
385
+ /**
386
+ * Extract a GoogleFontConfig from an ObjectExpression AST node.
387
+ *
388
+ * This is the node-level equivalent of `extractFontConfigAst` — it skips
389
+ * the parse step because the caller already has a parsed Program.
390
+ */
391
+ export function extractGoogleFontConfigFromNode(node: AstNode): GoogleFontConfig | null {
392
+ if (node.type !== 'ObjectExpression') return null;
393
+ return extractGoogleConfig(node as ObjectExpressionNode);
394
+ }
395
+
396
+ /**
397
+ * Extract a LocalFontConfig from an ObjectExpression AST node.
398
+ */
399
+ export function extractLocalFontConfigFromNode(node: AstNode): LocalFontConfig | null {
400
+ if (node.type !== 'ObjectExpression') return null;
401
+ return extractLocalConfig(node as ObjectExpressionNode);
402
+ }
403
+
404
+ /** Shared implementation for GoogleFontConfig extraction from an object node. */
405
+ function extractGoogleConfig(objNode: ObjectExpressionNode): GoogleFontConfig {
406
+ const props = objectProperties(objNode);
407
+ const config: GoogleFontConfig = {};
408
+
409
+ const subsetsNode = props.get('subsets');
410
+ if (subsetsNode) {
411
+ const arr = extractStringArray(subsetsNode);
412
+ if (arr) config.subsets = arr;
413
+ }
414
+
415
+ const weightNode = props.get('weight');
416
+ if (weightNode) {
417
+ const arr = extractStringArray(weightNode);
418
+ if (arr) {
419
+ config.weight = arr;
420
+ } else {
421
+ const s = extractString(weightNode);
422
+ if (s !== undefined) config.weight = s;
423
+ }
424
+ }
425
+
426
+ const displayNode = props.get('display');
427
+ if (displayNode) {
428
+ const s = extractString(displayNode);
429
+ if (s !== undefined) config.display = s as GoogleFontConfig['display'];
430
+ }
431
+
432
+ const variableNode = props.get('variable');
433
+ if (variableNode) {
434
+ const s = extractString(variableNode);
435
+ if (s !== undefined) config.variable = s;
436
+ }
437
+
438
+ const styleNode = props.get('style');
439
+ if (styleNode) {
440
+ const arr = extractStringArray(styleNode);
441
+ if (arr) {
442
+ config.style = arr;
443
+ } else {
444
+ const s = extractString(styleNode);
445
+ if (s !== undefined) config.style = s;
446
+ }
447
+ }
448
+
449
+ const preloadNode = props.get('preload');
450
+ if (preloadNode) {
451
+ const b = extractBoolean(preloadNode);
452
+ if (b !== undefined) config.preload = b;
453
+ }
454
+
455
+ return config;
456
+ }
457
+
458
+ /** Shared implementation for LocalFontConfig extraction from an object node. */
459
+ function extractLocalConfig(objNode: ObjectExpressionNode): LocalFontConfig | null {
460
+ const props = objectProperties(objNode);
461
+
462
+ const displayNode = props.get('display');
463
+ const display = displayNode
464
+ ? (extractString(displayNode) as LocalFontConfig['display'])
465
+ : undefined;
466
+
467
+ const variableNode = props.get('variable');
468
+ const variable = variableNode ? extractString(variableNode) : undefined;
469
+
470
+ const familyNode = props.get('family');
471
+ const family = familyNode ? extractString(familyNode) : undefined;
472
+
473
+ const srcNode = props.get('src');
474
+ if (!srcNode) return null;
475
+
476
+ const srcString = extractString(srcNode);
477
+ if (srcString !== undefined) {
478
+ return { src: srcString, display, variable, family };
479
+ }
480
+
481
+ if (srcNode.type === 'ArrayExpression') {
482
+ const arr = srcNode as ArrayExpressionNode;
483
+ const entries: LocalFontSrc[] = [];
484
+ for (const elem of arr.elements) {
485
+ const entry = extractLocalFontSrcEntry(elem);
486
+ if (!entry) return null;
487
+ entries.push(entry);
488
+ }
489
+ if (entries.length === 0) return null;
490
+ return { src: entries, display, variable, family };
491
+ }
492
+
493
+ return null;
494
+ }
495
+
496
+ // ── Font module specifiers ───────────────────────────────────────────────────
497
+
498
+ const GOOGLE_FONT_SOURCES = new Set([
499
+ '@timber/fonts/google',
500
+ '@timber-js/app/fonts/google',
501
+ 'next/font/google',
502
+ ]);
503
+
504
+ const LOCAL_FONT_SOURCES = new Set([
505
+ '@timber/fonts/local',
506
+ '@timber-js/app/fonts/local',
507
+ 'next/font/local',
508
+ ]);
509
+
510
+ // ── Parsed source types ──────────────────────────────────────────────────────
511
+
512
+ /** A font import binding discovered from an ImportDeclaration. */
513
+ export interface FontBinding {
514
+ /** The local name used in the module (after `as`, if present). */
515
+ localName: string;
516
+ /**
517
+ * The original exported name (before `as`). For Google fonts this is
518
+ * the PascalCase_With_Underscores name that maps to the family. For
519
+ * local fonts (`default` import), this is `'default'`.
520
+ */
521
+ importedName: string;
522
+ /** `'google'` or `'local'`. */
523
+ provider: 'google' | 'local';
524
+ /** The AST node of the ImportDeclaration (for removal via magic-string). */
525
+ importNode: AstNode & { start: number; end: number };
526
+ }
527
+
528
+ /** A font function call site discovered from a CallExpression. */
529
+ export interface FontCallSite {
530
+ /** The binding that this call references. */
531
+ binding: FontBinding;
532
+ /**
533
+ * The variable name this call is assigned to, if the call appears in a
534
+ * VariableDeclarator. `null` for bare expression statements.
535
+ */
536
+ assignedName: string | null;
537
+ /** The first argument AST node (expected to be an ObjectExpression). */
538
+ configNode: AstNode;
539
+ /**
540
+ * The outermost AST node to replace. For a VariableDeclaration with a
541
+ * single declarator this is the full declaration; for a bare expression
542
+ * statement it is the ExpressionStatement. The transform replaces
543
+ * `source[replaceStart..replaceEnd]`.
544
+ */
545
+ replaceStart: number;
546
+ replaceEnd: number;
547
+ /** Line number (1-based) of the call for error messages. */
548
+ line: number;
549
+ /** Column (0-based) of the call for error messages. */
550
+ column: number;
551
+ }
552
+
553
+ /** Result of `findFontCallSites` — everything the transform needs. */
554
+ export interface FontAnalysis {
555
+ bindings: FontBinding[];
556
+ callSites: FontCallSite[];
557
+ /** Import nodes that should be removed (deduplicated). */
558
+ importNodesToRemove: Array<{ start: number; end: number }>;
559
+ /** First dynamic call expression text, or `null`. */
560
+ dynamicCall: string | null;
561
+ /**
562
+ * Font calls found in nested scopes (functions, if-blocks, etc.).
563
+ * These are errors — font calls must be module-level.
564
+ */
565
+ nestedCalls: Array<{ name: string; line: number; column: number }>;
566
+ }
567
+
568
+ interface PositionedNode extends AstNode {
569
+ start: number;
570
+ end: number;
571
+ }
572
+
573
+ interface ImportDeclarationNode extends PositionedNode {
574
+ type: 'ImportDeclaration';
575
+ source: LiteralNode & { value: string };
576
+ specifiers: Array<
577
+ PositionedNode & {
578
+ type: string;
579
+ local: IdentifierNode;
580
+ imported?: IdentifierNode;
581
+ }
582
+ >;
583
+ }
584
+
585
+ interface CallExpressionNode extends PositionedNode {
586
+ type: 'CallExpression';
587
+ callee: AstNode;
588
+ arguments: PositionedNode[];
589
+ }
590
+
591
+ interface VariableDeclaratorNode extends PositionedNode {
592
+ type: 'VariableDeclarator';
593
+ id: AstNode;
594
+ init: AstNode | null;
595
+ }
596
+
597
+ interface VariableDeclarationNode extends PositionedNode {
598
+ type: 'VariableDeclaration';
599
+ declarations: VariableDeclaratorNode[];
600
+ }
601
+
602
+ interface ExpressionStatementNode extends PositionedNode {
603
+ type: 'ExpressionStatement';
604
+ expression: AstNode;
605
+ }
606
+
607
+ interface ProgramNode extends PositionedNode {
608
+ type: 'Program';
609
+ body: PositionedNode[];
610
+ }
611
+
612
+ /**
613
+ * Parse a full source file and find all font imports and call sites.
614
+ *
615
+ * Returns `null` if the source has no font-related imports.
616
+ */
617
+ export function findFontCallSites(source: string): FontAnalysis | null {
618
+ let program: ProgramNode;
619
+ try {
620
+ program = viteParseAst(source, { lang: 'tsx' }) as unknown as ProgramNode;
621
+ } catch {
622
+ return null;
623
+ }
624
+
625
+ // Step 1: Find all font import declarations and build the bindings list.
626
+ const bindings: FontBinding[] = [];
627
+ const importNodesToRemove: Array<{ start: number; end: number }> = [];
628
+
629
+ for (const stmt of program.body) {
630
+ if (stmt.type !== 'ImportDeclaration') continue;
631
+ const importDecl = stmt as unknown as ImportDeclarationNode;
632
+ const specSource = importDecl.source.value;
633
+
634
+ let provider: 'google' | 'local' | null = null;
635
+ if (GOOGLE_FONT_SOURCES.has(specSource)) provider = 'google';
636
+ else if (LOCAL_FONT_SOURCES.has(specSource)) provider = 'local';
637
+ if (!provider) continue;
638
+
639
+ for (const spec of importDecl.specifiers) {
640
+ if (spec.type === 'ImportSpecifier' && provider === 'google') {
641
+ bindings.push({
642
+ localName: spec.local.name,
643
+ importedName: spec.imported?.name ?? spec.local.name,
644
+ provider,
645
+ importNode: importDecl,
646
+ });
647
+ } else if (spec.type === 'ImportDefaultSpecifier' && provider === 'local') {
648
+ bindings.push({
649
+ localName: spec.local.name,
650
+ importedName: 'default',
651
+ provider,
652
+ importNode: importDecl,
653
+ });
654
+ }
655
+ // ImportNamespaceSpecifier (`import * as fonts from ...`) is not
656
+ // currently supported — the old regex didn't handle it either.
657
+ // If we add it later, we'd need to also handle `fonts.Inter(...)`
658
+ // member expressions in the call-site walk.
659
+ }
660
+
661
+ importNodesToRemove.push({ start: importDecl.start, end: importDecl.end });
662
+ }
663
+
664
+ if (bindings.length === 0) return null;
665
+
666
+ // Step 2: Walk the AST for CallExpression nodes whose callee is a font binding.
667
+ const bindingsByName = new Map(bindings.map((b) => [b.localName, b]));
668
+ const callSites: FontCallSite[] = [];
669
+ let dynamicCall: string | null = null;
670
+
671
+ for (const stmt of program.body) {
672
+ // VariableDeclaration: `const inter = Inter({ ... })`
673
+ if (stmt.type === 'VariableDeclaration') {
674
+ const varDecl = stmt as unknown as VariableDeclarationNode;
675
+ for (const declarator of varDecl.declarations) {
676
+ if (!declarator.init || declarator.init.type !== 'CallExpression') continue;
677
+ const call = declarator.init as unknown as CallExpressionNode;
678
+ const site = matchCallExpression(call, bindingsByName, source);
679
+ if (!site) continue;
680
+
681
+ if (site === 'dynamic') {
682
+ if (!dynamicCall) {
683
+ const calleeName = (call.callee as IdentifierNode).name;
684
+ const argStart = call.arguments[0].start;
685
+ const argEnd = call.arguments[0].end;
686
+ dynamicCall = `${calleeName}(${source.slice(argStart, argEnd)})`;
687
+ }
688
+ continue;
689
+ }
690
+
691
+ const assignedName =
692
+ declarator.id.type === 'Identifier' ? (declarator.id as IdentifierNode).name : null;
693
+
694
+ const { line, column } = offsetToLineColumn(source, call.start);
695
+ callSites.push({
696
+ binding: site.binding,
697
+ assignedName,
698
+ configNode: site.configNode,
699
+ replaceStart: call.start,
700
+ replaceEnd: call.end,
701
+ line,
702
+ column,
703
+ });
704
+ }
705
+ }
706
+
707
+ // ExpressionStatement: `Inter({ ... })` as a bare call
708
+ if (stmt.type === 'ExpressionStatement') {
709
+ const exprStmt = stmt as unknown as ExpressionStatementNode;
710
+ if (exprStmt.expression.type === 'CallExpression') {
711
+ const call = exprStmt.expression as unknown as CallExpressionNode;
712
+ const site = matchCallExpression(call, bindingsByName, source);
713
+ if (site && site !== 'dynamic') {
714
+ const { line, column } = offsetToLineColumn(source, call.start);
715
+ callSites.push({
716
+ binding: site.binding,
717
+ assignedName: null,
718
+ configNode: site.configNode,
719
+ replaceStart: (exprStmt as PositionedNode).start,
720
+ replaceEnd: (exprStmt as PositionedNode).end,
721
+ line,
722
+ column,
723
+ });
724
+ } else if (site === 'dynamic' && !dynamicCall) {
725
+ const calleeName = (call.callee as IdentifierNode).name;
726
+ const argStart = call.arguments[0].start;
727
+ const argEnd = call.arguments[0].end;
728
+ dynamicCall = `${calleeName}(${source.slice(argStart, argEnd)})`;
729
+ }
730
+ }
731
+ }
732
+ }
733
+
734
+ // Step 3: Walk nested scopes to find font calls that aren't module-level.
735
+ // Font calls must be at module scope (same constraint as Next.js).
736
+ // If the import is removed but a nested call remains, it's a ReferenceError.
737
+ const nestedCalls: Array<{ name: string; line: number; column: number }> = [];
738
+ for (const stmt of program.body) {
739
+ if (stmt.type === 'ImportDeclaration') continue;
740
+ walkForNestedFontCalls(stmt as AstNode, bindingsByName, source, nestedCalls, false);
741
+ }
742
+
743
+ return { bindings, callSites, importNodesToRemove, dynamicCall, nestedCalls };
744
+ }
745
+
746
+ /**
747
+ * Recursively walk an AST node looking for font CallExpressions in nested
748
+ * scopes (function bodies, if-blocks, loops, etc.). Font calls must be
749
+ * module-level; nested calls are collected as errors.
750
+ *
751
+ * `isNested` tracks whether we've descended past the top-level statement.
752
+ * Top-level VariableDeclarations and ExpressionStatements are already
753
+ * handled by the main loop in `findFontCallSites`, so we only flag calls
754
+ * found deeper.
755
+ */
756
+ function walkForNestedFontCalls(
757
+ node: AstNode,
758
+ bindings: Map<string, FontBinding>,
759
+ source: string,
760
+ out: Array<{ name: string; line: number; column: number }>,
761
+ isNested: boolean
762
+ ): void {
763
+ if (!node || typeof node !== 'object') return;
764
+
765
+ if (node.type === 'CallExpression') {
766
+ const callee = node.callee as AstNode;
767
+ if (isNested && callee.type === 'Identifier' && bindings.has((callee as IdentifierNode).name)) {
768
+ const pos = offsetToLineColumn(source, (node as PositionedNode).start);
769
+ out.push({ name: (callee as IdentifierNode).name, line: pos.line, column: pos.column });
770
+ return; // Don't recurse into this call's children
771
+ }
772
+ }
773
+
774
+ // These node types introduce a nested scope — any font call inside is an error.
775
+ const scopeTypes = new Set([
776
+ 'FunctionDeclaration',
777
+ 'FunctionExpression',
778
+ 'ArrowFunctionExpression',
779
+ 'IfStatement',
780
+ 'ForStatement',
781
+ 'ForInStatement',
782
+ 'ForOfStatement',
783
+ 'WhileStatement',
784
+ 'DoWhileStatement',
785
+ 'SwitchStatement',
786
+ 'TryStatement',
787
+ 'BlockStatement',
788
+ 'ClassDeclaration',
789
+ 'ClassExpression',
790
+ ]);
791
+ const enterNested = isNested || scopeTypes.has(node.type);
792
+
793
+ for (const key of Object.keys(node)) {
794
+ if (key === 'type') continue;
795
+ const child = node[key];
796
+ if (Array.isArray(child)) {
797
+ for (const item of child) {
798
+ if (item && typeof item === 'object' && (item as AstNode).type) {
799
+ walkForNestedFontCalls(item as AstNode, bindings, source, out, enterNested);
800
+ }
801
+ }
802
+ } else if (child && typeof child === 'object' && (child as AstNode).type) {
803
+ walkForNestedFontCalls(child as AstNode, bindings, source, out, enterNested);
804
+ }
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Match a CallExpression against the font bindings map.
810
+ *
811
+ * Returns the binding + config node if the call is a static font call,
812
+ * `'dynamic'` if it's a font call with a non-object argument, or `null`
813
+ * if the callee doesn't match any font binding.
814
+ */
815
+ function matchCallExpression(
816
+ call: CallExpressionNode,
817
+ bindings: Map<string, FontBinding>,
818
+ _source: string
819
+ ): { binding: FontBinding; configNode: AstNode } | 'dynamic' | null {
820
+ if (call.callee.type !== 'Identifier') return null;
821
+ const calleeName = (call.callee as IdentifierNode).name;
822
+ const binding = bindings.get(calleeName);
823
+ if (!binding) return null;
824
+
825
+ const args = call.arguments;
826
+ if (args.length === 0 || args[0].type !== 'ObjectExpression') {
827
+ return 'dynamic';
828
+ }
829
+
830
+ return { binding, configNode: args[0] };
831
+ }
@@ -173,7 +173,7 @@ export function processLocalFont(config: LocalFontConfig, importerPath: string):
173
173
  *
174
174
  * Returns null if the call cannot be statically analyzed.
175
175
  *
176
- * Uses acorn AST parsing for robust handling of comments, trailing commas,
176
+ * Uses OXC (via vite.parseAst) AST parsing for robust handling of comments, trailing commas,
177
177
  * and multi-line configs.
178
178
  */
179
179
  export function extractLocalFontConfig(callSource: string): LocalFontConfig | null {