@nitronjs/framework 0.2.26 → 0.3.0

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 (59) hide show
  1. package/README.md +260 -170
  2. package/lib/Auth/Auth.js +2 -2
  3. package/lib/Build/CssBuilder.js +5 -7
  4. package/lib/Build/EffectivePropUsage.js +174 -0
  5. package/lib/Build/FactoryTransform.js +1 -21
  6. package/lib/Build/FileAnalyzer.js +2 -33
  7. package/lib/Build/Manager.js +390 -58
  8. package/lib/Build/PropUsageAnalyzer.js +1189 -0
  9. package/lib/Build/jsxRuntime.js +25 -155
  10. package/lib/Build/plugins.js +212 -146
  11. package/lib/Build/propUtils.js +70 -0
  12. package/lib/Console/Commands/DevCommand.js +30 -10
  13. package/lib/Console/Commands/MakeCommand.js +8 -1
  14. package/lib/Console/Output.js +0 -2
  15. package/lib/Console/Stubs/rsc-consumer.tsx +74 -0
  16. package/lib/Console/Stubs/vendor-dev.tsx +30 -41
  17. package/lib/Console/Stubs/vendor.tsx +25 -1
  18. package/lib/Core/Config.js +0 -6
  19. package/lib/Core/Paths.js +0 -19
  20. package/lib/Database/Migration/Checksum.js +0 -3
  21. package/lib/Database/Migration/MigrationRepository.js +0 -8
  22. package/lib/Database/Migration/MigrationRunner.js +1 -2
  23. package/lib/Database/Model.js +19 -11
  24. package/lib/Database/QueryBuilder.js +25 -4
  25. package/lib/Database/Schema/Blueprint.js +10 -0
  26. package/lib/Database/Schema/Manager.js +2 -0
  27. package/lib/Date/DateTime.js +1 -1
  28. package/lib/Dev/DevContext.js +44 -0
  29. package/lib/Dev/DevErrorPage.js +990 -0
  30. package/lib/Dev/DevIndicator.js +836 -0
  31. package/lib/HMR/Server.js +16 -37
  32. package/lib/Http/Server.js +177 -24
  33. package/lib/Logging/Log.js +34 -2
  34. package/lib/Mail/Mail.js +41 -10
  35. package/lib/Route/Router.js +43 -19
  36. package/lib/Runtime/Entry.js +10 -6
  37. package/lib/Session/Manager.js +144 -1
  38. package/lib/Session/Redis.js +117 -0
  39. package/lib/Session/Session.js +0 -4
  40. package/lib/Support/Str.js +6 -4
  41. package/lib/Translation/Lang.js +376 -32
  42. package/lib/Translation/pluralize.js +81 -0
  43. package/lib/Validation/MagicBytes.js +120 -0
  44. package/lib/Validation/Validator.js +46 -29
  45. package/lib/View/Client/hmr-client.js +100 -90
  46. package/lib/View/Client/spa.js +121 -50
  47. package/lib/View/ClientManifest.js +60 -0
  48. package/lib/View/FlightRenderer.js +100 -0
  49. package/lib/View/Layout.js +0 -3
  50. package/lib/View/PropFilter.js +81 -0
  51. package/lib/View/View.js +230 -495
  52. package/lib/index.d.ts +22 -1
  53. package/package.json +3 -2
  54. package/skeleton/config/app.js +1 -0
  55. package/skeleton/config/server.js +13 -0
  56. package/skeleton/config/session.js +4 -0
  57. package/lib/Build/HydrationBuilder.js +0 -190
  58. package/lib/Console/Stubs/page-hydration-dev.tsx +0 -72
  59. package/lib/Console/Stubs/page-hydration.tsx +0 -53
@@ -10,17 +10,19 @@ import Layout from "../View/Layout.js";
10
10
  import JSX_RUNTIME from "./jsxRuntime.js";
11
11
  import FileAnalyzer from "./FileAnalyzer.js";
12
12
  import CssBuilder from "./CssBuilder.js";
