@timber-js/app 0.2.0-alpha.100 → 0.2.0-alpha.102
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.
- package/dist/_chunks/{actions-CQ8Z8VGL.js → actions-n9qTlpmY.js} +7 -10
- package/dist/_chunks/actions-n9qTlpmY.js.map +1 -0
- package/dist/_chunks/{build-output-helper-DXnW0qjz.js → build-output-helper-5TK2Emwp.js} +1 -1
- package/dist/_chunks/{build-output-helper-DXnW0qjz.js.map → build-output-helper-5TK2Emwp.js.map} +1 -1
- package/dist/_chunks/{define-CfBPoJb0.js → define-Btmz9B20.js} +3 -3
- package/dist/_chunks/{define-CfBPoJb0.js.map → define-Btmz9B20.js.map} +1 -1
- package/dist/_chunks/{define-B-Q_UMOD.js → define-Dn98bPbb.js} +3 -3
- package/dist/_chunks/{define-B-Q_UMOD.js.map → define-Dn98bPbb.js.map} +1 -1
- package/dist/_chunks/{format-Bcn-Iv1x.js → format-CYBGxKtc.js} +1 -1
- package/dist/_chunks/{format-Bcn-Iv1x.js.map → format-CYBGxKtc.js.map} +1 -1
- package/dist/_chunks/{handler-store-B-lqaGyh.js → handler-store-B_df5Npe.js} +1 -1
- package/dist/_chunks/{handler-store-B-lqaGyh.js.map → handler-store-B_df5Npe.js.map} +1 -1
- package/dist/_chunks/{logger-0m8MsKdc.js → logger-DDmK5skc.js} +2 -2
- package/dist/_chunks/{logger-0m8MsKdc.js.map → logger-DDmK5skc.js.map} +1 -1
- package/dist/_chunks/merge-search-params-Cm_KIWDX.js +41 -0
- package/dist/_chunks/merge-search-params-Cm_KIWDX.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BU684ls2.js → metadata-routes-Cjmvi3rQ.js} +1 -1
- package/dist/_chunks/{metadata-routes-BU684ls2.js.map → metadata-routes-Cjmvi3rQ.js.map} +1 -1
- package/dist/_chunks/{navigation-root-BCYczjml.js → navigation-root-2ypaZQDx.js} +2 -2
- package/dist/_chunks/{navigation-root-BCYczjml.js.map → navigation-root-2ypaZQDx.js.map} +1 -1
- package/dist/_chunks/{registry-I2ss-lvy.js → registry-DB93kT5c.js} +1 -1
- package/dist/_chunks/{registry-I2ss-lvy.js.map → registry-DB93kT5c.js.map} +1 -1
- package/dist/_chunks/{router-ref-h3-UaCQv.js → router-ref-TkUdmqtM.js} +2 -2
- package/dist/_chunks/{router-ref-h3-UaCQv.js.map → router-ref-TkUdmqtM.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-Cxu4l-7p.js → schema-bridge-C3xl_vfb.js} +1 -1
- package/dist/_chunks/{schema-bridge-Cxu4l-7p.js.map → schema-bridge-C3xl_vfb.js.map} +1 -1
- package/dist/_chunks/{segment-classify-BjfuctV2.js → segment-classify-CnfLLvir.js} +1 -1
- package/dist/_chunks/{segment-classify-BjfuctV2.js.map → segment-classify-CnfLLvir.js.map} +1 -1
- package/dist/_chunks/{segment-context-Dx_OizxD.js → segment-context-Dc-GbSTe.js} +1 -1
- package/dist/_chunks/{segment-context-Dx_OizxD.js.map → segment-context-Dc-GbSTe.js.map} +1 -1
- package/dist/_chunks/{ssr-data-B4CdH7rE.js → ssr-data-DzuI0bIV.js} +1 -1
- package/dist/_chunks/{ssr-data-B4CdH7rE.js.map → ssr-data-DzuI0bIV.js.map} +1 -1
- package/dist/_chunks/{stale-reload-Bab885FO.js → stale-reload-D6FlD3xn.js} +1 -1
- package/dist/_chunks/{stale-reload-Bab885FO.js.map → stale-reload-D6FlD3xn.js.map} +1 -1
- package/dist/_chunks/{tracing-C8V-YGsP.js → tracing-DvKLzCFa.js} +1 -1
- package/dist/_chunks/{tracing-C8V-YGsP.js.map → tracing-DvKLzCFa.js.map} +1 -1
- package/dist/_chunks/{use-query-states-B2XTqxDR.js → use-query-states-B-cLl8mx.js} +2 -2
- package/dist/_chunks/{use-query-states-B2XTqxDR.js.map → use-query-states-B-cLl8mx.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-BkpKAQ7D.js → use-segment-params-07V1TIxL.js} +3 -3
- package/dist/_chunks/{use-segment-params-BkpKAQ7D.js.map → use-segment-params-07V1TIxL.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-BphMdht_.js → validation-DfyuMoK2.js} +2 -40
- package/dist/_chunks/validation-DfyuMoK2.js.map +1 -0
- package/dist/_chunks/{walkers-Tg0Alwcg.js → walkers-DQn5NNrf.js} +4 -4
- package/dist/_chunks/{walkers-Tg0Alwcg.js.map → walkers-DQn5NNrf.js.map} +1 -1
- package/dist/_chunks/{warnings-Cg47l5sk.js → warnings-B30wpfaS.js} +2 -2
- package/dist/_chunks/{warnings-Cg47l5sk.js.map → warnings-B30wpfaS.js.map} +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +3 -3
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/index.js +10 -117
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.d.ts +1 -1
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +8 -8
- package/dist/client/use-cookie.d.ts +4 -0
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/define-cookie.d.ts +5 -45
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +252 -1
- package/dist/cookies/index.js.map +1 -1
- package/dist/fonts/ast.d.ts +98 -6
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +1 -1
- package/dist/fonts/transform.d.ts +15 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1586 -11611
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +2 -23
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/search-params/index.js +3 -3
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +3 -3
- package/dist/server/internal.js +7 -7
- package/dist/server/request-context.d.ts.map +1 -1
- package/package.json +8 -6
- package/src/cli.ts +0 -0
- package/src/client/index.ts +0 -9
- package/src/client/internal.ts +1 -1
- package/src/client/use-cookie.ts +2 -2
- package/src/cookies/define-cookie.ts +100 -166
- package/src/fonts/ast.ts +488 -16
- package/src/fonts/local.ts +1 -1
- package/src/fonts/transform.ts +147 -236
- package/src/index.ts +14 -2
- package/src/plugins/dev-server.ts +8 -9
- package/src/plugins/routing.ts +1 -12
- package/src/plugins/static-build.ts +3 -83
- package/src/server/request-context.ts +1 -8
- package/LICENSE +0 -8
- package/dist/_chunks/actions-CQ8Z8VGL.js.map +0 -1
- package/dist/_chunks/chunk-BYIpzuS7.js +0 -39
- package/dist/_chunks/define-cookie-BjpIt4UC.js +0 -194
- package/dist/_chunks/define-cookie-BjpIt4UC.js.map +0 -1
- package/dist/_chunks/merge-search-params-BphMdht_.js.map +0 -1
- package/dist/plugins/server-action-exports.d.ts +0 -26
- package/dist/plugins/server-action-exports.d.ts.map +0 -1
- package/dist/routing/status-file-lint.d.ts +0 -34
- package/dist/routing/status-file-lint.d.ts.map +0 -1
- package/dist/utils/directive-parser.d.ts +0 -28
- package/dist/utils/directive-parser.d.ts.map +0 -1
- package/src/plugins/server-action-exports.ts +0 -220
- package/src/routing/status-file-lint.ts +0 -115
- package/src/utils/directive-parser.ts +0 -140
package/src/fonts/transform.ts
CHANGED
|
@@ -7,28 +7,25 @@
|
|
|
7
7
|
* call sites to static `FontResult` literals so the runtime never has to
|
|
8
8
|
* evaluate the font function.
|
|
9
9
|
*
|
|
10
|
+
* Uses a single OXC (via vite.parseAst) parse of the source file (via `findFontCallSites`)
|
|
11
|
+
* and magic-string for precise source rewrites with source maps.
|
|
12
|
+
*
|
|
10
13
|
* Design doc: 24-fonts.md
|
|
11
14
|
*/
|
|
12
15
|
|
|
16
|
+
import MagicString from 'magic-string';
|
|
13
17
|
import type { ExtractedFont, GoogleFontConfig } from './types.js';
|
|
14
18
|
import { buildFontStack } from './fallbacks.js';
|
|
15
19
|
import { processLocalFont } from './local.js';
|
|
16
20
|
import {
|
|
17
21
|
extractFontConfigAst,
|
|
18
|
-
extractLocalFontConfigAst,
|
|
19
22
|
detectDynamicFontCallAst,
|
|
23
|
+
findFontCallSites,
|
|
24
|
+
extractGoogleFontConfigFromNode,
|
|
25
|
+
extractLocalFontConfigFromNode,
|
|
20
26
|
} from './ast.js';
|
|
21
27
|
import type { FontPipeline } from './pipeline.js';
|
|
22
28
|
|
|
23
|
-
/**
|
|
24
|
-
* Regex that matches imports from either `@timber/fonts/google` or
|
|
25
|
-
* `next/font/google` (which the shims plugin resolves to the same virtual
|
|
26
|
-
* module). The transform hook still needs to recognise both spellings in
|
|
27
|
-
* source code.
|
|
28
|
-
*/
|
|
29
|
-
const GOOGLE_FONT_IMPORT_RE =
|
|
30
|
-
/import\s*\{([^}]+)\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"]/g;
|
|
31
|
-
|
|
32
29
|
/**
|
|
33
30
|
* Convert a font family name to a scoped class name.
|
|
34
31
|
* e.g. "JetBrains Mono" → "timber-font-jetbrains-mono"
|
|
@@ -83,29 +80,20 @@ export function detectDynamicFontCall(source: string, importedNames: string[]):
|
|
|
83
80
|
return detectDynamicFontCallAst(source, importedNames);
|
|
84
81
|
}
|
|
85
82
|
|
|
83
|
+
// ── Regex-free import/family parsing (thin wrappers over AST) ────────────────
|
|
84
|
+
//
|
|
85
|
+
// These keep the public surface stable. They use `findFontCallSites`
|
|
86
|
+
// internally so they agree with the transform on what counts as an import.
|
|
87
|
+
|
|
86
88
|
/**
|
|
87
89
|
* Parse the local names imported from `@timber/fonts/google` /
|
|
88
90
|
* `next/font/google`. e.g. `import { Inter, JetBrains_Mono as Mono }` →
|
|
89
91
|
* `['Inter', 'Mono']`.
|
|
90
92
|
*/
|
|
91
93
|
export function parseGoogleFontImports(source: string): string[] {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
let match;
|
|
96
|
-
while ((match = importPattern.exec(source)) !== null) {
|
|
97
|
-
const specifiers = match[1]
|
|
98
|
-
.split(',')
|
|
99
|
-
.map((s) => s.trim())
|
|
100
|
-
.filter(Boolean);
|
|
101
|
-
for (const spec of specifiers) {
|
|
102
|
-
// Handle `Inter as MyInter` — we want the local name
|
|
103
|
-
const parts = spec.split(/\s+as\s+/);
|
|
104
|
-
names.push(parts[parts.length - 1].trim());
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return names;
|
|
94
|
+
const analysis = findFontCallSites(source);
|
|
95
|
+
if (!analysis) return [];
|
|
96
|
+
return analysis.bindings.filter((b) => b.provider === 'google').map((b) => b.localName);
|
|
109
97
|
}
|
|
110
98
|
|
|
111
99
|
/**
|
|
@@ -116,25 +104,14 @@ export function parseGoogleFontImports(source: string): string[] {
|
|
|
116
104
|
* to the human-readable family name by replacing `_` with a space.
|
|
117
105
|
*/
|
|
118
106
|
export function parseGoogleFontFamilies(source: string): Map<string, string> {
|
|
119
|
-
const
|
|
107
|
+
const analysis = findFontCallSites(source);
|
|
120
108
|
const families = new Map<string, string>();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
.map((s) => s.trim())
|
|
127
|
-
.filter(Boolean);
|
|
128
|
-
for (const spec of specifiers) {
|
|
129
|
-
const parts = spec.split(/\s+as\s+/);
|
|
130
|
-
const originalName = parts[0].trim();
|
|
131
|
-
const localName = parts[parts.length - 1].trim();
|
|
132
|
-
// Convert export name back to family name: JetBrains_Mono → JetBrains Mono
|
|
133
|
-
const family = originalName.replace(/_/g, ' ');
|
|
134
|
-
families.set(localName, family);
|
|
135
|
-
}
|
|
109
|
+
if (!analysis) return families;
|
|
110
|
+
for (const b of analysis.bindings) {
|
|
111
|
+
if (b.provider !== 'google') continue;
|
|
112
|
+
const family = b.importedName.replace(/_/g, ' ');
|
|
113
|
+
families.set(b.localName, family);
|
|
136
114
|
}
|
|
137
|
-
|
|
138
115
|
return families;
|
|
139
116
|
}
|
|
140
117
|
|
|
@@ -146,10 +123,10 @@ export function parseGoogleFontFamilies(source: string): Map<string, string> {
|
|
|
146
123
|
* import myLoader from 'next/font/local'
|
|
147
124
|
*/
|
|
148
125
|
export function parseLocalFontImportName(source: string): string | null {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
);
|
|
152
|
-
return
|
|
126
|
+
const analysis = findFontCallSites(source);
|
|
127
|
+
if (!analysis) return null;
|
|
128
|
+
const localBinding = analysis.bindings.find((b) => b.provider === 'local');
|
|
129
|
+
return localBinding?.localName ?? null;
|
|
153
130
|
}
|
|
154
131
|
|
|
155
132
|
/** Build the static `FontResult` literal we substitute for a font call. */
|
|
@@ -162,168 +139,13 @@ function buildFontResultLiteral(extracted: ExtractedFont): string {
|
|
|
162
139
|
/** Type for the Vite plugin's `this.error` callback. */
|
|
163
140
|
type EmitError = (msg: string) => never;
|
|
164
141
|
|
|
165
|
-
/**
|
|
166
|
-
* Find Google font calls in `originalCode`, register them in the pipeline,
|
|
167
|
-
* and replace each call site in `transformedCode` with a static FontResult.
|
|
168
|
-
*/
|
|
169
|
-
function transformGoogleFonts(
|
|
170
|
-
transformedCode: string,
|
|
171
|
-
originalCode: string,
|
|
172
|
-
importerId: string,
|
|
173
|
-
pipeline: FontPipeline,
|
|
174
|
-
emitError: EmitError
|
|
175
|
-
): string {
|
|
176
|
-
const families = parseGoogleFontFamilies(originalCode);
|
|
177
|
-
if (families.size === 0) return transformedCode;
|
|
178
|
-
|
|
179
|
-
const importedNames = [...families.keys()];
|
|
180
|
-
|
|
181
|
-
const dynamicCall = detectDynamicFontCall(originalCode, importedNames);
|
|
182
|
-
if (dynamicCall) {
|
|
183
|
-
emitError(
|
|
184
|
-
`Font function calls must be statically analyzable. ` +
|
|
185
|
-
`Found dynamic call: ${dynamicCall}. ` +
|
|
186
|
-
`Pass a literal object with string/array values instead.`
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
for (const [localName, family] of families) {
|
|
191
|
-
const callPattern = new RegExp(
|
|
192
|
-
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
193
|
-
'g'
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
let callMatch;
|
|
197
|
-
while ((callMatch = callPattern.exec(originalCode)) !== null) {
|
|
198
|
-
const varName = callMatch[1];
|
|
199
|
-
const configSource = callMatch[2];
|
|
200
|
-
const fullMatch = callMatch[0];
|
|
201
|
-
|
|
202
|
-
const config = extractFontConfig(`(${configSource})`);
|
|
203
|
-
if (!config) {
|
|
204
|
-
emitError(
|
|
205
|
-
`Could not statically analyze font config for ${family}. ` +
|
|
206
|
-
`Ensure all config values are string literals or arrays of string literals.`
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const fontId = generateFontId(family, config!);
|
|
211
|
-
const className = familyToClassName(family);
|
|
212
|
-
const fontStack = buildFontStack(family);
|
|
213
|
-
const display = config!.display ?? 'swap';
|
|
214
|
-
|
|
215
|
-
const extracted: ExtractedFont = {
|
|
216
|
-
id: fontId,
|
|
217
|
-
family,
|
|
218
|
-
provider: 'google',
|
|
219
|
-
weights: normalizeWeightArray(config!.weight),
|
|
220
|
-
styles: normalizeStyleArray(config!.style),
|
|
221
|
-
subsets: config!.subsets ?? ['latin'],
|
|
222
|
-
display,
|
|
223
|
-
variable: config!.variable,
|
|
224
|
-
className,
|
|
225
|
-
fontFamily: fontStack,
|
|
226
|
-
importer: importerId,
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
pipeline.pruneFor(importerId, family);
|
|
230
|
-
pipeline.register(extracted);
|
|
231
|
-
|
|
232
|
-
const replacement = `const ${varName} = ${buildFontResultLiteral(extracted)}`;
|
|
233
|
-
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Strip the import statement; the values it referred to are now inlined.
|
|
238
|
-
transformedCode = transformedCode.replace(
|
|
239
|
-
/import\s*\{[^}]+\}\s*from\s*['"](?:@timber\/fonts\/google|@timber-js\/app\/fonts\/google|next\/font\/google)['"];?\s*\n?/g,
|
|
240
|
-
''
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
return transformedCode;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Find local font calls in `originalCode`, register them in the pipeline,
|
|
248
|
-
* and replace each call site in `transformedCode` with a static FontResult.
|
|
249
|
-
*/
|
|
250
|
-
function transformLocalFonts(
|
|
251
|
-
transformedCode: string,
|
|
252
|
-
originalCode: string,
|
|
253
|
-
importerId: string,
|
|
254
|
-
pipeline: FontPipeline,
|
|
255
|
-
emitError: EmitError
|
|
256
|
-
): string {
|
|
257
|
-
const localName = parseLocalFontImportName(originalCode);
|
|
258
|
-
if (!localName) return transformedCode;
|
|
259
|
-
|
|
260
|
-
const dynamicCall = detectDynamicFontCall(originalCode, [localName]);
|
|
261
|
-
if (dynamicCall) {
|
|
262
|
-
emitError(
|
|
263
|
-
`Font function calls must be statically analyzable. ` +
|
|
264
|
-
`Found dynamic call: ${dynamicCall}. ` +
|
|
265
|
-
`Pass a literal object with string/array values instead.`
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const callPattern = new RegExp(
|
|
270
|
-
`(?:const|let|var)\\s+(\\w+)\\s*=\\s*${localName}\\s*\\(\\s*(\\{[\\s\\S]*?\\})\\s*\\)`,
|
|
271
|
-
'g'
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
let callMatch;
|
|
275
|
-
while ((callMatch = callPattern.exec(originalCode)) !== null) {
|
|
276
|
-
const varName = callMatch[1];
|
|
277
|
-
const configSource = callMatch[2];
|
|
278
|
-
const fullMatch = callMatch[0];
|
|
279
|
-
|
|
280
|
-
const config = extractLocalFontConfigAst(`(${configSource})`);
|
|
281
|
-
if (!config) {
|
|
282
|
-
emitError(
|
|
283
|
-
`Could not statically analyze local font config. ` +
|
|
284
|
-
`Ensure src is a string or array of { path, weight?, style? } objects.`
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const extracted = processLocalFont(config!, importerId);
|
|
289
|
-
pipeline.pruneFor(importerId, extracted.family);
|
|
290
|
-
pipeline.register(extracted);
|
|
291
|
-
|
|
292
|
-
const replacement = `const ${varName} = ${buildFontResultLiteral(extracted)}`;
|
|
293
|
-
transformedCode = transformedCode.replace(fullMatch, replacement);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Strip the import statement; the values it referred to are now inlined.
|
|
297
|
-
transformedCode = transformedCode.replace(
|
|
298
|
-
/import\s+\w+\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"];?\s*\n?/g,
|
|
299
|
-
''
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
return transformedCode;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Run the timber-fonts transform pass on a single source file.
|
|
307
|
-
*
|
|
308
|
-
* Returns the rewritten code (with font calls inlined and a side-effect
|
|
309
|
-
* `import 'virtual:timber-font-css/<hash>.css'` prepended so Vite's CSS
|
|
310
|
-
* pipeline owns the font CSS) or `null` if the file does not import from
|
|
311
|
-
* any timber-fonts virtual module and therefore needs no transformation.
|
|
312
|
-
*/
|
|
313
142
|
/**
|
|
314
143
|
* File extensions that may contain real font imports. Only JS/TS module
|
|
315
144
|
* variants are scanned. Excluding non-JS extensions (.mdx, .md, .css,
|
|
316
145
|
* .json, etc.) prevents false-positive matches on text that mentions a
|
|
317
146
|
* font specifier inside documentation, code fences, or asset metadata.
|
|
318
147
|
*
|
|
319
|
-
*
|
|
320
|
-
* not an AST parse. An MDX file documenting Next.js font compatibility
|
|
321
|
-
* (e.g. a code fence containing `import { Inter } from 'next/font/google'`)
|
|
322
|
-
* would trigger the same match as a real import. The transform then prepends
|
|
323
|
-
* `import 'virtual:timber-font-css/<hash>.css';` to the file, which breaks
|
|
324
|
-
* downstream parsers that require the file to start with their own delimiter
|
|
325
|
-
* — most notably MDX frontmatter, which must start at line 1 col 1 for
|
|
326
|
-
* `remark-frontmatter` to recognise it. See TIM-840.
|
|
148
|
+
* See TIM-840.
|
|
327
149
|
*/
|
|
328
150
|
const FONT_IMPORT_SCANNABLE_EXTENSIONS = new Set([
|
|
329
151
|
'.js',
|
|
@@ -337,31 +159,35 @@ const FONT_IMPORT_SCANNABLE_EXTENSIONS = new Set([
|
|
|
337
159
|
]);
|
|
338
160
|
|
|
339
161
|
function hasScannableExtension(id: string): boolean {
|
|
340
|
-
// Strip Vite query suffixes (?v=..., ?import, ?worker, etc.) before
|
|
341
|
-
// matching so files imported with a query string are still scanned.
|
|
342
162
|
const path = id.split('?', 1)[0];
|
|
343
163
|
const dot = path.lastIndexOf('.');
|
|
344
164
|
if (dot === -1) return false;
|
|
345
165
|
return FONT_IMPORT_SCANNABLE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
346
166
|
}
|
|
347
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Run the timber-fonts transform pass on a single source file.
|
|
170
|
+
*
|
|
171
|
+
* Parses the source once with OXC (via vite.parseAst), walks the AST to find font imports
|
|
172
|
+
* and call sites, then uses magic-string to rewrite call sites to static
|
|
173
|
+
* `FontResult` literals and strip the import declarations. Returns a
|
|
174
|
+
* source map alongside the transformed code.
|
|
175
|
+
*
|
|
176
|
+
* Returns `null` if the file does not import from any font virtual module.
|
|
177
|
+
*/
|
|
348
178
|
export function runFontsTransform(
|
|
349
179
|
code: string,
|
|
350
180
|
id: string,
|
|
351
181
|
pipeline: FontPipeline,
|
|
352
182
|
emitError: EmitError
|
|
353
|
-
): { code: string; map:
|
|
183
|
+
): { code: string; map: ReturnType<MagicString['generateMap']> } | null {
|
|
354
184
|
// Skip virtual modules and node_modules
|
|
355
185
|
if (id.startsWith('\0') || id.includes('node_modules')) return null;
|
|
356
186
|
|
|
357
|
-
// Only scan JS/TS module variants
|
|
358
|
-
// mention font specifiers in documentation or asset metadata without
|
|
359
|
-
// ever importing them — a substring match would be a false positive
|
|
360
|
-
// and the prepended virtual CSS import would corrupt downstream
|
|
361
|
-
// parsers (e.g. MDX frontmatter, which must start at line 1).
|
|
362
|
-
// See TIM-840.
|
|
187
|
+
// Only scan JS/TS module variants (see TIM-840).
|
|
363
188
|
if (!hasScannableExtension(id)) return null;
|
|
364
189
|
|
|
190
|
+
// Fast pre-filter: skip files that don't mention any font module.
|
|
365
191
|
const hasGoogleImport =
|
|
366
192
|
code.includes('@timber/fonts/google') ||
|
|
367
193
|
code.includes('@timber-js/app/fonts/google') ||
|
|
@@ -372,35 +198,120 @@ export function runFontsTransform(
|
|
|
372
198
|
code.includes('next/font/local');
|
|
373
199
|
if (!hasGoogleImport && !hasLocalImport) return null;
|
|
374
200
|
|
|
375
|
-
|
|
201
|
+
// Single parse + AST walk.
|
|
202
|
+
const analysis = findFontCallSites(code);
|
|
203
|
+
if (!analysis) return null;
|
|
376
204
|
|
|
377
|
-
|
|
378
|
-
|
|
205
|
+
// Bail on dynamic calls.
|
|
206
|
+
if (analysis.dynamicCall) {
|
|
207
|
+
emitError(
|
|
208
|
+
`Font function calls must be statically analyzable. ` +
|
|
209
|
+
`Found dynamic call: ${analysis.dynamicCall}. ` +
|
|
210
|
+
`Pass a literal object with string/array values instead.`
|
|
211
|
+
);
|
|
379
212
|
}
|
|
380
213
|
|
|
381
|
-
|
|
382
|
-
|
|
214
|
+
// Bail on nested font calls (must be module-level).
|
|
215
|
+
if (analysis.nestedCalls.length > 0) {
|
|
216
|
+
const first = analysis.nestedCalls[0];
|
|
217
|
+
emitError(
|
|
218
|
+
`Font function \`${first.name}()\` must be called at module scope. ` +
|
|
219
|
+
`Found nested call at line ${first.line}, column ${first.column}. ` +
|
|
220
|
+
`Move the call to the top level of the module.`
|
|
221
|
+
);
|
|
383
222
|
}
|
|
384
223
|
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
224
|
+
if (analysis.callSites.length === 0 && analysis.bindings.length === 0) return null;
|
|
225
|
+
|
|
226
|
+
const s = new MagicString(code);
|
|
227
|
+
|
|
228
|
+
// Process each call site.
|
|
229
|
+
for (const site of analysis.callSites) {
|
|
230
|
+
if (site.binding.provider === 'google') {
|
|
231
|
+
const family = site.binding.importedName.replace(/_/g, ' ');
|
|
232
|
+
|
|
233
|
+
const config = extractGoogleFontConfigFromNode(site.configNode);
|
|
234
|
+
if (!config) {
|
|
235
|
+
emitError(
|
|
236
|
+
`Could not statically analyze font config for ${family} ` +
|
|
237
|
+
`at line ${site.line}, column ${site.column}. ` +
|
|
238
|
+
`Ensure all config values are string literals or arrays of string literals.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fontId = generateFontId(family, config!);
|
|
243
|
+
const className = familyToClassName(family);
|
|
244
|
+
const fontStack = buildFontStack(family);
|
|
245
|
+
const display = config!.display ?? 'swap';
|
|
246
|
+
|
|
247
|
+
const extracted: ExtractedFont = {
|
|
248
|
+
id: fontId,
|
|
249
|
+
family,
|
|
250
|
+
provider: 'google',
|
|
251
|
+
weights: normalizeWeightArray(config!.weight),
|
|
252
|
+
styles: normalizeStyleArray(config!.style),
|
|
253
|
+
subsets: config!.subsets ?? ['latin'],
|
|
254
|
+
display,
|
|
255
|
+
variable: config!.variable,
|
|
256
|
+
className,
|
|
257
|
+
fontFamily: fontStack,
|
|
258
|
+
importer: id,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
pipeline.pruneFor(id, family);
|
|
262
|
+
pipeline.register(extracted);
|
|
263
|
+
|
|
264
|
+
s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
|
|
265
|
+
} else {
|
|
266
|
+
// local font
|
|
267
|
+
const config = extractLocalFontConfigFromNode(site.configNode);
|
|
268
|
+
if (!config) {
|
|
269
|
+
emitError(
|
|
270
|
+
`Could not statically analyze local font config ` +
|
|
271
|
+
`at line ${site.line}, column ${site.column}. ` +
|
|
272
|
+
`Ensure src is a string or array of { path, weight?, style? } objects.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const extracted = processLocalFont(config!, id);
|
|
277
|
+
pipeline.pruneFor(id, extracted.family);
|
|
278
|
+
pipeline.register(extracted);
|
|
279
|
+
|
|
280
|
+
s.overwrite(site.replaceStart, site.replaceEnd, buildFontResultLiteral(extracted));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Remove font import declarations. Use a Set to avoid double-removing
|
|
285
|
+
// when multiple bindings come from the same ImportDeclaration.
|
|
286
|
+
const removedRanges = new Set<string>();
|
|
287
|
+
for (const imp of analysis.importNodesToRemove) {
|
|
288
|
+
const key = `${imp.start}:${imp.end}`;
|
|
289
|
+
if (removedRanges.has(key)) continue;
|
|
290
|
+
removedRanges.add(key);
|
|
291
|
+
|
|
292
|
+
// Also remove the trailing newline if present.
|
|
293
|
+
let end = imp.end;
|
|
294
|
+
if (code[end] === '\n') end++;
|
|
295
|
+
else if (code[end] === '\r' && code[end + 1] === '\n') end += 2;
|
|
296
|
+
// Also remove a trailing semicolon before the newline.
|
|
297
|
+
if (code[imp.end] === ';') {
|
|
298
|
+
end = imp.end + 1;
|
|
299
|
+
if (code[end] === '\n') end++;
|
|
300
|
+
else if (code[end] === '\r' && code[end + 1] === '\n') end += 2;
|
|
401
301
|
}
|
|
402
|
-
|
|
302
|
+
s.remove(imp.start, end);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!s.hasChanged()) return null;
|
|
306
|
+
|
|
307
|
+
// Inject a side-effect import for the per-importer virtual CSS module.
|
|
308
|
+
if (pipeline.size() > 0) {
|
|
309
|
+
const virtualCssId = pipeline.cssIdMap.idFor(id);
|
|
310
|
+
s.prepend(`import '${virtualCssId}';\n`);
|
|
403
311
|
}
|
|
404
312
|
|
|
405
|
-
return
|
|
313
|
+
return {
|
|
314
|
+
code: s.toString(),
|
|
315
|
+
map: s.generateMap({ hires: true }),
|
|
316
|
+
};
|
|
406
317
|
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,6 @@ import { timberRouting } from './plugins/routing';
|
|
|
22
22
|
import { timberShims } from './plugins/shims';
|
|
23
23
|
import { timberFonts } from './plugins/fonts';
|
|
24
24
|
import { timberStaticBuild } from './plugins/static-build';
|
|
25
|
-
import { timberServerActionExports } from './plugins/server-action-exports';
|
|
26
25
|
import { timberBuildManifest } from './plugins/build-manifest';
|
|
27
26
|
import { timberDevLogs } from './dev-tools/logs';
|
|
28
27
|
import { timberDevBrowserLogs } from './dev-tools/browser-logs';
|
|
@@ -417,6 +416,20 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
417
416
|
ctx.root = resolved.root;
|
|
418
417
|
ctx.appDir = resolveAppDir(resolved.root, ctx.config.appDir);
|
|
419
418
|
ctx.dev = resolved.command === 'serve';
|
|
419
|
+
|
|
420
|
+
// Register all timber config file candidates as Vite config file
|
|
421
|
+
// dependencies so Vite's built-in restart-on-change handles them
|
|
422
|
+
// (including atomic writes that emit unlink+add instead of change).
|
|
423
|
+
// All candidates are registered — not just the one that exists — so
|
|
424
|
+
// that creating a config file after server start also triggers a
|
|
425
|
+
// restart. The watcher.add() call in dev-server.ts ensures all
|
|
426
|
+
// candidates are in the watch set.
|
|
427
|
+
// See design/21-dev-server.md §HMR Wiring.
|
|
428
|
+
const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
429
|
+
for (const name of configNames) {
|
|
430
|
+
resolved.configFileDependencies.push(resolve(resolved.root, name));
|
|
431
|
+
}
|
|
432
|
+
|
|
420
433
|
// Sync buildDir from Vite's resolved build.outDir. Vite keeps
|
|
421
434
|
// outDir as-is (may be relative), so resolve against root.
|
|
422
435
|
ctx.buildDir = resolve(resolved.root, resolved.build.outDir);
|
|
@@ -489,7 +502,6 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
|
|
|
489
502
|
// coordinates with plugin-react via __vite_plugin_react_preamble_installed__.
|
|
490
503
|
react(),
|
|
491
504
|
...reactCompilerPlugins,
|
|
492
|
-
timberServerActionExports(),
|
|
493
505
|
vitePluginRsc(createRscOptions(ctx, encryptionKeyExpr)),
|
|
494
506
|
timberShims(ctx),
|
|
495
507
|
timberRouting(ctx),
|
|
@@ -84,16 +84,15 @@ export function timberDevServer(ctx: PluginContext): Plugin {
|
|
|
84
84
|
* filtered out explicitly and passed through to Vite.
|
|
85
85
|
*/
|
|
86
86
|
configureServer(server: ViteDevServer) {
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
87
|
+
// Ensure config files are in the watch set. Vite's watcher covers
|
|
88
|
+
// the project root, but `server.watcher.add()` is a no-op for files
|
|
89
|
+
// already watched, and ensures newly-created config files (e.g. from
|
|
90
|
+
// create-timber-app) are picked up. The actual restart logic is
|
|
91
|
+
// handled by Vite's built-in configFileDependencies check — see the
|
|
92
|
+
// configResolved hook in index.ts where we register the config path.
|
|
93
|
+
// See 21-dev-server.md §HMR Wiring.
|
|
90
94
|
const configPaths = CONFIG_FILE_NAMES.map((name) => join(ctx.root, name));
|
|
91
|
-
|
|
92
|
-
server.watcher.on('change', (filePath: string) => {
|
|
93
|
-
if (configPaths.includes(filePath)) {
|
|
94
|
-
server.restart();
|
|
95
|
-
}
|
|
96
|
-
});
|
|
95
|
+
server.watcher.add(configPaths);
|
|
97
96
|
|
|
98
97
|
// Register Vite server for browser console warning forwarding.
|
|
99
98
|
// See 21-dev-server.md §Dev-Mode Warnings.
|
package/src/plugins/routing.ts
CHANGED
|
@@ -15,10 +15,7 @@ import { join } from 'node:path';
|
|
|
15
15
|
import { scanRoutes } from '../routing/scanner.js';
|
|
16
16
|
import { generateRouteMap } from '../routing/codegen.js';
|
|
17
17
|
import { collectInterceptionRewrites } from '../routing/interception.js';
|
|
18
|
-
|
|
19
|
-
lintStatusFileDirectives,
|
|
20
|
-
formatStatusFileLintWarnings,
|
|
21
|
-
} from '../routing/status-file-lint.js';
|
|
18
|
+
|
|
22
19
|
import {
|
|
23
20
|
lintConventions,
|
|
24
21
|
checkAppDirExists,
|
|
@@ -129,14 +126,6 @@ export function timberRouting(ctx: PluginContext): Plugin {
|
|
|
129
126
|
ctx.timer.end('route-scan');
|
|
130
127
|
writeCodegen(ctx);
|
|
131
128
|
|
|
132
|
-
// Lint status files for missing 'use client' directive
|
|
133
|
-
const statusWarnings = lintStatusFileDirectives(ctx.routeTree);
|
|
134
|
-
const newStatusWarnings = statusWarnings.filter((w) => !warnedFiles.has(w.filePath));
|
|
135
|
-
if (newStatusWarnings.length > 0) {
|
|
136
|
-
for (const w of newStatusWarnings) warnedFiles.add(w.filePath);
|
|
137
|
-
console.warn(formatStatusFileLintWarnings(newStatusWarnings));
|
|
138
|
-
}
|
|
139
|
-
|
|
140
129
|
// Lint conventions (empty app, missing methods, missing root layout)
|
|
141
130
|
const conventionWarnings = lintConventions(ctx.routeTree, ctx.appDir);
|
|
142
131
|
const newConventionWarnings = conventionWarnings.filter((w) => !warnedFiles.has(w.id));
|