@timber-js/app 0.1.1 → 0.1.3

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 (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Acorn-based AST utilities for extracting static font configs from source code.
3
+ *
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.
8
+ *
9
+ * Design doc: 24-fonts.md §"Step 1: Static Analysis"
10
+ */
11
+
12
+ import { parse } from 'acorn';
13
+ import type { GoogleFontConfig } from './types.js';
14
+ import type { LocalFontConfig, LocalFontSrc } from './types.js';
15
+
16
+ // ── AST node types (minimal subset of estree) ────────────────────────────────
17
+
18
+ interface AstNode {
19
+ type: string;
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ interface LiteralNode extends AstNode {
24
+ type: 'Literal';
25
+ value: string | number | boolean | null;
26
+ }
27
+
28
+ interface ArrayExpressionNode extends AstNode {
29
+ type: 'ArrayExpression';
30
+ elements: AstNode[];
31
+ }
32
+
33
+ interface ObjectExpressionNode extends AstNode {
34
+ type: 'ObjectExpression';
35
+ properties: PropertyNode[];
36
+ }
37
+
38
+ interface PropertyNode extends AstNode {
39
+ type: 'Property';
40
+ key: AstNode & { name?: string; value?: string | number };
41
+ value: AstNode;
42
+ }
43
+
44
+ interface IdentifierNode extends AstNode {
45
+ type: 'Identifier';
46
+ name: string;
47
+ }
48
+
49
+ // ── Core AST extraction helpers ──────────────────────────────────────────────
50
+
51
+ /**
52
+ * Parse a JavaScript expression string into an AST node.
53
+ *
54
+ * Wraps the expression in parens so acorn treats it as an expression statement.
55
+ * Returns null if parsing fails.
56
+ */
57
+ function parseExpression(source: string): AstNode | null {
58
+ try {
59
+ const ast = parse(`(${source})`, {
60
+ ecmaVersion: 'latest',
61
+ sourceType: 'module',
62
+ }) as unknown as { body: Array<{ expression: AstNode }> };
63
+ return ast.body[0]?.expression ?? null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Extract a static string value from an AST node.
71
+ * Returns undefined if the node is not a string literal.
72
+ */
73
+ function extractString(node: AstNode): string | undefined {
74
+ if (node.type === 'Literal' && typeof (node as LiteralNode).value === 'string') {
75
+ return (node as LiteralNode).value as string;
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Extract a static boolean value from an AST node.
82
+ * Returns undefined if the node is not a boolean literal.
83
+ */
84
+ function extractBoolean(node: AstNode): boolean | undefined {
85
+ if (node.type === 'Literal' && typeof (node as LiteralNode).value === 'boolean') {
86
+ return (node as LiteralNode).value as boolean;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ /**
92
+ * Extract a string array from an AST ArrayExpression node.
93
+ * Returns undefined if any element is not a string literal.
94
+ */
95
+ function extractStringArray(node: AstNode): string[] | undefined {
96
+ if (node.type !== 'ArrayExpression') return undefined;
97
+ const arr = node as ArrayExpressionNode;
98
+ const result: string[] = [];
99
+ for (const elem of arr.elements) {
100
+ const s = extractString(elem);
101
+ if (s === undefined) return undefined;
102
+ result.push(s);
103
+ }
104
+ return result;
105
+ }
106
+
107
+ /**
108
+ * Get the key name from a Property node.
109
+ */
110
+ function getPropertyKey(prop: PropertyNode): string | undefined {
111
+ if (prop.key.type === 'Identifier') {
112
+ return (prop.key as IdentifierNode).name;
113
+ }
114
+ if (prop.key.type === 'Literal' && typeof prop.key.value === 'string') {
115
+ return prop.key.value;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * Build a map of property name → value node from an ObjectExpression.
122
+ */
123
+ function objectProperties(node: ObjectExpressionNode): Map<string, AstNode> {
124
+ const map = new Map<string, AstNode>();
125
+ for (const prop of node.properties) {
126
+ // Skip spread elements or computed keys
127
+ if (prop.type !== 'Property') continue;
128
+ const key = getPropertyKey(prop);
129
+ if (key) map.set(key, prop.value);
130
+ }
131
+ return map;
132
+ }
133
+
134
+ // ── Google font config extraction ────────────────────────────────────────────
135
+
136
+ /**
137
+ * Extract a GoogleFontConfig from the source text of a font function call's
138
+ * object argument.
139
+ *
140
+ * The `callSource` should be the text of the call's argument list including
141
+ * parens, e.g. `({ subsets: ['latin'], weight: '400' })`.
142
+ *
143
+ * Returns null if parsing or extraction fails.
144
+ */
145
+ export function extractFontConfigAst(callSource: string): GoogleFontConfig | null {
146
+ const node = parseExpression(callSource);
147
+ if (!node || node.type !== 'ObjectExpression') return null;
148
+
149
+ const props = objectProperties(node as ObjectExpressionNode);
150
+ const config: GoogleFontConfig = {};
151
+
152
+ // subsets: string[]
153
+ const subsetsNode = props.get('subsets');
154
+ if (subsetsNode) {
155
+ const arr = extractStringArray(subsetsNode);
156
+ if (arr) config.subsets = arr;
157
+ }
158
+
159
+ // weight: string | string[]
160
+ const weightNode = props.get('weight');
161
+ if (weightNode) {
162
+ const arr = extractStringArray(weightNode);
163
+ if (arr) {
164
+ config.weight = arr;
165
+ } else {
166
+ const s = extractString(weightNode);
167
+ if (s !== undefined) config.weight = s;
168
+ }
169
+ }
170
+
171
+ // display: string
172
+ const displayNode = props.get('display');
173
+ if (displayNode) {
174
+ const s = extractString(displayNode);
175
+ if (s !== undefined) config.display = s as GoogleFontConfig['display'];
176
+ }
177
+
178
+ // variable: string
179
+ const variableNode = props.get('variable');
180
+ if (variableNode) {
181
+ const s = extractString(variableNode);
182
+ if (s !== undefined) config.variable = s;
183
+ }
184
+
185
+ // style: string | string[]
186
+ const styleNode = props.get('style');
187
+ if (styleNode) {
188
+ const arr = extractStringArray(styleNode);
189
+ if (arr) {
190
+ config.style = arr;
191
+ } else {
192
+ const s = extractString(styleNode);
193
+ if (s !== undefined) config.style = s;
194
+ }
195
+ }
196
+
197
+ // preload: boolean
198
+ const preloadNode = props.get('preload');
199
+ if (preloadNode) {
200
+ const b = extractBoolean(preloadNode);
201
+ if (b !== undefined) config.preload = b;
202
+ }
203
+
204
+ return config;
205
+ }
206
+
207
+ // ── Local font config extraction ─────────────────────────────────────────────
208
+
209
+ /**
210
+ * Extract a single LocalFontSrc entry from an ObjectExpression AST node.
211
+ */
212
+ function extractLocalFontSrcEntry(node: AstNode): LocalFontSrc | null {
213
+ if (node.type !== 'ObjectExpression') return null;
214
+ const props = objectProperties(node as ObjectExpressionNode);
215
+
216
+ const pathNode = props.get('path');
217
+ if (!pathNode) return null;
218
+ const path = extractString(pathNode);
219
+ if (path === undefined) return null;
220
+
221
+ const entry: LocalFontSrc = { path };
222
+
223
+ const weightNode = props.get('weight');
224
+ if (weightNode) {
225
+ const w = extractString(weightNode);
226
+ if (w !== undefined) entry.weight = w;
227
+ }
228
+
229
+ const styleNode = props.get('style');
230
+ if (styleNode) {
231
+ const s = extractString(styleNode);
232
+ if (s !== undefined) entry.style = s;
233
+ }
234
+
235
+ return entry;
236
+ }
237
+
238
+ /**
239
+ * Extract a LocalFontConfig from the source text of a localFont() call's
240
+ * object argument.
241
+ *
242
+ * Returns null if parsing or extraction fails.
243
+ */
244
+ export function extractLocalFontConfigAst(callSource: string): LocalFontConfig | null {
245
+ const node = parseExpression(callSource);
246
+ if (!node || node.type !== 'ObjectExpression') return null;
247
+
248
+ const props = objectProperties(node as ObjectExpressionNode);
249
+
250
+ // display: string
251
+ const displayNode = props.get('display');
252
+ const display = displayNode
253
+ ? (extractString(displayNode) as LocalFontConfig['display'])
254
+ : undefined;
255
+
256
+ // variable: string
257
+ const variableNode = props.get('variable');
258
+ const variable = variableNode ? extractString(variableNode) : undefined;
259
+
260
+ // family: string
261
+ const familyNode = props.get('family');
262
+ const family = familyNode ? extractString(familyNode) : undefined;
263
+
264
+ // src: string | LocalFontSrc[]
265
+ const srcNode = props.get('src');
266
+ if (!srcNode) return null;
267
+
268
+ // String form: src: './fonts/MyFont.woff2'
269
+ const srcString = extractString(srcNode);
270
+ if (srcString !== undefined) {
271
+ return { src: srcString, display, variable, family };
272
+ }
273
+
274
+ // Array form: src: [{ path: '...', weight: '...' }, ...]
275
+ if (srcNode.type === 'ArrayExpression') {
276
+ const arr = srcNode as ArrayExpressionNode;
277
+ const entries: LocalFontSrc[] = [];
278
+ for (const elem of arr.elements) {
279
+ const entry = extractLocalFontSrcEntry(elem);
280
+ if (!entry) return null;
281
+ entries.push(entry);
282
+ }
283
+ if (entries.length === 0) return null;
284
+ return { src: entries, display, variable, family };
285
+ }
286
+
287
+ return null;
288
+ }
289
+
290
+ // ── Dynamic call detection ───────────────────────────────────────────────────
291
+
292
+ /**
293
+ * Detect if source code contains dynamic/computed font function calls
294
+ * that cannot be statically analyzed.
295
+ *
296
+ * Uses acorn to parse the full source and inspect CallExpression nodes.
297
+ * Returns the offending expression string if found, null if all calls are static.
298
+ */
299
+ export function detectDynamicFontCallAst(source: string, importedNames: string[]): string | null {
300
+ if (importedNames.length === 0) return null;
301
+
302
+ let ast;
303
+ try {
304
+ ast = parse(source, {
305
+ ecmaVersion: 'latest',
306
+ sourceType: 'module',
307
+ }) as unknown as { body: AstNode[] };
308
+ } catch {
309
+ // If we can't parse the source at all, fall back to no detection
310
+ return null;
311
+ }
312
+
313
+ const nameSet = new Set(importedNames);
314
+ const result = walkForDynamicCalls(ast as unknown as AstNode, nameSet, source);
315
+ return result;
316
+ }
317
+
318
+ /**
319
+ * Recursively walk the AST looking for CallExpression nodes where
320
+ * the callee is one of the imported font names and the first argument
321
+ * is not an ObjectExpression (i.e. it's dynamic).
322
+ */
323
+ function walkForDynamicCalls(node: AstNode, names: Set<string>, source: string): string | null {
324
+ if (!node || typeof node !== 'object') return null;
325
+
326
+ if (node.type === 'CallExpression') {
327
+ const callee = node.callee as AstNode;
328
+ if (callee.type === 'Identifier' && names.has((callee as IdentifierNode).name)) {
329
+ const args = node.arguments as AstNode[];
330
+ if (args.length > 0 && args[0].type !== 'ObjectExpression') {
331
+ // Extract the argument source text
332
+ const argStart = (args[0] as AstNode & { start: number }).start;
333
+ const argEnd = (args[0] as AstNode & { end: number }).end;
334
+ const argText = source.slice(argStart, argEnd);
335
+ const calleeName = (callee as IdentifierNode).name;
336
+ return `${calleeName}(${argText})`;
337
+ }
338
+ }
339
+ }
340
+
341
+ // Walk all child properties
342
+ for (const key of Object.keys(node)) {
343
+ if (key === 'type') continue;
344
+ const child = node[key];
345
+ if (Array.isArray(child)) {
346
+ for (const item of child) {
347
+ if (item && typeof item === 'object' && (item as AstNode).type) {
348
+ const result = walkForDynamicCalls(item as AstNode, names, source);
349
+ if (result) return result;
350
+ }
351
+ }
352
+ } else if (child && typeof child === 'object' && (child as AstNode).type) {
353
+ const result = walkForDynamicCalls(child as AstNode, names, source);
354
+ if (result) return result;
355
+ }
356
+ }
357
+
358
+ return null;
359
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @font-face CSS generation.
3
+ *
4
+ * Generates CSS strings from font face descriptors. Used by both
5
+ * Google and local font providers.
6
+ *
7
+ * Design doc: 24-fonts.md §"Step 3: @font-face Generation"
8
+ */
9
+
10
+ import type { FontFaceDescriptor } from './types.js';
11
+
12
+ /**
13
+ * Generate a single `@font-face` CSS rule from a descriptor.
14
+ */
15
+ export function generateFontFace(desc: FontFaceDescriptor): string {
16
+ const lines: string[] = [];
17
+ lines.push('@font-face {');
18
+ lines.push(` font-family: '${desc.family}';`);
19
+ lines.push(` src: ${desc.src};`);
20
+ if (desc.weight) lines.push(` font-weight: ${desc.weight};`);
21
+ if (desc.style) lines.push(` font-style: ${desc.style};`);
22
+ if (desc.display) lines.push(` font-display: ${desc.display};`);
23
+ if (desc.unicodeRange) lines.push(` unicode-range: ${desc.unicodeRange};`);
24
+ lines.push('}');
25
+ return lines.join('\n');
26
+ }
27
+
28
+ /**
29
+ * Generate multiple `@font-face` rules from an array of descriptors.
30
+ */
31
+ export function generateFontFaces(descriptors: FontFaceDescriptor[]): string {
32
+ return descriptors.map(generateFontFace).join('\n\n');
33
+ }
34
+
35
+ /**
36
+ * Generate a scoped CSS class that sets a CSS custom property for the font.
37
+ *
38
+ * Example output:
39
+ * ```css
40
+ * .timber-font-inter {
41
+ * --font-sans: 'Inter', 'Inter Fallback', system-ui, sans-serif;
42
+ * }
43
+ * ```
44
+ */
45
+ export function generateVariableClass(
46
+ className: string,
47
+ variable: string,
48
+ fontFamily: string
49
+ ): string {
50
+ return `.${className} {\n ${variable}: ${fontFamily};\n}`;
51
+ }
52
+
53
+ /**
54
+ * Generate a scoped CSS class that applies font-family directly.
55
+ *
56
+ * Used when no `variable` is specified — the className applies
57
+ * the font-family inline instead of through a CSS custom property.
58
+ *
59
+ * Example output:
60
+ * ```css
61
+ * .timber-font-inter {
62
+ * font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif;
63
+ * }
64
+ * ```
65
+ */
66
+ export function generateFontFamilyClass(className: string, fontFamily: string): string {
67
+ return `.${className} {\n font-family: ${fontFamily};\n}`;
68
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Size-adjusted fallback font generation.
3
+ *
4
+ * Generates `@font-face` declarations for fallback fonts with
5
+ * `size-adjust`, `ascent-override`, `descent-override`, and
6
+ * `line-gap-override` to match custom font metrics and eliminate CLS.
7
+ *
8
+ * Design doc: 24-fonts.md §"Step 4: Size-Adjusted Fallbacks"
9
+ */
10
+
11
+ import type { FontFaceDescriptor } from './types.js';
12
+
13
+ /**
14
+ * Font metrics for size-adjusted fallback calculation.
15
+ *
16
+ * Values are percentages used in CSS override descriptors.
17
+ */
18
+ interface FallbackMetrics {
19
+ /** The local() system font to use as base. */
20
+ fallbackFont: string;
21
+ /** size-adjust percentage (e.g. 107.64). */
22
+ sizeAdjust: number;
23
+ /** ascent-override percentage (e.g. 90.49). */
24
+ ascentOverride: number;
25
+ /** descent-override percentage (e.g. 22.48). */
26
+ descentOverride: number;
27
+ /** line-gap-override percentage (e.g. 0). */
28
+ lineGapOverride: number;
29
+ }
30
+
31
+ /**
32
+ * Lookup table for commonly used Google Fonts.
33
+ *
34
+ * Metrics sourced from fontaine / @capsizecss/metrics.
35
+ * Keyed by lowercase font family name.
36
+ */
37
+ const FALLBACK_METRICS: Record<string, FallbackMetrics> = {
38
+ 'inter': {
39
+ fallbackFont: 'Arial',
40
+ sizeAdjust: 107.64,
41
+ ascentOverride: 90.49,
42
+ descentOverride: 22.48,
43
+ lineGapOverride: 0,
44
+ },
45
+ 'roboto': {
46
+ fallbackFont: 'Arial',
47
+ sizeAdjust: 100.3,
48
+ ascentOverride: 92.77,
49
+ descentOverride: 24.41,
50
+ lineGapOverride: 0,
51
+ },
52
+ 'open sans': {
53
+ fallbackFont: 'Arial',
54
+ sizeAdjust: 105.48,
55
+ ascentOverride: 101.03,
56
+ descentOverride: 27.47,
57
+ lineGapOverride: 0,
58
+ },
59
+ 'lato': {
60
+ fallbackFont: 'Arial',
61
+ sizeAdjust: 112.5,
62
+ ascentOverride: 100.22,
63
+ descentOverride: 21.16,
64
+ lineGapOverride: 0,
65
+ },
66
+ 'montserrat': {
67
+ fallbackFont: 'Arial',
68
+ sizeAdjust: 112.17,
69
+ ascentOverride: 85.13,
70
+ descentOverride: 22.07,
71
+ lineGapOverride: 0,
72
+ },
73
+ 'poppins': {
74
+ fallbackFont: 'Arial',
75
+ sizeAdjust: 112.76,
76
+ ascentOverride: 96.31,
77
+ descentOverride: 32.1,
78
+ lineGapOverride: 0,
79
+ },
80
+ 'roboto mono': {
81
+ fallbackFont: 'Courier New',
82
+ sizeAdjust: 109.29,
83
+ ascentOverride: 87.79,
84
+ descentOverride: 23.1,
85
+ lineGapOverride: 0,
86
+ },
87
+ 'jetbrains mono': {
88
+ fallbackFont: 'Courier New',
89
+ sizeAdjust: 112.7,
90
+ ascentOverride: 89.53,
91
+ descentOverride: 24.21,
92
+ lineGapOverride: 0,
93
+ },
94
+ 'source code pro': {
95
+ fallbackFont: 'Courier New',
96
+ sizeAdjust: 106.13,
97
+ ascentOverride: 93.47,
98
+ descentOverride: 26.01,
99
+ lineGapOverride: 0,
100
+ },
101
+ 'fira code': {
102
+ fallbackFont: 'Courier New',
103
+ sizeAdjust: 112.96,
104
+ ascentOverride: 86.87,
105
+ descentOverride: 26.34,
106
+ lineGapOverride: 0,
107
+ },
108
+ 'nunito': {
109
+ fallbackFont: 'Arial',
110
+ sizeAdjust: 103.62,
111
+ ascentOverride: 99.45,
112
+ descentOverride: 34.8,
113
+ lineGapOverride: 0,
114
+ },
115
+ 'playfair display': {
116
+ fallbackFont: 'Georgia',
117
+ sizeAdjust: 110.72,
118
+ ascentOverride: 84.44,
119
+ descentOverride: 23.56,
120
+ lineGapOverride: 0,
121
+ },
122
+ 'merriweather': {
123
+ fallbackFont: 'Georgia',
124
+ sizeAdjust: 107.66,
125
+ ascentOverride: 91.93,
126
+ descentOverride: 27.6,
127
+ lineGapOverride: 0,
128
+ },
129
+ 'raleway': {
130
+ fallbackFont: 'Arial',
131
+ sizeAdjust: 107.74,
132
+ ascentOverride: 94.19,
133
+ descentOverride: 26.76,
134
+ lineGapOverride: 0,
135
+ },
136
+ };
137
+
138
+ /**
139
+ * Known serif font families (lowercase).
140
+ * Used for generic family detection when the name doesn't contain "serif".
141
+ */
142
+ const SERIF_FAMILIES = new Set([
143
+ 'playfair display',
144
+ 'merriweather',
145
+ 'lora',
146
+ 'georgia',
147
+ 'garamond',
148
+ 'eb garamond',
149
+ 'crimson text',
150
+ 'libre baskerville',
151
+ 'source serif pro',
152
+ 'source serif 4',
153
+ 'dm serif display',
154
+ 'dm serif text',
155
+ 'noto serif',
156
+ 'pt serif',
157
+ 'bitter',
158
+ 'domine',
159
+ 'cormorant',
160
+ 'cormorant garamond',
161
+ ]);
162
+
163
+ export function getGenericFamily(family: string): string {
164
+ const lc = family.toLowerCase();
165
+ if (lc.includes('mono') || lc.includes('code')) return 'monospace';
166
+ if ((lc.includes('serif') && !lc.includes('sans')) || SERIF_FAMILIES.has(lc)) {
167
+ return 'serif';
168
+ }
169
+ return 'sans-serif';
170
+ }
171
+
172
+ /**
173
+ * Generate a size-adjusted fallback @font-face for a given font family.
174
+ *
175
+ * Returns null if no metrics are available (unknown font — no fallback generated).
176
+ */
177
+ export function generateFallbackFontFace(family: string): FontFaceDescriptor | null {
178
+ const metrics = FALLBACK_METRICS[family.toLowerCase()];
179
+ if (!metrics) return null;
180
+
181
+ const fallbackFamily = `${family} Fallback`;
182
+
183
+ return {
184
+ family: fallbackFamily,
185
+ src: `local('${metrics.fallbackFont}')`,
186
+ // Encode the metrics into a CSS descriptor string.
187
+ // We abuse the 'style' field to carry the override properties
188
+ // since FontFaceDescriptor doesn't have dedicated fields.
189
+ // The generateFallbackCss function handles this specially.
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Generate the full CSS for a size-adjusted fallback font.
195
+ *
196
+ * This produces a complete @font-face block with override descriptors
197
+ * that FontFaceDescriptor doesn't natively support.
198
+ */
199
+ export function generateFallbackCss(family: string): string | null {
200
+ const metrics = FALLBACK_METRICS[family.toLowerCase()];
201
+ if (!metrics) return null;
202
+
203
+ const fallbackFamily = `${family} Fallback`;
204
+
205
+ const lines = [
206
+ '@font-face {',
207
+ ` font-family: '${fallbackFamily}';`,
208
+ ` src: local('${metrics.fallbackFont}');`,
209
+ ` size-adjust: ${metrics.sizeAdjust}%;`,
210
+ ` ascent-override: ${metrics.ascentOverride}%;`,
211
+ ` descent-override: ${metrics.descentOverride}%;`,
212
+ ` line-gap-override: ${metrics.lineGapOverride}%;`,
213
+ '}',
214
+ ];
215
+
216
+ return lines.join('\n');
217
+ }
218
+
219
+ /**
220
+ * Check whether fallback metrics are available for a font family.
221
+ */
222
+ export function hasFallbackMetrics(family: string): boolean {
223
+ return family.toLowerCase() in FALLBACK_METRICS;
224
+ }
225
+
226
+ /**
227
+ * Build the full font stack string for a font, including its
228
+ * size-adjusted fallback and a generic family.
229
+ *
230
+ * Example: `'Inter', 'Inter Fallback', system-ui, sans-serif`
231
+ */
232
+ export function buildFontStack(family: string): string {
233
+ const generic = getGenericFamily(family);
234
+ const hasMetrics = hasFallbackMetrics(family);
235
+
236
+ const parts = [`'${family}'`];
237
+ if (hasMetrics) parts.push(`'${family} Fallback'`);
238
+
239
+ // Add system-ui for sans-serif fonts, ui-monospace for mono
240
+ if (generic === 'monospace') {
241
+ parts.push('ui-monospace');
242
+ } else if (generic === 'sans-serif') {
243
+ parts.push('system-ui');
244
+ }
245
+ parts.push(generic);
246
+
247
+ return parts.join(', ');
248
+ }