13
- import HydrationBuilder from "./HydrationBuilder.js";
14
13
  import {
15
14
  createPathAliasPlugin,
16
15
  createOriginalJsxPlugin,
17
16
  createVendorGlobalsPlugin,
18
17
  createServerFunctionsPlugin,
19
18
  createCssStubPlugin,
20
- createMarkerPlugin,
21
- createServerModuleBlockerPlugin
19
+ createServerModuleBlockerPlugin,
20
+ createClientReferencePlugin,
21
+ createPropFilterPlugin
22
22
  } from "./plugins.js";
23
23
  import { transformFile as factoryTransform } from "./FactoryTransform.js";
24
+ import PropUsageAnalyzer from "./PropUsageAnalyzer.js";
25
+ import EffectivePropUsage from "./EffectivePropUsage.js";
24
26
  import COLORS from "./colors.js";
25
27
 
26
28
  dotenv.config({ quiet: true });
@@ -43,21 +45,21 @@ class Builder {
43
45
  css: new Map(),
44
46
  cssHashes: new Map(),
45
47
  viewHashes: new Map(),
46
- hydrationTemplate: null,
47
- hydrationTemplateDev: null,
48
48
  vendorBuilt: false,
49
49
  spaBuilt: false,
50
50
  hmrBuilt: false,
51
+ rscConsumerBuilt: false,
51
52
  tailwindProcessor: null,
52
53
  viewsChanged: false,
53
54
  fileMeta: new Map(),
54
- fileHashes: new Map()
55
+ fileHashes: new Map(),
56
+ propAnalysis: new Map()
55
57
  };
56
58
  #diskCachePath = path.join(Paths.nitronTemp, "build-cache.json");
57
59
  #changedFiles = new Set();
60
+ #devBuiltOnce = false;
58
61
  #analyzer;
59
62
  #cssBuilder;
60
- #hydrationBuilder;
61
63
 
62
64
  constructor() {
63
65
  this.#paths = {
@@ -74,8 +76,7 @@ class Builder {
74
76
  };
75
77
 
76
78
  this.#analyzer = new FileAnalyzer(this.#cache);
77
- this.#cssBuilder = new CssBuilder(this.#cache, this.#isDev, this.#paths.cssInput, this.#paths.cssOutput);
78
- this.#hydrationBuilder = new HydrationBuilder(this.#cache, this.#isDev, this.#paths.templates, this.#analyzer);
79
+ this.#cssBuilder = new CssBuilder(this.#cache, this.#paths.cssInput, this.#paths.cssOutput);
79
80
  }
80
81
 
