@timber-js/app 0.1.0 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +43 -58
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Google Fonts download, caching, and dev CDN fallback.
3
+ *
4
+ * At build time (production only):
5
+ * 1. Queries Google Fonts CSS API v2 for font metadata and file URLs
6
+ * 2. Downloads woff2 font files
7
+ * 3. Caches them in node_modules/.cache/timber-fonts/
8
+ * 4. Content-hashes filenames for cache busting
9
+ * 5. Emits font files into the build output via generateBundle
10
+ *
11
+ * In dev mode:
12
+ * - Generates @font-face rules pointing to Google Fonts CDN
13
+ * - No downloads, no caching
14
+ *
15
+ * Design doc: 24-fonts.md §"Step 2: Font Download & Subsetting"
16
+ */
17
+
18
+ import { createHash } from 'node:crypto';
19
+ import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
20
+ import { join } from 'node:path';
21
+ import type { ExtractedFont, FontFaceDescriptor } from './types.js';
22
+
23
+ /** Google Fonts CSS API v2 base URL. */
24
+ const GOOGLE_FONTS_API = 'https://fonts.googleapis.com/css2';
25
+
26
+ /**
27
+ * User-Agent string that requests woff2 format from Google Fonts API.
28
+ * Google serves different formats based on user-agent.
29
+ */
30
+ const WOFF2_USER_AGENT =
31
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
32
+
33
+ /** Default cache directory for downloaded font files. */
34
+ const DEFAULT_CACHE_DIR = 'node_modules/.cache/timber-fonts';
35
+
36
+ /** A parsed @font-face block from the Google Fonts CSS response. */
37
+ export interface GoogleFontFace {
38
+ family: string;
39
+ weight: string;
40
+ style: string;
41
+ /** The remote URL to the font file. */
42
+ url: string;
43
+ /** The unicode-range from the CSS (e.g. `U+0000-00FF, U+0131`). */
44
+ unicodeRange: string;
45
+ /** Subset label extracted from the CSS comment (e.g. `latin`, `cyrillic`). */
46
+ subset: string;
47
+ }
48
+
49
+ /** A downloaded and cached font file, ready for build output. */
50
+ export interface CachedFont {
51
+ /** The original parsed face data. */
52
+ face: GoogleFontFace;
53
+ /** Content-hashed filename (e.g. `inter-latin-400-normal-abc123.woff2`). */
54
+ hashedFilename: string;
55
+ /** Absolute path to the cached file. */
56
+ cachePath: string;
57
+ /** Raw font file bytes. */
58
+ data: Buffer;
59
+ }
60
+
61
+ /**
62
+ * Build the Google Fonts CSS API v2 URL for a given font config.
63
+ *
64
+ * Example output:
65
+ * https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap&subset=latin
66
+ */
67
+ export function buildGoogleFontsUrl(font: ExtractedFont): string {
68
+ const family = font.family.replace(/\s+/g, '+');
69
+
70
+ // Build axis spec: wght for weights, ital for italic styles
71
+ const hasItalic = font.styles.includes('italic');
72
+ const weights = font.weights.map(Number).sort((a, b) => a - b);
73
+
74
+ let axisSpec: string;
75
+ if (hasItalic && weights.length > 0) {
76
+ // ital,wght@0,400;0,700;1,400;1,700
77
+ const pairs: string[] = [];
78
+ for (const ital of [0, 1]) {
79
+ if (ital === 1 && !hasItalic) continue;
80
+ if (ital === 0 && font.styles.length === 1 && font.styles[0] === 'italic') continue;
81
+ for (const w of weights) {
82
+ pairs.push(`${ital},${w}`);
83
+ }
84
+ }
85
+ axisSpec = `ital,wght@${pairs.join(';')}`;
86
+ } else if (weights.length > 0) {
87
+ axisSpec = `wght@${weights.join(';')}`;
88
+ } else {
89
+ axisSpec = '';
90
+ }
91
+
92
+ const familyParam = axisSpec ? `${family}:${axisSpec}` : family;
93
+
94
+ // Build URL manually — URLSearchParams encodes +, :, @, ; which
95
+ // the Google Fonts CSS API v2 requires as literal characters.
96
+ const parts = [`family=${familyParam}`];
97
+ if (font.display) parts.push(`display=${font.display}`);
98
+
99
+ return `${GOOGLE_FONTS_API}?${parts.join('&')}`;
100
+ }
101
+
102
+ /**
103
+ * Fetch the CSS from Google Fonts API and parse out @font-face blocks.
104
+ *
105
+ * The API returns CSS with subset comments like:
106
+ * ```
107
+ * /* latin * /
108
+ * @font-face { ... }
109
+ * ```
110
+ *
111
+ * We parse each block to extract the font URL, unicode-range, and subset label.
112
+ */
113
+ export async function fetchGoogleFontsCss(url: string): Promise<GoogleFontFace[]> {
114
+ const response = await fetch(url, {
115
+ headers: { 'User-Agent': WOFF2_USER_AGENT },
116
+ });
117
+
118
+ if (!response.ok) {
119
+ throw new Error(
120
+ `Google Fonts API returned ${response.status}: ${response.statusText} for ${url}`
121
+ );
122
+ }
123
+
124
+ const css = await response.text();
125
+ return parseGoogleFontsCss(css);
126
+ }
127
+
128
+ /**
129
+ * Parse the CSS response from Google Fonts API into structured font face data.
130
+ *
131
+ * Handles the Google Fonts CSS format with subset comments and @font-face blocks.
132
+ */
133
+ export function parseGoogleFontsCss(css: string): GoogleFontFace[] {
134
+ const faces: GoogleFontFace[] = [];
135
+
136
+ // Match subset comments followed by @font-face blocks
137
+ const blockPattern = /\/\*\s*([a-z0-9-]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/g;
138
+
139
+ let match;
140
+ while ((match = blockPattern.exec(css)) !== null) {
141
+ const subset = match[1];
142
+ const block = match[2];
143
+
144
+ const familyMatch = block.match(/font-family:\s*'([^']+)'/);
145
+ const weightMatch = block.match(/font-weight:\s*(\d+)/);
146
+ const styleMatch = block.match(/font-style:\s*(\w+)/);
147
+ const urlMatch = block.match(/url\(([^)]+)\)\s*format\('woff2'\)/);
148
+ const rangeMatch = block.match(/unicode-range:\s*([^;]+)/);
149
+
150
+ if (familyMatch && urlMatch) {
151
+ faces.push({
152
+ family: familyMatch[1],
153
+ weight: weightMatch?.[1] ?? '400',
154
+ style: styleMatch?.[1] ?? 'normal',
155
+ url: urlMatch[1],
156
+ unicodeRange: rangeMatch?.[1]?.trim() ?? '',
157
+ subset,
158
+ });
159
+ }
160
+ }
161
+
162
+ return faces;
163
+ }
164
+
165
+ /**
166
+ * Filter parsed font faces to only the requested subsets.
167
+ */
168
+ export function filterBySubsets(faces: GoogleFontFace[], subsets: string[]): GoogleFontFace[] {
169
+ if (subsets.length === 0) return faces;
170
+ const subsetSet = new Set(subsets);
171
+ return faces.filter((f) => subsetSet.has(f.subset));
172
+ }
173
+
174
+ /**
175
+ * Generate a content hash for font data.
176
+ * Returns the first 8 hex chars of the SHA-256 hash.
177
+ */
178
+ export function contentHash(data: Buffer): string {
179
+ return createHash('sha256').update(data).digest('hex').slice(0, 8);
180
+ }
181
+
182
+ /**
183
+ * Generate a content-hashed filename for a font face.
184
+ *
185
+ * Format: `<family>-<subset>-<weight>-<style>-<hash>.woff2`
186
+ * Example: `inter-latin-400-normal-abc12345.woff2`
187
+ */
188
+ export function hashedFontFilename(face: GoogleFontFace, data: Buffer): string {
189
+ const slug = face.family.toLowerCase().replace(/\s+/g, '-');
190
+ const hash = contentHash(data);
191
+ return `${slug}-${face.subset}-${face.weight}-${face.style}-${hash}.woff2`;
192
+ }
193
+
194
+ /**
195
+ * Build the cache key for a font face.
196
+ * Used to check if a font has already been downloaded.
197
+ */
198
+ export function cacheKey(face: GoogleFontFace): string {
199
+ const slug = face.family.toLowerCase().replace(/\s+/g, '-');
200
+ return `${slug}-${face.subset}-${face.weight}-${face.style}`;
201
+ }
202
+
203
+ /**
204
+ * Download a single font file from its URL.
205
+ */
206
+ export async function downloadFontFile(url: string): Promise<Buffer> {
207
+ const response = await fetch(url);
208
+ if (!response.ok) {
209
+ throw new Error(`Failed to download font from ${url}: ${response.status}`);
210
+ }
211
+ return Buffer.from(await response.arrayBuffer());
212
+ }
213
+
214
+ /**
215
+ * Download and cache all font files for a set of extracted Google fonts.
216
+ *
217
+ * - Checks the local cache first (node_modules/.cache/timber-fonts/)
218
+ * - Downloads missing fonts from Google Fonts CDN
219
+ * - Writes downloaded fonts to cache
220
+ * - Returns CachedFont entries with content-hashed filenames
221
+ */
222
+ export async function downloadAndCacheFonts(
223
+ fonts: ExtractedFont[],
224
+ projectRoot: string
225
+ ): Promise<CachedFont[]> {
226
+ const cacheDir = join(projectRoot, DEFAULT_CACHE_DIR);
227
+ await mkdir(cacheDir, { recursive: true });
228
+
229
+ const googleFonts = fonts.filter((f) => f.provider === 'google');
230
+ const cached: CachedFont[] = [];
231
+
232
+ for (const font of googleFonts) {
233
+ const apiUrl = buildGoogleFontsUrl(font);
234
+ const faces = await fetchGoogleFontsCss(apiUrl);
235
+ const filtered = filterBySubsets(faces, font.subsets);
236
+
237
+ for (const face of filtered) {
238
+ const key = cacheKey(face);
239
+ const metaPath = join(cacheDir, `${key}.meta.json`);
240
+ const dataPath = join(cacheDir, `${key}.woff2`);
241
+
242
+ let data: Buffer;
243
+ let filename: string;
244
+
245
+ // Check cache
246
+ const cacheHit = await isCacheHit(metaPath, dataPath);
247
+ if (cacheHit) {
248
+ data = await readFile(dataPath);
249
+ const meta = JSON.parse(await readFile(metaPath, 'utf-8'));
250
+ filename = meta.hashedFilename;
251
+ } else {
252
+ // Download and cache
253
+ data = await downloadFontFile(face.url);
254
+ filename = hashedFontFilename(face, data);
255
+
256
+ await writeFile(dataPath, data);
257
+ await writeFile(metaPath, JSON.stringify({ hashedFilename: filename, url: face.url }));
258
+ }
259
+
260
+ cached.push({ face, hashedFilename: filename, cachePath: dataPath, data });
261
+ }
262
+ }
263
+
264
+ return cached;
265
+ }
266
+
267
+ /**
268
+ * Check if both the meta and data files exist in the cache.
269
+ */
270
+ async function isCacheHit(metaPath: string, dataPath: string): Promise<boolean> {
271
+ try {
272
+ await stat(metaPath);
273
+ await stat(dataPath);
274
+ return true;
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Generate @font-face descriptors for cached (production) Google Fonts.
282
+ *
283
+ * Each CachedFont gets a FontFaceDescriptor pointing to the
284
+ * content-hashed URL under `/_timber/fonts/`.
285
+ */
286
+ export function generateProductionFontFaces(
287
+ cachedFonts: CachedFont[],
288
+ display: string
289
+ ): FontFaceDescriptor[] {
290
+ return cachedFonts.map((cf) => ({
291
+ family: cf.face.family,
292
+ src: `url('/_timber/fonts/${cf.hashedFilename}') format('woff2')`,
293
+ weight: cf.face.weight,
294
+ style: cf.face.style,
295
+ display,
296
+ unicodeRange: cf.face.unicodeRange,
297
+ }));
298
+ }
299
+
300
+ /**
301
+ * Generate @font-face descriptors for dev mode (CDN-pointing).
302
+ *
303
+ * In dev mode, we query the Google Fonts API but use the CDN URLs
304
+ * directly instead of downloading. This avoids the download/cache
305
+ * step during `vite dev`.
306
+ */
307
+ export function generateDevFontFaces(
308
+ faces: GoogleFontFace[],
309
+ display: string
310
+ ): FontFaceDescriptor[] {
311
+ return faces.map((face) => ({
312
+ family: face.family,
313
+ src: `url('${face.url}') format('woff2')`,
314
+ weight: face.weight,
315
+ style: face.style,
316
+ display,
317
+ unicodeRange: face.unicodeRange,
318
+ }));
319
+ }
320
+
321
+ /**
322
+ * Resolve dev-mode font faces for an extracted font.
323
+ *
324
+ * Fetches the CSS from Google Fonts API and returns FontFaceDescriptors
325
+ * pointing to CDN URLs. No files are downloaded.
326
+ */
327
+ export async function resolveDevFontFaces(font: ExtractedFont): Promise<FontFaceDescriptor[]> {
328
+ const apiUrl = buildGoogleFontsUrl(font);
329
+ const faces = await fetchGoogleFontsCss(apiUrl);
330
+ const filtered = filterBySubsets(faces, font.subsets);
331
+ return generateDevFontFaces(filtered, font.display);
332
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Local font processing for the timber-fonts pipeline.
3
+ *
4
+ * Handles:
5
+ * - Resolving font file paths relative to the importing module
6
+ * - Normalizing single-string `src` to array form
7
+ * - Generating @font-face descriptors for each weight/style variant
8
+ *
9
+ * Does NOT handle:
10
+ * - Font format conversion (serve whatever the user provides)
11
+ * - Font subsetting (user's responsibility for local fonts)
12
+ *
13
+ * Design doc: 24-fonts.md §"Local Fonts"
14
+ */
15
+
16
+ import { resolve, dirname, extname } from 'node:path';
17
+ import type { LocalFontConfig, LocalFontSrc, FontFaceDescriptor, ExtractedFont } from './types.js';
18
+ import { buildFontStack } from './fallbacks.js';
19
+ import { extractLocalFontConfigAst } from './ast.js';
20
+
21
+ /**
22
+ * Infer the font format from a file extension.
23
+ *
24
+ * Returns the CSS `format()` value for `@font-face` src descriptors.
25
+ */
26
+ export function inferFontFormat(filePath: string): string {
27
+ const ext = extname(filePath).toLowerCase();
28
+ switch (ext) {
29
+ case '.woff2':
30
+ return 'woff2';
31
+ case '.woff':
32
+ return 'woff';
33
+ case '.ttf':
34
+ return 'truetype';
35
+ case '.otf':
36
+ return 'opentype';
37
+ case '.eot':
38
+ return 'embedded-opentype';
39
+ default:
40
+ return 'woff2';
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Normalize `src` config to an array of LocalFontSrc entries.
46
+ *
47
+ * When `src` is a single string, it becomes a single entry with
48
+ * default weight '400' and style 'normal'.
49
+ */
50
+ export function normalizeSrc(src: string | LocalFontSrc[]): LocalFontSrc[] {
51
+ if (typeof src === 'string') {
52
+ return [{ path: src, weight: '400', style: 'normal' }];
53
+ }
54
+ return src.map((entry) => ({
55
+ path: entry.path,
56
+ weight: entry.weight ?? '400',
57
+ style: entry.style ?? 'normal',
58
+ }));
59
+ }
60
+
61
+ /**
62
+ * Resolve font file paths relative to the importing module's directory.
63
+ *
64
+ * Takes the importer's file path and the normalized src entries,
65
+ * returns new entries with absolute resolved paths.
66
+ */
67
+ export function resolveLocalFontPaths(
68
+ importerPath: string,
69
+ sources: LocalFontSrc[]
70
+ ): LocalFontSrc[] {
71
+ const importerDir = dirname(importerPath);
72
+ return sources.map((entry) => ({
73
+ ...entry,
74
+ path: resolve(importerDir, entry.path),
75
+ }));
76
+ }
77
+
78
+ /**
79
+ * Generate a deterministic font family name from the file path
80
+ * when no explicit `family` is provided.
81
+ *
82
+ * Uses the filename without extension, e.g. "MyFont-Regular.woff2" → "MyFont-Regular".
83
+ * For multi-weight sources, uses the first file's name stem.
84
+ */
85
+ export function generateFamilyName(sources: LocalFontSrc[]): string {
86
+ if (sources.length === 0) return 'Local Font';
87
+ const firstPath = sources[0].path;
88
+ const basename = firstPath.split('/').pop() ?? firstPath;
89
+ // Remove extension and weight/style suffixes for a cleaner family name
90
+ const stem = basename.replace(/\.[^.]+$/, '');
91
+ // Strip common weight/style suffixes to get the family root
92
+ const family = stem.replace(
93
+ /[-_]?(Regular|Bold|Italic|Light|Medium|SemiBold|ExtraBold|Thin|Black|Heavy)$/i,
94
+ ''
95
+ );
96
+ return family || stem;
97
+ }
98
+
99
+ /**
100
+ * Generate @font-face descriptors for local font sources.
101
+ *
102
+ * Each source entry produces one @font-face rule. The `src` descriptor
103
+ * uses a `url()` pointing to the resolved file path with the inferred format.
104
+ */
105
+ export function generateLocalFontFaces(
106
+ family: string,
107
+ sources: LocalFontSrc[],
108
+ display: string
109
+ ): FontFaceDescriptor[] {
110
+ return sources.map((entry) => {
111
+ const format = inferFontFormat(entry.path);
112
+ return {
113
+ family,
114
+ src: `url('${entry.path}') format('${format}')`,
115
+ weight: entry.weight,
116
+ style: entry.style,
117
+ display,
118
+ };
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Build the className for a local font, following the same convention
124
+ * as Google fonts: `timber-font-<lowercase-hyphenated-family>`.
125
+ */
126
+ export function localFontClassName(family: string): string {
127
+ return `timber-font-${family.toLowerCase().replace(/\s+/g, '-')}`;
128
+ }
129
+
130
+ /**
131
+ * Process a local font config into an ExtractedFont.
132
+ *
133
+ * This is the main entry point called by the fonts plugin's transform hook
134
+ * when it encounters a `localFont()` call.
135
+ */
136
+ export function processLocalFont(config: LocalFontConfig, importerPath: string): ExtractedFont {
137
+ const sources = normalizeSrc(config.src);
138
+ const resolvedSources = resolveLocalFontPaths(importerPath, sources);
139
+ const family = config.family ?? generateFamilyName(sources);
140
+ const display = config.display ?? 'swap';
141
+ const className = localFontClassName(family);
142
+ const fontStack = buildFontStack(family);
143
+
144
+ const weights = [...new Set(resolvedSources.map((s) => s.weight ?? '400'))];
145
+ const styles = [...new Set(resolvedSources.map((s) => s.style ?? 'normal'))];
146
+
147
+ return {
148
+ id: `local-${family.toLowerCase().replace(/\s+/g, '-')}-${weights.join(',')}-${styles.join(',')}`,
149
+ family,
150
+ provider: 'local',
151
+ weights,
152
+ styles,
153
+ subsets: [],
154
+ display,
155
+ variable: config.variable,
156
+ localSources: resolvedSources,
157
+ importer: importerPath,
158
+ className,
159
+ fontFamily: fontStack,
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Extract a LocalFontConfig from a static `localFont()` call source.
165
+ *
166
+ * Parses patterns like:
167
+ * localFont({ src: './fonts/MyFont.woff2', display: 'swap', variable: '--font-custom' })
168
+ * localFont({ src: [{ path: './a.woff2', weight: '400' }, { path: './b.woff2', weight: '700' }] })
169
+ *
170
+ * Returns null if the call cannot be statically analyzed.
171
+ *
172
+ * Uses acorn AST parsing for robust handling of comments, trailing commas,
173
+ * and multi-line configs.
174
+ */
175
+ export function extractLocalFontConfig(callSource: string): LocalFontConfig | null {
176
+ return extractLocalFontConfigAst(callSource);
177
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Shared types for the timber-fonts pipeline.
3
+ *
4
+ * Used by the fonts plugin, CSS generator, fallback generator,
5
+ * and the Google/local font providers.
6
+ *
7
+ * Design doc: 24-fonts.md
8
+ */
9
+
10
+ /** Configuration passed to a Google font function (e.g. `Inter({ ... })`). */
11
+ export interface GoogleFontConfig {
12
+ weight?: string | string[];
13
+ subsets?: string[];
14
+ display?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional';
15
+ variable?: string;
16
+ style?: string | string[];
17
+ preload?: boolean;
18
+ }
19
+
20
+ /** A single local font source entry (multi-weight). */
21
+ export interface LocalFontSrc {
22
+ path: string;
23
+ weight?: string;
24
+ style?: string;
25
+ }
26
+
27
+ /** Configuration passed to `localFont()`. */
28
+ export interface LocalFontConfig {
29
+ src: string | LocalFontSrc[];
30
+ display?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional';
31
+ variable?: string;
32
+ /** Override the font family name. Defaults to a generated name. */
33
+ family?: string;
34
+ }
35
+
36
+ /**
37
+ * The return value of a font function call.
38
+ *
39
+ * Matches the Next.js `next/font` return shape for compatibility.
40
+ */
41
+ export interface FontResult {
42
+ /** Scoped CSS class that applies font-family (e.g. `timber-font-inter`). */
43
+ className: string;
44
+ /** Inline style with the full font stack including fallbacks. */
45
+ style: { fontFamily: string };
46
+ /** CSS custom property name when `variable` is specified (e.g. `--font-sans`). */
47
+ variable?: string;
48
+ }
49
+
50
+ /** Internal representation of a font extracted during static analysis. */
51
+ export interface ExtractedFont {
52
+ /** Unique identifier for this font instance (e.g. `inter-400-normal-latin`). */
53
+ id: string;
54
+ /** The font family name (e.g. `Inter`). */
55
+ family: string;
56
+ /** Provider: 'google' or 'local'. */
57
+ provider: 'google' | 'local';
58
+ /** Weights requested (e.g. ['400', '700']). */
59
+ weights: string[];
60
+ /** Styles requested (e.g. ['normal', 'italic']). */
61
+ styles: string[];
62
+ /** Subsets requested (e.g. ['latin']). Google fonts only. */
63
+ subsets: string[];
64
+ /** font-display value. */
65
+ display: string;
66
+ /** CSS variable name (e.g. `--font-sans`). */
67
+ variable?: string;
68
+ /** Source file paths for local fonts. */
69
+ localSources?: LocalFontSrc[];
70
+ /** The module that imported this font (for segment association). */
71
+ importer: string;
72
+ /** Generated scoped class name. */
73
+ className: string;
74
+ /** Full font stack including fallback. */
75
+ fontFamily: string;
76
+ }
77
+
78
+ /**
79
+ * A single @font-face declaration's data (before CSS serialization).
80
+ */
81
+ export interface FontFaceDescriptor {
82
+ family: string;
83
+ src: string;
84
+ weight?: string;
85
+ style?: string;
86
+ display?: string;
87
+ unicodeRange?: string;
88
+ }