@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
@@ -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 importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
93
- const names: string[] = [];
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 importPattern = new RegExp(GOOGLE_FONT_IMPORT_RE.source, 'g');
107
+ const analysis = findFontCallSites(source);
120
108
  const families = new Map<string, string>();
121
-
122
- let match;
123
- while ((match = importPattern.exec(source)) !== null) {
124
- const specifiers = match[1]
125
- .split(',')
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 match = source.match(
150
- /import\s+(\w+)\s+from\s*['"](?:@timber\/fonts\/local|@timber-js\/app\/fonts\/local|next\/font\/local)['"]/
151
- );
152
- return match ? match[1] : null;
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
- * Why this matters: the heuristic below is a substring check (`code.includes`),
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: null } | null {
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. .mdx, .md, .css, .json, etc. may
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
- let transformedCode = code;
201
+ // Single parse + AST walk.
202
+ const analysis = findFontCallSites(code);
203
+ if (!analysis) return null;
376
204
 
377
- if (hasGoogleImport) {
378
- transformedCode = transformGoogleFonts(transformedCode, code, id, pipeline, emitError);
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
- if (hasLocalImport) {
382
- transformedCode = transformLocalFonts(transformedCode, code, id, pipeline, emitError);
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 (transformedCode !== code) {
386
- // Inject a side-effect import for this file's per-importer virtual
387
- // CSS module. The `.css` suffix is what makes Vite's CSS pipeline
388
- // own the module — in dev that gives us HMR + source maps, and in
389
- // production @vitejs/plugin-rsc walks the RSC module graph via
390
- // `collectCss`, finds this virtual CSS import, and injects a
391
- // `<link rel="stylesheet" data-rsc-css-href=...>` through React
392
- // Float's preinit mechanism (the same path component CSS rides on).
393
- //
394
- // The per-importer id guarantees two concurrent requests rendering
395
- // different routes never see each other's font CSS: each importer's
396
- // virtual module only resolves to the fonts that importer registered
397
- // via `FontPipeline.getCssForSegment(importer)`. See TIM-828.
398
- if (pipeline.size() > 0) {
399
- const virtualCssId = pipeline.cssIdMap.idFor(id);
400
- transformedCode = `import '${virtualCssId}';\n` + transformedCode;
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
- return { code: transformedCode, map: null };
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 null;
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
- // Watch config files for full restart.
88
- // timber.config.ts is loaded once at startup — any change requires
89
- // a full dev server restart. See 21-dev-server.md §HMR Wiring.
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.
@@ -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
- import {
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));