81
82
  /**
@@ -96,7 +97,7 @@ class Builder {
96
97
  try {
97
98
  if (this.#isDev) this.#loadDiskCache();
98
99
  if (!silent) console.log(`\n${COLORS.cyan}⚡ NitronJS Build${COLORS.reset}\n`);
99
- if (!only) this.#cleanOutputDirs();
100
+ if (!only && !(this.#isDev && this.#devBuiltOnce)) this.#cleanOutputDirs();
100
101
 
101
102
  this.#cache.viewsChanged = false;
102
103
  this.#writeJsxRuntime();
@@ -104,7 +105,10 @@ class Builder {
104
105
  if (!only || only === "views") await this.#buildViews();
105
106
  if (!only || only === "css") await this.#buildCss();
106
107
 
107
- if (this.#isDev) this.#saveDiskCache();
108
+ if (this.#isDev) {
109
+ this.#devBuiltOnce = true;
110
+ this.#saveDiskCache();
111
+ }
108
112
  this.#cleanupTemp();
109
113
  if (!silent) this.#printSummary(Date.now() - startTime);
110
114
 
@@ -116,7 +120,8 @@ class Builder {
116
120
  time: Date.now() - startTime
117
121
  };
118
122
  } catch (error) {
119
- this.#cleanupTemp();
123
+ try { this.#cleanupTemp(); }
124
+ catch {}
120
125
 
121
126
  if (!silent) {
122
127
  console.log(this.#formatBuildError(error));
@@ -133,8 +138,8 @@ class Builder {
133
138
 
134
139
  if (data.fileMeta) {
135
140
  for (const [key, value] of Object.entries(data.fileMeta)) {
136
- value.imports = new Set(value.imports || []);
137
- value.css = new Set(value.css || []);
141
+ value.imports = value.imports || [];
142
+ value.css = value.css || [];
138
143
  this.#cache.fileMeta.set(key, value);
139
144
  }
140
145
  }
@@ -153,6 +158,11 @@ class Builder {
153
158
  this.#cache.cssHashes.set(key, value);
154
159
  }
155
160
  }
161
+ if (data.propAnalysis) {
162
+ for (const [key, value] of Object.entries(data.propAnalysis)) {
163
+ this.#cache.propAnalysis.set(key, value);
164
+ }
165
+ }
156
166
  }
157
167
  } catch {}
158
168
  }
@@ -168,10 +178,12 @@ class Builder {
168
178
  fileMeta: {},
169
179
  fileHashes: {},
170
180
  viewHashes: {},
171
- cssHashes: {}
181
+ cssHashes: {},
182
+ propAnalysis: {}
172
183
  };
173
184
 
174
185
  for (const [key, value] of this.#cache.fileMeta) {
186
+ if (!fs.existsSync(key)) continue;
175
187
  data.fileMeta[key] = {
176
188
  ...value,
177
189
  imports: Array.from(value.imports || []),
@@ -179,16 +191,30 @@ class Builder {
179
191
  };
180
192
  }
181
193
  for (const [key, value] of this.#cache.fileHashes) {
194
+ if (!fs.existsSync(key)) continue;
182
195
  data.fileHashes[key] = value;
183
196
  }
184
197
  for (const [key, value] of this.#cache.viewHashes) {
198
+ const filePath = key.endsWith(":mtime") ? key.slice(0, -6) : key;
199
+ if (!fs.existsSync(filePath)) continue;
185
200
  data.viewHashes[key] = value;
186
201
  }
187
202
  for (const [key, value] of this.#cache.cssHashes) {
203
+ if (!fs.existsSync(key)) continue;
188
204
  data.cssHashes[key] = value;
189
205
  }
206
+ for (const [key, value] of this.#cache.propAnalysis) {
207
+ if (!fs.existsSync(key)) continue;
208
+ data.propAnalysis[key] = value;
209
+ }
190
210
 
191
- fs.writeFileSync(this.#diskCachePath, JSON.stringify(data));
211
+ const tmpPath = this.#diskCachePath + ".tmp";
212
+ fs.writeFileSync(tmpPath, JSON.stringify(data));
213
+
214
+ try { fs.renameSync(tmpPath, this.#diskCachePath); }
215
+ catch {
216
+ fs.writeFileSync(this.#diskCachePath, JSON.stringify(data));
217
+ }
192
218
  } catch {}
193
219
  }
194
220
 
@@ -218,10 +244,11 @@ class Builder {
218
244
  }
219
245
 
220
246
  async #buildViews() {
221
- const [, , , userBundle, frameworkBundle] = await Promise.all([
247
+ const [, , , , userBundle, frameworkBundle] = await Promise.all([
222
248
  this.#buildVendor(),
223
249
  this.#buildSpaRuntime(),
224
250
  this.#buildHmrClient(),
251
+ this.#buildRscConsumer(),
225
252
  this.#buildViewBundle("user", this.#paths.userViews, this.#paths.userOutput),
226
253
  this.#buildViewBundle("framework", this.#paths.frameworkViews, this.#paths.frameworkOutput)
227
254
  ]);
@@ -231,19 +258,27 @@ class Builder {
231
258
  ...(frameworkBundle.changedFiles || [])
232
259
  ]);
233
260
 
261
+ // Include changed client component sources so their hydration bundles rebuild
262
+ for (const bundle of [userBundle, frameworkBundle]) {
263
+ if (!bundle?.changedSources) continue;
264
+
265
+ for (const file of bundle.changedSources) {
266
+ const fileMeta = bundle.meta?.get(file);
267
+ if (fileMeta?.isClient) changedViews.add(file);
268
+ }
269
+ }
270
+
234
271
  for (const file of changedViews) {
235
272
  this.#changedFiles.add(file);
236
273
  }
237
274
 
238
- const isFirstBuild = changedViews.size === 0 &&
239
- (userBundle.entries.length > 0 || frameworkBundle.entries.length > 0);
240
-
241
- await this.#buildHydrationBundles(
275
+ const clientComponents = await this.#buildClientComponents(
242
276
  userBundle,
243
277
  frameworkBundle,
244
- isFirstBuild ? null : (changedViews.size > 0 ? changedViews : null)
278
+ changedViews
245
279
  );
246
280
 
281
+ this.#writeClientManifest(clientComponents);
247
282
  this.#writeManifest();
248
283
  }
249
284
 
@@ -304,36 +339,92 @@ class Builder {
304
339
  this.#cache.spaBuilt = true;
305
340
  }
306
341
 
342
+ async #buildRscConsumer() {
343
+ const outfile = path.join(this.#paths.jsOutput, "rsc-consumer.js");
344
+ if (this.#cache.rscConsumerBuilt && fs.existsSync(outfile)) return;
345
+
346
+ const consumerFile = path.join(this.#paths.templates, "rsc-consumer.tsx");
347
+
348
+ await esbuild.build({
349
+ entryPoints: [consumerFile],
350
+ outfile,
351
+ bundle: true,
352
+ platform: "browser",
353
+ format: "iife",
354
+ target: "es2020",
355
+ sourcemap: this.#isDev,
356
+ minify: !this.#isDev,
357
+ keepNames: true,
358
+ jsx: "automatic",
359
+ plugins: [
360
+ createVendorGlobalsPlugin(),
361
+ createCssStubPlugin()
362
+ ]
363
+ });
364
+ this.#cache.rscConsumerBuilt = true;
365
+ }
366
+
307
367
  async #buildViewBundle(namespace, srcDir, outDir) {
308
368
  if (!fs.existsSync(srcDir)) {
309
- return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
369
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [], changedSources: new Set() };
310
370
  }
311
371
 
312
- const { entries, layouts, meta } = this.#analyzer.discoverEntries(srcDir);
372
+ const { entries, layouts, meta, importedBy } = this.#analyzer.discoverEntries(srcDir);
313
373
 
314
374
  if (!entries.length && !layouts.length) {
315
- return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
375
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [], changedSources: new Set() };
316
376
  }
317
377
 
318
- this.#addToManifest(entries, layouts, meta, srcDir, namespace);
378
+ // Pre-scan: compute effective prop usage for server→client boundary filtering.
379
+ // Must run before addToManifest so manifest gets effective (forward-aware) usage.
380
+ const { effectiveMap, clientPathSet } = this.#preScanPropUsage(meta);
381
+
382
+ this.#addToManifest(entries, layouts, meta, srcDir, namespace, effectiveMap);
319
383
 
320
384
  const allFiles = [...entries, ...layouts];
321
- const changedFiles = [];
385
+ const entryLayoutSet = new Set(allFiles);
386
+ const changedSources = new Set();
387
+
388
+ await Promise.all([...meta.keys()].map(async (file) => {
389
+ const stat = await fs.promises.stat(file);
390
+ const mtimeKey = file + ":mtime";
391
+ const cachedMtime = this.#cache.viewHashes.get(mtimeKey);
392
+
393
+ if (cachedMtime === stat.mtimeMs && this.#cache.viewHashes.has(file)) {
394
+ return;
395
+ }
322
396
 
323
- for (const file of allFiles) {
324
397
  const content = await fs.promises.readFile(file, "utf8");
325
398
  const hash = crypto.createHash("md5").update(content).digest("hex");
326
399
  const cachedHash = this.#cache.viewHashes.get(file);
327
400
 
401
+ this.#cache.viewHashes.set(mtimeKey, stat.mtimeMs);
402
+
328
403
  if (cachedHash !== hash) {
329
404
  this.#cache.viewHashes.set(file, hash);
330
- changedFiles.push(file);
405
+ changedSources.add(file);
406
+ }
407
+ }));
408
+
409
+ const filesToBuild = new Set();
410
+
411
+ for (const file of changedSources) {
412
+ if (entryLayoutSet.has(file)) {
413
+ filesToBuild.add(file);
414
+ }
415
+
416
+ for (const dep of this.#getTransitiveDependents(file, importedBy)) {
417
+ if (entryLayoutSet.has(dep)) {
418
+ filesToBuild.add(dep);
419
+ }
331
420
  }
332
421
  }
333
422
 
423
+ const changedFiles = [...filesToBuild];
424
+
334
425
  if (changedFiles.length) {
335
426
  this.#cache.viewsChanged = true;
336
- await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir });
427
+ await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir, effectiveMap, clientPaths: clientPathSet });
337
428
  await this.#postProcessMeta(changedFiles, srcDir, outDir);
338
429
 
339
430
  for (const entry of changedFiles) {
@@ -344,7 +435,7 @@ class Builder {
344
435
 
345
436
  this.#stats[namespace === "user" ? "user" : "framework"] = entries.length;
346
437
 
347
- return { entries, layouts, meta, srcDir, namespace, changedFiles };
438
+ return { entries, layouts, meta, srcDir, namespace, changedFiles, changedSources };
348
439
  }
349
440
 
350
441
  async #postProcessMeta(entries, srcDir, outDir) {
@@ -420,43 +511,122 @@ class Builder {
420
511
  await Promise.all(entries.map(processEntry));
421
512
  }
422
513
 
423
- async #buildHydrationBundles(userBundle, frameworkBundle, changedViews = null) {
424
- const hydrationFiles = await this.#hydrationBuilder.build(
425
- userBundle,
426
- frameworkBundle,
427
- this.#manifest,
428
- changedViews
429
- );
514
+ async #buildClientComponents(userBundle, frameworkBundle, changedViews = null) {
515
+ const clientFiles = [];
430
516
 
431
- if (!hydrationFiles.length) {
432
- return;
517
+ for (const bundle of [userBundle, frameworkBundle]) {
518
+ if (!bundle?.meta) continue;
519
+
520
+ for (const [filePath, fileMeta] of bundle.meta.entries()) {
521
+ if (!fileMeta.isClient) continue;
522
+
523
+ const relative = path.relative(bundle.srcDir, filePath)
524
+ .replace(/\.tsx$/, ".js")
525
+ .replace(/\\/g, "/");
526
+
527
+ const chunkFile = "js/" + relative;
528
+ const outputPath = path.join(this.#paths.jsOutput, relative);
529
+ const needsRebuild = !changedViews || changedViews.has(filePath);
530
+
531
+ clientFiles.push({
532
+ sourcePath: filePath,
533
+ srcDir: bundle.srcDir,
534
+ chunkId: relative.replace(/\.js$/, ""),
535
+ chunkFile,
536
+ outputPath,
537
+ needsRebuild
538
+ });
539
+ }
433
540
  }
434
541
 
435
- await this.#runEsbuild(hydrationFiles, this.#paths.jsOutput, {
436
- platform: "browser",
437
- target: "es2020",
438
- outbase: path.join(Paths.project, ".nitron/hydration"),
439
- external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime"],
440
- vendor: true,
441
- serverFunctions: true
442
- });
542
+ const toBuild = clientFiles.filter(c => c.needsRebuild || !fs.existsSync(c.outputPath));
543
+
544
+ if (toBuild.length) {
545
+ const srcDirs = new Set(toBuild.map(c => c.srcDir));
546
+
547
+ await Promise.all([...srcDirs].map(srcDir => {
548
+ const entries = toBuild.filter(c => c.srcDir === srcDir).map(c => c.sourcePath);
549
+
550
+ return this.#runEsbuild(entries, this.#paths.jsOutput, {
551
+ platform: "browser",
552
+ target: "es2020",
553
+ outbase: srcDir,
554
+ external: ["react", "react-dom", "react-dom/client", "react/jsx-runtime"],
555
+ vendor: true,
556
+ serverFunctions: true
557
+ });
558
+ }));
443
559
 
444
- this.#stats.islands = hydrationFiles.length;
560
+ this.#stats.islands = toBuild.length;
561
+ }
562
+
563
+ return clientFiles;
564
+ }
565
+
566
+ #writeClientManifest(clientComponents) {
567
+ const manifest = {};
568
+
569
+ for (const { sourcePath, chunkId, chunkFile } of clientComponents) {
570
+ const fileUrl = "file:///" + sourcePath.replace(/\\/g, "/");
571
+
572
+ manifest[fileUrl] = {
573
+ id: chunkId,
574
+ chunks: [chunkId, chunkFile],
575
+ name: "*"
576
+ };
577
+ }
578
+
579
+ const manifestPath = path.join(Paths.build, "client-manifest.json");
580
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
581
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
582
+ }
583
+
584
+ #getTransitiveDependents(file, importedBy) {
585
+ const result = new Set();
586
+ const queue = [file];
587
+ let i = 0;
588
+
589
+ while (i < queue.length) {
590
+ const current = queue[i++];
591
+ const dependents = importedBy.get(current) || [];
592
+
593
+ for (const dep of dependents) {
594
+ if (!result.has(dep)) {
595
+ result.add(dep);
596
+ queue.push(dep);
597
+ }
598
+ }
599
+ }
600
+
601
+ return result;
445
602
  }
446
603
 
447
604
  async #buildCss() {
448
- this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged);
605
+ this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged, this.#isDev);
449
606
  }
450
607
 
451
608
  async #runEsbuild(entries, outDir, options = {}) {
452
609
  if (!entries.length) return;
453
610
 
611
+ const isNode = (options.platform ?? "node") === "node";
612
+
454
613
  const plugins = [
455
614
  createCssStubPlugin(),
456
- createMarkerPlugin(options, this.#isDev),
457
615
  createPathAliasPlugin()
458
616
  ];
459
617
 
618
+ // RSC plugin only for server builds — replaces "use client" with reference stubs.
619
+ // Browser builds need the actual component code.
620
+ if (isNode) {
621
+ // Prop filter must be registered BEFORE client reference so its onLoad
622
+ // handles "nitron-filtered" namespace before createClientReferencePlugin tries.
623
+ if (options.effectiveMap && options.clientPaths) {
624
+ plugins.push(createPropFilterPlugin(options.effectiveMap, options.clientPaths));
625
+ }
626
+
627
+ plugins.push(createClientReferencePlugin(this.#isDev));
628
+ }
629
+
460
630
  if (options.vendor) {
461
631
  plugins.push(createVendorGlobalsPlugin());
462
632
  plugins.push(createServerModuleBlockerPlugin());
@@ -466,8 +636,6 @@ class Builder {
466
636
  plugins.push(createServerFunctionsPlugin());
467
637
  }
468
638
 
469
- const isNode = (options.platform ?? "node") === "node";
470
-
471
639
  const config = {
472
640
  entryPoints: entries,
473
641
  outdir: outDir,
@@ -499,7 +667,7 @@ class Builder {
499
667
  await esbuild.build(config);
500
668
  }
501
669
 
502
- #addToManifest(entries, layouts, meta, baseDir, namespace) {
670
+ #addToManifest(entries, layouts, meta, baseDir, namespace, effectiveMap) {
503
671
  const layoutSet = new Set(layouts);
504
672
 
505
673
  for (const file of entries) {
@@ -531,11 +699,26 @@ class Builder {
531
699
  cssSet.add(`/css/${path.basename(css)}`);
532
700
  }
533
701
 
534
- this.#manifest[key] = {
702
+ const entry = {
535
703
  css: [...cssSet],
536
704
  layouts: layoutChain.map(l => l.name),
537
705
  hydrationScript: null
538
706
  };
707
+
708
+ if (fileMeta?.isClient) {
709
+ const propUsage = effectiveMap.get(file) ?? null;
710
+ entry.propUsage = propUsage;
711
+
712
+ if (this.#isDev && propUsage === null) {
713
+ const rel = path.relative(Paths.project, file).replace(/\\/g, "/");
714
+ console.warn(`${COLORS.yellow}⚠ PropFilter: ${rel} — spread or dynamic prop access detected, all props will pass through${COLORS.reset}`);
715
+ }
716
+ }
717
+
718
+ // Collect translation keys from view + all transitive imports
719
+ entry.translationKeys = this.#collectTranslationKeys(file, meta);
720
+
721
+ this.#manifest[key] = entry;
539
722
  }
540
723
 
541
724
  for (const layout of layouts) {
@@ -559,15 +742,164 @@ class Builder {
559
742
  }
560
743
  }
561
744
 
745
+ /**
746
+ * Collects translation keys from a view file and all its transitive imports.
747
+ *
748
+ * Walks the import graph starting from `file`, collecting every static translation
749
+ * key found via build-time AST analysis (PropUsageAnalyzer).
750
+ *
751
+ * Returns null (= "send ALL translations") when any file in the graph uses dynamic
752
+ * keys that can't be statically determined — e.g. `t(variable)` instead of `t("key")`.
753
+ *
754
+ * @param {string} file - Entry file path (absolute).
755
+ * @param {Map} meta - File metadata from FileAnalyzer.
756
+ * @returns {string[]|null} Array of keys, or null if any file has dynamic keys.
757
+ */
758
+ #collectTranslationKeys(file, meta) {
759
+ const visited = new Set();
760
+ const allKeys = new Set();
761
+ let hasNull = false;
762
+
763
+ const walk = (filePath) => {
764
+ if (visited.has(filePath)) return;
765
+
766
+ visited.add(filePath);
767
+
768
+ const cached = this.#cache.propAnalysis.get(filePath);
769
+
770
+ if (!cached) {
771
+ // Non-JSX files (.ts, .js) don't have translation analysis — skip silently.
772
+ // JSX files without cache mean analysis failed — poison to null (send all).
773
+ if (!filePath.endsWith(".tsx") && !filePath.endsWith(".jsx")) {
774
+ return;
775
+ }
776
+ hasNull = true;
777
+ return;
778
+ }
779
+
780
+ const keys = cached.translationKeys;
781
+
782
+ // null means "dynamic keys detected" — can't filter, must send all translations
783
+ if (keys === null) {
784
+ hasNull = true;
785
+ return;
786
+ }
787
+
788
+ if (keys) {
789
+ for (const k of keys) allKeys.add(k);
790
+ }
791
+
792
+ // Walk transitive imports — resolve relative paths to absolute before lookup
793
+ const fileMeta = meta.get(filePath);
794
+
795
+ if (fileMeta?.imports) {
796
+ for (const imp of fileMeta.imports) {
797
+ const resolved = this.#analyzer.resolveImport(filePath, imp);
798
+ walk(resolved);
799
+ }
800
+ }
801
+ };
802
+
803
+ walk(file);
804
+
805
+ if (hasNull) return null;
806
+ if (allKeys.size === 0) return [];
807
+
808
+ return [...allKeys];
809
+ }
810
+
811
+ /**
812
+ * Pre-scan phase: analyzes JSX prop forwarding and computes effective prop usage.
813
+ * This enables the prop filter plugin to strip unused data at server→client boundaries.
814
+ * @param {Map} meta - File metadata from FileAnalyzer.
815
+ * @returns {{effectiveMap: Map, clientPathSet: Set}}
816
+ */
817
+ #preScanPropUsage(meta) {
818
+ const propUsageMap = new Map();
819
+ const forwardMap = new Map();
820
+ const clientPathSet = new Set();
821
+ const cache = this.#cache.propAnalysis;
822
+
823
+ for (const [filePath, fileMeta] of meta.entries()) {
824
+ const isJsx = filePath.endsWith(".tsx") || filePath.endsWith(".jsx");
825
+
826
+ if (!fileMeta.isClient && !isJsx) continue;
827
+
828
+ if (fileMeta.isClient) clientPathSet.add(filePath);
829
+
830
+ let stat;
831
+
832
+ try { stat = fs.statSync(filePath); }
833
+ catch { continue; }
834
+
835
+ const cached = cache.get(filePath);
836
+
837
+ // Fast path: mtime unchanged → skip entirely
838
+ if (cached && cached.mtime === stat.mtimeMs) {
839
+ if (fileMeta.isClient) {
840
+ propUsageMap.set(filePath, cached.propUsage);
841
+ forwardMap.set(filePath, cached.forwarding);
842
+ }
843
+ continue;
844
+ }
845
+
846
+ // mtime changed — verify with content hash to avoid stale cache
847
+ const content = fs.readFileSync(filePath, "utf8");
848
+ const hash = crypto.createHash("md5").update(content).digest("hex");
849
+
850
+ if (cached && cached.hash === hash) {
851
+ const updatedCache = { ...cached, mtime: stat.mtimeMs };
852
+ cache.set(filePath, updatedCache);
853
+
854
+ if (fileMeta.isClient) {
855
+ propUsageMap.set(filePath, cached.propUsage);
856
+ forwardMap.set(filePath, cached.forwarding);
857
+ }
858
+ continue;
859
+ }
860
+
861
+ const { propUsage, forwarding, translationKeys } = PropUsageAnalyzer.analyzeAll(filePath);
862
+
863
+ cache.set(filePath, { mtime: stat.mtimeMs, hash, propUsage, forwarding, translationKeys });
864
+
865
+ if (fileMeta.isClient) {
866
+ propUsageMap.set(filePath, propUsage);
867
+ forwardMap.set(filePath, forwarding);
868
+ }
869
+ }
870
+
871
+ if (clientPathSet.size === 0) {
872
+ return { effectiveMap: new Map(), clientPathSet };
873
+ }
874
+
875
+ const effectiveMap = EffectivePropUsage.compute(propUsageMap, forwardMap, clientPathSet);
876
+
877
+ return { effectiveMap, clientPathSet };
878
+ }
879
+
562
880
  #writeManifest() {
563
881
  const manifestPath = path.join(Paths.build, "manifest.json");
882
+ const tmpPath = manifestPath + ".tmp";
883
+ const data = JSON.stringify(this.#manifest, null, 2);
884
+
564
885
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
565
- fs.writeFileSync(manifestPath, JSON.stringify(this.#manifest, null, 2));
886
+ fs.writeFileSync(tmpPath, data);
887
+
888
+ try { fs.renameSync(tmpPath, manifestPath); }
889
+ catch {
890
+ fs.writeFileSync(manifestPath, data);
891
+ }
566
892
  }
567
893
 
568
894
  #writeJsxRuntime() {
569
- fs.mkdirSync(path.dirname(this.#paths.jsxRuntime), { recursive: true });
570
- fs.writeFileSync(this.#paths.jsxRuntime, JSX_RUNTIME);
895
+ const runtimePath = this.#paths.jsxRuntime;
896
+
897
+ if (fs.existsSync(runtimePath) && fs.readFileSync(runtimePath, "utf8") === JSX_RUNTIME) {
898
+ return;
899
+ }
900
+
901
+ fs.mkdirSync(path.dirname(runtimePath), { recursive: true });
902
+ fs.writeFileSync(runtimePath, JSX_RUNTIME);
571
903
  }
572
904
 
573
905
  #cleanOutputDirs() {