@nitronjs/framework 0.1.24 → 0.2.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 (37) hide show
  1. package/lib/Build/CssBuilder.js +129 -0
  2. package/lib/Build/FileAnalyzer.js +395 -0
  3. package/lib/Build/HydrationBuilder.js +173 -0
  4. package/lib/Build/Manager.js +290 -943
  5. package/lib/Build/colors.js +10 -0
  6. package/lib/Build/jsxRuntime.js +116 -0
  7. package/lib/Build/plugins.js +264 -0
  8. package/lib/Console/Commands/BuildCommand.js +6 -5
  9. package/lib/Console/Commands/DevCommand.js +151 -311
  10. package/lib/Console/Stubs/page-hydration-dev.tsx +72 -0
  11. package/lib/Console/Stubs/page-hydration.tsx +9 -10
  12. package/lib/Console/Stubs/vendor-dev.tsx +50 -0
  13. package/lib/Core/Environment.js +29 -2
  14. package/lib/Core/Paths.js +12 -4
  15. package/lib/Database/Drivers/MySQLDriver.js +5 -4
  16. package/lib/Database/QueryBuilder.js +2 -3
  17. package/lib/Filesystem/Manager.js +32 -7
  18. package/lib/HMR/Server.js +87 -0
  19. package/lib/Http/Server.js +9 -5
  20. package/lib/Logging/Manager.js +68 -18
  21. package/lib/Route/Loader.js +3 -4
  22. package/lib/Route/Manager.js +24 -3
  23. package/lib/Runtime/Entry.js +26 -1
  24. package/lib/Session/File.js +18 -7
  25. package/lib/View/Client/hmr-client.js +166 -0
  26. package/lib/View/Client/spa.js +142 -0
  27. package/lib/View/Layout.js +94 -0
  28. package/lib/View/Manager.js +390 -46
  29. package/lib/index.d.ts +55 -0
  30. package/package.json +2 -1
  31. package/skeleton/.env.example +0 -2
  32. package/skeleton/app/Controllers/HomeController.js +27 -3
  33. package/skeleton/config/app.js +15 -14
  34. package/skeleton/config/session.js +1 -1
  35. package/skeleton/globals.d.ts +3 -63
  36. package/skeleton/resources/views/Site/Home.tsx +274 -50
  37. package/skeleton/tsconfig.json +5 -1
@@ -1,170 +1,57 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from "crypto";
2
3
  import dotenv from "dotenv";
3
4
  import fs from "fs";
4
5
  import path from "path";
5
6
  import esbuild from "esbuild";
6
- import { parse } from "@babel/parser";
7
- import traverse from "@babel/traverse";
8
- import postcss from "postcss";
9
- import tailwindPostcss from "@tailwindcss/postcss";
10
7
  import Paths from "../Core/Paths.js";
8
+ import Environment from "../Core/Environment.js";
9
+ import Layout from "../View/Layout.js";
10
+ import JSX_RUNTIME from "./jsxRuntime.js";
11
+ import FileAnalyzer from "./FileAnalyzer.js";
12
+ import CssBuilder from "./CssBuilder.js";
13
+ import HydrationBuilder from "./HydrationBuilder.js";
14
+ import {
15
+ createPathAliasPlugin,
16
+ createOriginalJsxPlugin,
17
+ createVendorGlobalsPlugin,
18
+ createServerFunctionsPlugin,
19
+ createCssStubPlugin,
20
+ createMarkerPlugin,
21
+ createServerModuleBlockerPlugin
22
+ } from "./plugins.js";
23
+ import COLORS from "./colors.js";
11
24
 
12
25
  dotenv.config({ quiet: true });
13
26
 
14
- const COLORS = {
15
- reset: "\x1b[0m",
16
- dim: "\x1b[2m",
17
- red: "\x1b[31m",
18
- green: "\x1b[32m",
19
- yellow: "\x1b[33m",
20
- cyan: "\x1b[36m"
21
- };
22
-
23
- const CLIENT_HOOKS = new Set([
24
- "useState",
25
- "useEffect",
26
- "useRef",
27
- "useReducer",
28
- "useLayoutEffect",
29
- "useCallback",
30
- "useMemo"
31
- ]);
32
-
33
- const MAX_DEPTH = 50;
34
-
35
- function sanitizeName(name) {
36
- return name.replace(/[^a-zA-Z0-9_$]/g, "_");
37
- }
38
-
39
- const JSX_RUNTIME = `
40
- import * as React from 'react';
41
- import * as OriginalJsx from '__react_jsx_original__';
42
-
43
- const CTX = Symbol.for('__nitron_view_context__');
44
- const MARK = Symbol.for('__nitron_client_component__');
45
- const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
-
47
- function getContext() {
48
- return globalThis[CTX]?.getStore?.();
49
- }
50
-
51
- globalThis.csrf = () => getContext()?.csrf || '';
52
-
53
- globalThis.request = () => {
54
- const ctx = getContext();
55
- return ctx?.request || { params: {}, query: {}, url: '', method: 'GET', headers: {} };
56
- };
57
-
58
- const DepthContext = React.createContext(false);
59
- const componentCache = new WeakMap();
60
-
61
- function sanitizeProps(obj, seen = new WeakSet()) {
62
- if (obj == null) return obj;
63
-
64
- const type = typeof obj;
65
- if (type === 'function' || type === 'symbol') return undefined;
66
- if (type === 'bigint') return obj.toString();
67
- if (type !== 'object') return obj;
68
-
69
- if (seen.has(obj)) return undefined;
70
- seen.add(obj);
71
-
72
- if (Array.isArray(obj)) {
73
- return obj.map(item => {
74
- const sanitized = sanitizeProps(item, seen);
75
- return sanitized === undefined ? null : sanitized;
76
- });
77
- }
78
-
79
- if (obj instanceof Date) return obj.toISOString();
80
- if (obj._attributes && typeof obj._attributes === 'object') {
81
- return sanitizeProps(obj._attributes, seen);
82
- }
83
- if (typeof obj.toJSON === 'function') {
84
- return sanitizeProps(obj.toJSON(), seen);
85
- }
86
-
87
- const proto = Object.getPrototypeOf(obj);
88
- if (proto !== Object.prototype && proto !== null) return undefined;
89
-
90
- const result = {};
91
- for (const key of Object.keys(obj)) {
92
- if (UNSAFE_KEYS.has(key)) continue;
93
- const value = sanitizeProps(obj[key], seen);
94
- if (value !== undefined) result[key] = value;
95
- }
96
- return result;
97
- }
98
-
99
- function wrapWithDepth(children) {
100
- return OriginalJsx.jsx(DepthContext.Provider, { value: true, children });
101
- }
102
-
103
- function createIsland(Component, name) {
104
- function IslandBoundary(props) {
105
- // Nested client components render normally (already inside an island)
106
- if (React.useContext(DepthContext)) {
107
- return OriginalJsx.jsx(Component, props);
108
- }
109
-
110
- const id = React.useId();
111
- const safeProps = sanitizeProps(props) || {};
112
-
113
- const ctx = getContext();
114
- if (ctx) {
115
- ctx.props = ctx.props || {};
116
- ctx.props[id] = safeProps;
117
- }
118
-
119
- // SSR: Render full HTML for SEO and initial paint
120
- // Client: Will re-render with createRoot for animations to work
121
- return OriginalJsx.jsx('div', {
122
- 'data-cid': id,
123
- 'data-island': name,
124
- children: wrapWithDepth(OriginalJsx.jsx(Component, props))
125
- });
126
- }
127
-
128
- IslandBoundary.displayName = 'Island(' + name + ')';
129
- return IslandBoundary;
130
- }
131
-
132
- function getWrappedComponent(Component) {
133
- if (!componentCache.has(Component)) {
134
- const name = Component.displayName || Component.name || 'Anonymous';
135
- componentCache.set(Component, createIsland(Component, name));
136
- }
137
- return componentCache.get(Component);
138
- }
139
-
140
- export function jsx(type, props, key) {
141
- if (typeof type === 'function' && type[MARK]) {
142
- return OriginalJsx.jsx(getWrappedComponent(type), props, key);
143
- }
144
- return OriginalJsx.jsx(type, props, key);
145
- }
146
-
147
- export function jsxs(type, props, key) {
148
- if (typeof type === 'function' && type[MARK]) {
149
- return OriginalJsx.jsx(getWrappedComponent(type), props, key);
150
- }
151
- return OriginalJsx.jsxs(type, props, key);
152
- }
153
-
154
- export const Fragment = OriginalJsx.Fragment;
155
- `;
156
-
157
27
  class Builder {
158
- #isDev = process.env.APP_DEV === "true";
159
- #traverse = traverse.default;
28
+ #isDev = false;
160
29
  #manifest = {};
161
30
  #paths;
162
31
  #stats = { user: 0, framework: 0, islands: 0, css: 0 };
163
- #cache = { imports: new Map(), css: new Map(), hydrationTemplate: null };
32
+ #cache = {
33
+ imports: new Map(),
34
+ css: new Map(),
35
+ cssHashes: new Map(),
36
+ viewHashes: new Map(),
37
+ hydrationTemplate: null,
38
+ hydrationTemplateDev: null,
39
+ vendorBuilt: false,
40
+ spaBuilt: false,
41
+ hmrBuilt: false,
42
+ tailwindProcessor: null,
43
+ viewsChanged: false,
44
+ fileMeta: new Map(),
45
+ fileHashes: new Map()
46
+ };
47
+ #diskCachePath = path.join(Paths.nitronTemp, "build-cache.json");
48
+ #changedFiles = new Set();
49
+ #analyzer;
50
+ #cssBuilder;
51
+ #hydrationBuilder;
164
52
 
165
53
  constructor() {
166
54
  this.#paths = {
167
- // User project paths
168
55
  userViews: Paths.views,
169
56
  userOutput: Paths.buildViews,
170
57
  frameworkOutput: Paths.buildFrameworkViews,
@@ -173,40 +60,119 @@ class Builder {
173
60
  jsOutput: Paths.publicJs,
174
61
  jsxRuntime: Paths.jsxRuntime,
175
62
  nitronTemp: Paths.nitronTemp,
176
- // Framework paths
177
63
  frameworkViews: Paths.frameworkViews,
178
64
  templates: Paths.frameworkTemplates
179
65
  };
66
+
67
+ this.#analyzer = new FileAnalyzer(this.#cache);
68
+ this.#cssBuilder = new CssBuilder(this.#cache, this.#isDev, this.#paths.cssInput, this.#paths.cssOutput);
69
+ this.#hydrationBuilder = new HydrationBuilder(this.#cache, this.#isDev, this.#paths.templates, this.#analyzer);
180
70
  }
181
71
 
182
- async run(only = null) {
72
+ async run(only = null, isDev = false, silent = false) {
73
+ this.#isDev = isDev;
74
+ Environment.setDev(isDev);
75
+
183
76
  const startTime = Date.now();
77
+ this.#changedFiles.clear();
78
+ this.#manifest = {};
184
79
 
185
80
  try {
186
- console.log(`\n${COLORS.cyan}⚡ NitronJS Build${COLORS.reset}\n`);
187
-
188
- if (!only) {
189
- this.#cleanOutputDirs();
190
- }
81
+ if (this.#isDev) this.#loadDiskCache();
82
+ if (!silent) console.log(`\n${COLORS.cyan}⚡ NitronJS Build${COLORS.reset}\n`);
83
+ if (!only) this.#cleanOutputDirs();
191
84
 
85
+ this.#cache.viewsChanged = false;
192
86
  this.#writeJsxRuntime();
193
87
 
194
- await Promise.all([
195
- (!only || only === "views") ? this.#buildViews() : null,
196
- (!only || only === "css") ? this.#buildCss() : null
197
- ]);
88
+ if (!only || only === "views") await this.#buildViews();
89
+ if (!only || only === "css") await this.#buildCss();
198
90
 
91
+ if (this.#isDev) this.#saveDiskCache();
199
92
  this.#cleanupTemp();
200
- this.#printSummary(Date.now() - startTime);
201
- return true;
93
+ if (!silent) this.#printSummary(Date.now() - startTime);
94
+
95
+ return {
96
+ success: true,
97
+ changedFiles: [...this.#changedFiles],
98
+ cssChanged: this.#stats.css > 0,
99
+ viewsChanged: this.#cache.viewsChanged,
100
+ time: Date.now() - startTime
101
+ };
202
102
  } catch (error) {
203
103
  this.#cleanupTemp();
204
- console.log(`\n${COLORS.red}✖ Build failed: ${error.message}${COLORS.reset}\n`);
205
- if (this.#isDev) console.error(error);
206
- return false;
104
+ if (!silent) console.log(`\n${COLORS.red}✖ Build failed: ${error.message}${COLORS.reset}\n`);
105
+ if (this.#isDev && !silent) console.error(error);
106
+ return { success: false, error: error.message };
207
107
  }
208
108
  }
209
109
 
110
+ #loadDiskCache() {
111
+ try {
112
+ if (fs.existsSync(this.#diskCachePath)) {
113
+ const data = JSON.parse(fs.readFileSync(this.#diskCachePath, "utf8"));
114
+
115
+ if (data.fileMeta) {
116
+ for (const [key, value] of Object.entries(data.fileMeta)) {
117
+ value.imports = new Set(value.imports || []);
118
+ value.css = new Set(value.css || []);
119
+ this.#cache.fileMeta.set(key, value);
120
+ }
121
+ }
122
+ if (data.fileHashes) {
123
+ for (const [key, value] of Object.entries(data.fileHashes)) {
124
+ this.#cache.fileHashes.set(key, value);
125
+ }
126
+ }
127
+ if (data.viewHashes) {
128
+ for (const [key, value] of Object.entries(data.viewHashes)) {
129
+ this.#cache.viewHashes.set(key, value);
130
+ }
131
+ }
132
+ if (data.cssHashes) {
133
+ for (const [key, value] of Object.entries(data.cssHashes)) {
134
+ this.#cache.cssHashes.set(key, value);
135
+ }
136
+ }
137
+ }
138
+ } catch {}
139
+ }
140
+
141
+ #saveDiskCache() {
142
+ try {
143
+ const cacheDir = path.dirname(this.#diskCachePath);
144
+ if (!fs.existsSync(cacheDir)) {
145
+ fs.mkdirSync(cacheDir, { recursive: true });
146
+ }
147
+
148
+ const data = {
149
+ fileMeta: {},
150
+ fileHashes: {},
151
+ viewHashes: {},
152
+ cssHashes: {}
153
+ };
154
+
155
+ for (const [key, value] of this.#cache.fileMeta) {
156
+ data.fileMeta[key] = {
157
+ ...value,
158
+ imports: Array.from(value.imports || []),
159
+ css: Array.from(value.css || [])
160
+ };
161
+ }
162
+ for (const [key, value] of this.#cache.fileHashes) {
163
+ data.fileHashes[key] = value;
164
+ }
165
+ for (const [key, value] of this.#cache.viewHashes) {
166
+ data.viewHashes[key] = value;
167
+ }
168
+ for (const [key, value] of this.#cache.cssHashes) {
169
+ data.cssHashes[key] = value;
170
+ }
171
+
172
+ fs.writeFileSync(this.#diskCachePath, JSON.stringify(data));
173
+ } catch {}
174
+ }
175
+
210
176
  #printSummary(duration) {
211
177
  const { user, framework, islands, css } = this.#stats;
212
178
  const lines = [];
@@ -233,20 +199,46 @@ class Builder {
233
199
  }
234
200
 
235
201
  async #buildViews() {
236
- const [, userBundle, frameworkBundle] = await Promise.all([
202
+ const [, , , userBundle, frameworkBundle] = await Promise.all([
237
203
  this.#buildVendor(),
204
+ this.#buildSpaRuntime(),
205
+ this.#buildHmrClient(),
238
206
  this.#buildViewBundle("user", this.#paths.userViews, this.#paths.userOutput),
239
207
  this.#buildViewBundle("framework", this.#paths.frameworkViews, this.#paths.frameworkOutput)
240
208
  ]);
241
209
 
242
- await this.#buildHydrationBundles(userBundle, frameworkBundle);
210
+ const changedViews = new Set([
211
+ ...(userBundle.changedFiles || []),
212
+ ...(frameworkBundle.changedFiles || [])
213
+ ]);
214
+
215
+ for (const file of changedViews) {
216
+ this.#changedFiles.add(file);
217
+ }
218
+
219
+ const isFirstBuild = changedViews.size === 0 &&
220
+ (userBundle.entries.length > 0 || frameworkBundle.entries.length > 0);
221
+
222
+ await this.#buildHydrationBundles(
223
+ userBundle,
224
+ frameworkBundle,
225
+ isFirstBuild ? null : (changedViews.size > 0 ? changedViews : null)
226
+ );
227
+
243
228
  this.#writeManifest();
244
229
  }
245
230
 
246
231
  async #buildVendor() {
232
+ const outfile = path.join(this.#paths.jsOutput, "vendor.js");
233
+ if (this.#cache.vendorBuilt && fs.existsSync(outfile)) return;
234
+
235
+ const vendorFile = this.#isDev
236
+ ? path.join(this.#paths.templates, "vendor-dev.tsx")
237
+ : path.join(this.#paths.templates, "vendor.tsx");
238
+
247
239
  await esbuild.build({
248
- entryPoints: [path.join(this.#paths.templates, "vendor.tsx")],
249
- outfile: path.join(this.#paths.jsOutput, "vendor.js"),
240
+ entryPoints: [vendorFile],
241
+ outfile,
250
242
  bundle: true,
251
243
  platform: "browser",
252
244
  format: "iife",
@@ -255,26 +247,79 @@ class Builder {
255
247
  minify: !this.#isDev,
256
248
  jsx: "automatic"
257
249
  });
250
+ this.#cache.vendorBuilt = true;
251
+ }
252
+
253
+ async #buildHmrClient() {
254
+ if (!this.#isDev) return;
255
+
256
+ const outfile = path.join(this.#paths.jsOutput, "hmr.js");
257
+ if (this.#cache.hmrBuilt && fs.existsSync(outfile)) return;
258
+
259
+ await esbuild.build({
260
+ entryPoints: [path.join(Paths.frameworkLib, "View/Client/hmr-client.js")],
261
+ outfile,
262
+ bundle: false,
263
+ platform: "browser",
264
+ format: "iife",
265
+ target: "es2020",
266
+ minify: false
267
+ });
268
+ this.#cache.hmrBuilt = true;
269
+ }
270
+
271
+ async #buildSpaRuntime() {
272
+ const outfile = path.join(this.#paths.jsOutput, "spa.js");
273
+ if (this.#cache.spaBuilt && fs.existsSync(outfile)) return;
274
+
275
+ await esbuild.build({
276
+ entryPoints: [path.join(Paths.frameworkLib, "View/Client/spa.js")],
277
+ outfile,
278
+ bundle: true,
279
+ platform: "browser",
280
+ format: "iife",
281
+ target: "es2020",
282
+ minify: !this.#isDev
283
+ });
284
+ this.#cache.spaBuilt = true;
258
285
  }
259
286
 
260
287
  async #buildViewBundle(namespace, srcDir, outDir) {
261
288
  if (!fs.existsSync(srcDir)) {
262
- return { entries: [], meta: new Map(), srcDir, namespace };
289
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
263
290
  }
264
291
 
265
- const { entries, meta } = this.#discoverEntries(srcDir);
292
+ const { entries, layouts, meta } = this.#analyzer.discoverEntries(srcDir);
266
293
 
267
- if (!entries.length) {
268
- return { entries: [], meta: new Map(), srcDir, namespace };
294
+ if (!entries.length && !layouts.length) {
295
+ return { entries: [], layouts: [], meta: new Map(), srcDir, namespace, changedFiles: [] };
269
296
  }
270
297
 
271
- this.#addToManifest(entries, meta, srcDir, namespace);
272
- await this.#runEsbuild(entries, outDir, { meta, outbase: srcDir });
273
- await this.#postProcessMeta(entries, srcDir, outDir);
298
+ this.#addToManifest(entries, layouts, meta, srcDir, namespace);
299
+
300
+ const allFiles = [...entries, ...layouts];
301
+ const changedFiles = [];
302
+
303
+ for (const file of allFiles) {
304
+ const content = await fs.promises.readFile(file, "utf8");
305
+ const hash = crypto.createHash("md5").update(content).digest("hex");
306
+ const cachedHash = this.#cache.viewHashes.get(file);
307
+
308
+ if (cachedHash !== hash) {
309
+ this.#cache.viewHashes.set(file, hash);
310
+ changedFiles.push(file);
311
+ }
312
+ }
313
+
314
+ if (changedFiles.length) {
315
+ this.#cache.viewsChanged = true;
316
+ await this.#runEsbuild(changedFiles, outDir, { meta, outbase: srcDir });
317
+ await this.#postProcessMeta(changedFiles, srcDir, outDir);
318
+ }
274
319
 
275
320
  this.#stats[namespace === "user" ? "user" : "framework"] = entries.length;
276
321
 
277
- return { entries, meta, srcDir, namespace };
322
+ return { entries, layouts, meta, srcDir, namespace, changedFiles };
278
323
  }
279
324
 
280
325
  async #postProcessMeta(entries, srcDir, outDir) {
@@ -350,27 +395,13 @@ class Builder {
350
395
  await Promise.all(entries.map(processEntry));
351
396
  }
352
397
 
353
- async #buildHydrationBundles(userBundle, frameworkBundle) {
354
- const hydrationFiles = [];
355
-
356
- for (const bundle of [userBundle, frameworkBundle]) {
357
- if (!bundle?.entries.length) continue;
358
-
359
- for (const viewPath of bundle.entries) {
360
- const clientComponents = this.#findClientComponents(viewPath, bundle.meta, new Set());
361
-
362
- if (clientComponents.size) {
363
- const hydrationFile = this.#generateHydrationFile(
364
- viewPath,
365
- bundle.srcDir,
366
- clientComponents,
367
- bundle.meta,
368
- bundle.namespace
369
- );
370
- hydrationFiles.push(hydrationFile);
371
- }
372
- }
373
- }
398
+ async #buildHydrationBundles(userBundle, frameworkBundle, changedViews = null) {
399
+ const hydrationFiles = await this.#hydrationBuilder.build(
400
+ userBundle,
401
+ frameworkBundle,
402
+ this.#manifest,
403
+ changedViews
404
+ );
374
405
 
375
406
  if (!hydrationFiles.length) {
376
407
  return;
@@ -388,136 +419,26 @@ class Builder {
388
419
  this.#stats.islands = hydrationFiles.length;
389
420
  }
390
421
 
391
- #findClientComponents(file, meta, seen, depth = 0) {
392
- const result = new Set();
393
-
394
- if (depth > MAX_DEPTH || seen.has(file)) {
395
- return result;
396
- }
397
- seen.add(file);
398
-
399
- const fileMeta = meta.get(file);
400
- if (!fileMeta) {
401
- return result;
402
- }
403
-
404
- if (fileMeta.isClient) {
405
- result.add(file);
406
- }
407
-
408
- for (const importPath of fileMeta.imports) {
409
- const resolvedPath = this.#resolveImport(file, importPath);
410
-
411
- if (meta.has(resolvedPath)) {
412
- const childComponents = this.#findClientComponents(resolvedPath, meta, seen, depth + 1);
413
- for (const component of childComponents) {
414
- result.add(component);
415
- }
416
- }
417
- }
418
-
419
- return result;
420
- }
421
-
422
- #generateHydrationFile(viewPath, srcDir, clientComponents, meta, namespace) {
423
- if (!this.#cache.hydrationTemplate) {
424
- const templatePath = path.join(this.#paths.templates, "page-hydration.tsx");
425
- this.#cache.hydrationTemplate = fs.readFileSync(templatePath, "utf8");
426
- }
427
-
428
- const viewRelative = path.relative(srcDir, viewPath).replace(/\.tsx$/, "").toLowerCase();
429
- const outputDir = path.join(Paths.project, ".nitron/hydration", path.dirname(viewRelative));
430
- const outputFile = path.join(outputDir, path.basename(viewRelative) + ".tsx");
431
-
432
- fs.mkdirSync(outputDir, { recursive: true });
433
-
434
- const imports = [];
435
- const manifestEntries = [];
436
- let index = 0;
437
-
438
- for (const componentPath of clientComponents) {
439
- const componentMeta = meta.get(componentPath);
440
- if (!componentMeta) continue;
441
-
442
- const baseName = path.basename(componentPath, ".tsx");
443
- const relativePath = path.relative(outputDir, componentPath)
444
- .replace(/\\/g, "/")
445
- .replace(/\.tsx$/, "");
446
-
447
- if (componentMeta.hasDefault) {
448
- const importName = sanitizeName(baseName) + "_" + index++;
449
- imports.push(`import ${importName} from "${relativePath}";`);
450
- manifestEntries.push(` "${baseName}": ${importName}`);
451
- // Also add displayName entry
452
- manifestEntries.push(` [${importName}.displayName || ${importName}.name || "${baseName}"]: ${importName}`);
453
- }
454
-
455
- for (const namedExport of componentMeta.named || []) {
456
- const importName = sanitizeName(namedExport) + "_" + index++;
457
- imports.push(`import { ${namedExport} as ${importName} } from "${relativePath}";`);
458
- manifestEntries.push(` "${namedExport}": ${importName}`);
459
- // Also add displayName entry
460
- manifestEntries.push(` [${importName}.displayName || ${importName}.name || "${namedExport}"]: ${importName}`);
461
- }
462
- }
463
-
464
- const code = this.#cache.hydrationTemplate
465
- .replace("// __COMPONENT_IMPORTS__", imports.join("\n"))
466
- .replace(
467
- "// __COMPONENT_MANIFEST__",
468
- `Object.assign(componentManifest, {\n${manifestEntries.join(",\n")}\n});`
469
- );
470
-
471
- fs.writeFileSync(outputFile, code);
472
-
473
- const manifestKey = `${namespace}:${viewRelative.replace(/\\/g, "/")}`;
474
- if (this.#manifest[manifestKey]) {
475
- this.#manifest[manifestKey].hydrationScript = `/js/${viewRelative.replace(/\\/g, "/")}.js`;
476
- }
477
-
478
- return outputFile;
479
- }
480
-
481
422
  async #buildCss() {
482
- if (!fs.existsSync(this.#paths.cssInput)) {
483
- return;
484
- }
485
-
486
- const cssFiles = fs.readdirSync(this.#paths.cssInput).filter(f => f.endsWith(".css"));
487
- if (!cssFiles.length) {
488
- return;
489
- }
490
-
491
- await Promise.all(cssFiles.map(async (filename) => {
492
- const filePath = path.join(this.#paths.cssInput, filename);
493
- if (!this.#cache.css.has(filePath)) {
494
- const content = await fs.promises.readFile(filePath, "utf8");
495
- this.#cache.css.set(filePath, content);
496
- }
497
- }));
498
-
499
- const hasTailwind = this.#detectTailwind();
500
- const processor = hasTailwind ? postcss([tailwindPostcss()]) : null;
501
-
502
- await Promise.all(cssFiles.map(filename => this.#processCss(filename, hasTailwind, processor)));
503
-
504
- this.#stats.css = cssFiles.length;
423
+ this.#stats.css = await this.#cssBuilder.build(this.#cache.viewsChanged);
505
424
  }
506
425
 
507
426
  async #runEsbuild(entries, outDir, options = {}) {
427
+ if (!entries.length) return;
428
+
508
429
  const plugins = [
509
- this.#createCssStubPlugin(),
510
- this.#createMarkerPlugin(options),
511
- this.#createPathAliasPlugin()
430
+ createCssStubPlugin(),
431
+ createMarkerPlugin(options, this.#isDev),
432
+ createPathAliasPlugin()
512
433
  ];
513
434
 
514
435
  if (options.vendor) {
515
- plugins.push(this.#createVendorGlobalsPlugin());
516
- plugins.push(this.#createServerStubPlugin());
436
+ plugins.push(createVendorGlobalsPlugin());
437
+ plugins.push(createServerModuleBlockerPlugin());
517
438
  }
518
439
 
519
440
  if (options.serverFunctions) {
520
- plugins.push(this.#createServerFunctionsPlugin());
441
+ plugins.push(createServerFunctionsPlugin());
521
442
  }
522
443
 
523
444
  const isNode = (options.platform ?? "node") === "node";
@@ -534,7 +455,9 @@ class Builder {
534
455
  sourcemap: this.#isDev,
535
456
  minify: !this.#isDev,
536
457
  external: options.external ?? ["react", "react-dom", "react-dom/server"],
537
- plugins
458
+ plugins,
459
+ write: true,
460
+ logLevel: "silent"
538
461
  };
539
462
 
540
463
  if (isNode) {
@@ -544,655 +467,78 @@ class Builder {
544
467
 
545
468
  if (options.platform !== "browser") {
546
469
  config.alias = { "react/jsx-runtime": this.#paths.jsxRuntime };
547
- plugins.push(this.#createOriginalJsxPlugin());
470
+ plugins.push(createOriginalJsxPlugin());
548
471
  }
549
472
 
550
473
  await esbuild.build(config);
551
474
  }
552
475
 
553
- #createPathAliasPlugin() {
554
- const root = Paths.project;
555
- return {
556
- name: "path-alias",
557
- setup: (build) => {
558
- // Handle @/* alias -> ./app/* with high priority
559
- // Using filter with higher specificity to run before packages: "external"
560
- build.onResolve({ filter: /^@\// }, async (args) => {
561
- const relativePath = args.path.replace(/^@\//, "");
562
- const absolutePath = path.join(root, "app", relativePath);
563
-
564
- // Try with .js extension first, then without
565
- const extensions = [".js", ".ts", ".jsx", ".tsx", ""];
566
- for (const ext of extensions) {
567
- const fullPath = absolutePath + ext;
568
- if (fs.existsSync(fullPath)) {
569
- // Return with explicit namespace to ensure it's bundled
570
- return { path: fullPath, external: false };
571
- }
572
- }
573
-
574
- // If it's a directory, try index files
575
- if (fs.existsSync(absolutePath) && fs.statSync(absolutePath).isDirectory()) {
576
- for (const ext of [".js", ".ts", ".jsx", ".tsx"]) {
577
- const indexPath = path.join(absolutePath, "index" + ext);
578
- if (fs.existsSync(indexPath)) {
579
- return { path: indexPath, external: false };
580
- }
581
- }
582
- }
583
-
584
- return { path: absolutePath + ".js", external: false };
585
- });
586
- }
587
- };
588
- }
589
-
590
- #createOriginalJsxPlugin() {
591
- return {
592
- name: "original-jsx",
593
- setup: (build) => {
594
- build.onResolve({ filter: /^__react_jsx_original__$/ }, () => ({
595
- path: "react/jsx-runtime",
596
- external: true
597
- }));
598
- }
599
- };
600
- }
601
-
602
- #createVendorGlobalsPlugin() {
603
- const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
476
+ #addToManifest(entries, layouts, meta, baseDir, namespace) {
477
+ const layoutSet = new Set(layouts);
604
478
 
605
- const packages = {
606
- "react": "__NITRON_REACT__",
607
- "react-dom": "__NITRON_REACT_DOM__",
608
- "react-dom/client": "__NITRON_REACT_DOM_CLIENT__",
609
- "react/jsx-runtime": "__NITRON_JSX_RUNTIME__"
610
- };
479
+ for (const file of entries) {
480
+ const fileMeta = meta.get(file);
481
+ const viewPath = path.relative(baseDir, file)
482
+ .replace(/\.tsx$/, "")
483
+ .replace(/\\/g, "/");
611
484
 
612
- const patterns = Object.entries(packages).map(([pkg, global]) => ({
613
- filter: new RegExp(`^${escapeRegex(pkg)}$`),
614
- pkg,
615
- global
616
- }));
617
-
618
- return {
619
- name: "vendor-globals",
620
- setup: (build) => {
621
- for (const { filter, pkg, global } of patterns) {
622
- build.onResolve({ filter }, () => ({
623
- path: pkg,
624
- namespace: "vendor-global"
625
- }));
626
-
627
- build.onLoad({ filter, namespace: "vendor-global" }, () => ({
628
- contents: `module.exports = window.${global};`,
629
- loader: "js"
630
- }));
631
- }
632
- }
633
- };
634
- }
485
+ const key = `${namespace}:${viewPath.toLowerCase()}`;
635
486
 
636
- #createServerStubPlugin() {
637
- return {
638
- name: "server-stub",
639
- setup: (build) => {
640
- build.onResolve({ filter: /lib\/Storage\.js$/ }, (args) => ({
641
- path: args.path,
642
- namespace: "storage-stub"
643
- }));
644
-
645
- build.onLoad({ filter: /.*/, namespace: "storage-stub" }, () => ({
646
- contents: `export default { url: p => '/storage/' + (p.startsWith('/') ? p.slice(1) : p) };`,
647
- loader: "js"
648
- }));
649
-
650
- build.onResolve(
651
- { filter: /lib\/(DB|Mail|Log|Hash|Environment|Server|Model|Validator)\.js$/ },
652
- (args) => ({ path: args.path, namespace: "server-only" })
653
- );
654
-
655
- build.onLoad({ filter: /.*/, namespace: "server-only" }, (args) => {
656
- const moduleName = args.path.split("/").pop()?.replace(".js", "") || "Module";
657
- return {
658
- contents: `const err = () => { throw new Error("${moduleName} is server-only") }; export default new Proxy({}, { get: err, apply: err });`,
659
- loader: "js"
660
- };
661
- });
487
+ if (this.#manifest[key]) {
488
+ throw new Error(`Duplicate: ${key}`);
662
489
  }
663
- };
664
- }
665
490
 
666
- #createServerFunctionsPlugin() {
667
- return {
668
- name: "server-functions",
669
- setup: (build) => {
670
- build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async (args) => {
671
- if (args.path.includes("node_modules")) {
672
- return null;
673
- }
491
+ const layoutDisabled = fileMeta?.layoutDisabled === true;
492
+ const layoutChain = layoutDisabled ? [] : Layout.resolve(viewPath + ".tsx", baseDir);
674
493
 
675
- let source = await fs.promises.readFile(args.path, "utf8");
676
-
677
- if (!source.includes("csrf(") && !source.includes("route(")) {
678
- return null;
494
+ const cssSet = new Set();
495
+ for (const layout of layoutChain) {
496
+ const layoutMeta = meta.get(layout.path);
497
+ if (layoutMeta?.css) {
498
+ for (const css of layoutMeta.css) {
499
+ cssSet.add(`/css/${path.basename(css)}`);
679
500
  }
680
-
681
- source = source.replace(
682
- /\bcsrf\s*\(\s*\)/g,
683
- "window.__NITRON_RUNTIME__.csrf"
684
- );
685
-
686
- source = source.replace(
687
- /\broute\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
688
- (_, routeName) => `window.__NITRON_RUNTIME__.routes["${routeName}"]`
689
- );
690
-
691
- const ext = args.path.split(".").pop();
692
- const loader = ext === "tsx" ? "tsx" : ext === "ts" ? "ts" : ext === "jsx" ? "jsx" : "js";
693
-
694
- return { contents: source, loader };
695
- });
696
- }
697
- };
698
- }
699
-
700
- #createCssStubPlugin() {
701
- return {
702
- name: "css-stub",
703
- setup: (build) => {
704
- build.onResolve({ filter: /\.css$/ }, (args) => ({
705
- path: args.path,
706
- namespace: "css-stub"
707
- }));
708
-
709
- build.onLoad({ filter: /.*/, namespace: "css-stub" }, () => ({
710
- contents: "",
711
- loader: "js"
712
- }));
713
- }
714
- };
715
- }
716
-
717
- #createMarkerPlugin(options) {
718
- const isDev = this.#isDev;
719
-
720
- return {
721
- name: "client-marker",
722
- setup: (build) => {
723
- if (options.platform === "browser") {
724
- return;
725
501
  }
726
-
727
- build.onLoad({ filter: /\.tsx$/ }, async (args) => {
728
- const source = await fs.promises.readFile(args.path, "utf8");
729
-
730
- if (!/^\s*["']use client["']/.test(source.slice(0, 50))) {
731
- return null;
732
- }
733
-
734
- let ast;
735
- try {
736
- ast = parse(source, {
737
- sourceType: "module",
738
- plugins: ["typescript", "jsx"]
739
- });
740
- } catch {
741
- if (isDev) {
742
- console.warn(`${COLORS.yellow}⚠ Parse: ${args.path}${COLORS.reset}`);
743
- }
744
- return null;
745
- }
746
-
747
- const exports = this.#findExports(ast);
748
- if (!exports.length) {
749
- return null;
750
- }
751
-
752
- const symbolCode = `Symbol.for('__nitron_client_component__')`;
753
- let additionalCode = "\n";
754
-
755
- for (const exp of exports) {
756
- additionalCode += `try { Object.defineProperty(${exp.name}, ${symbolCode}, { value: true }); ${exp.name}.displayName = "${exp.name}"; } catch {}\n`;
757
- }
758
-
759
- return { contents: source + additionalCode, loader: "tsx" };
760
- });
761
502
  }
762
- };
763
- }
764
-
765
- async #processCss(filename, hasTailwind, processor) {
766
- const inputPath = path.join(this.#paths.cssInput, filename);
767
- const outputPath = path.join(this.#paths.cssOutput, filename);
768
503
 
769
- await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
770
-
771
- let content = this.#cache.css.get(inputPath);
772
- if (!content) {
773
- content = await fs.promises.readFile(inputPath, "utf8");
774
- this.#cache.css.set(inputPath, content);
775
- }
776
-
777
- if (!hasTailwind) {
778
- await fs.promises.writeFile(outputPath, content);
779
- return;
780
- }
781
-
782
- const result = await processor.process(content, {
783
- from: inputPath,
784
- to: outputPath,
785
- map: this.#isDev ? { inline: false } : false
786
- });
787
-
788
- await fs.promises.writeFile(outputPath, result.css);
789
-
790
- if (result.map) {
791
- await fs.promises.writeFile(`${outputPath}.map`, result.map.toString());
792
- }
793
- }
794
-
795
- #detectTailwind() {
796
- if (!fs.existsSync(this.#paths.cssInput)) {
797
- return false;
798
- }
799
-
800
- const tailwindPattern = /@(import\s+["']tailwindcss["']|tailwind\s+(base|components|utilities))/;
801
-
802
- for (const filename of fs.readdirSync(this.#paths.cssInput).filter(f => f.endsWith(".css"))) {
803
- const filePath = path.join(this.#paths.cssInput, filename);
804
-
805
- let content = this.#cache.css.get(filePath);
806
- if (!content) {
807
- content = fs.readFileSync(filePath, "utf8");
808
- this.#cache.css.set(filePath, content);
504
+ for (const css of this.#analyzer.collectCss(file, meta, new Set())) {
505
+ cssSet.add(`/css/${path.basename(css)}`);
809
506
  }
810
507
 
811
- if (tailwindPattern.test(content)) {
812
- return true;
813
- }
814
- }
815
-
816
- return false;
817
- }
818
-
819
- #discoverEntries(baseDir) {
820
- if (!fs.existsSync(baseDir)) {
821
- return { entries: [], meta: new Map() };
822
- }
823
-
824
- const files = this.#findTsxFiles(baseDir);
825
- const { graph, imported, importedBy } = this.#buildDependencyGraph(files);
826
-
827
- const entries = [...graph.entries()]
828
- .filter(([file, meta]) => !imported.has(file) && meta.hasDefault && meta.jsx)
829
- .map(([file]) => file);
830
-
831
- this.#validateGraph(graph, entries, importedBy);
832
-
833
- return { entries, meta: graph };
834
- }
835
-
836
- #findTsxFiles(dir, result = []) {
837
- if (!fs.existsSync(dir)) {
838
- return result;
839
- }
840
-
841
- for (const item of fs.readdirSync(dir)) {
842
- const fullPath = path.join(dir, item);
843
- const stat = fs.lstatSync(fullPath);
844
-
845
- if (stat.isSymbolicLink()) {
846
- continue;
847
- }
848
-
849
- if (stat.isDirectory()) {
850
- this.#findTsxFiles(fullPath, result);
851
- } else if (fullPath.endsWith(".tsx")) {
852
- result.push(fullPath);
853
- }
854
- }
855
-
856
- return result;
857
- }
858
-
859
- #buildDependencyGraph(files) {
860
- const graph = new Map();
861
- const imported = new Set();
862
- const importedBy = new Map();
863
-
864
- for (const file of files) {
865
- const meta = this.#analyzeFile(file);
866
- graph.set(file, meta);
867
-
868
- for (const importPath of meta.imports) {
869
- const resolvedPath = this.#resolveImport(file, importPath);
870
-
871
- if (this.#isInRoot(resolvedPath)) {
872
- imported.add(resolvedPath);
873
-
874
- if (!importedBy.has(resolvedPath)) {
875
- importedBy.set(resolvedPath, []);
876
- }
877
- importedBy.get(resolvedPath).push(file);
878
- }
879
- }
880
- }
881
-
882
- return { graph, imported, importedBy };
883
- }
884
-
885
- #analyzeFile(filePath) {
886
- const source = fs.readFileSync(filePath, "utf8");
887
-
888
- let ast;
889
- try {
890
- ast = parse(source, {
891
- sourceType: "module",
892
- plugins: ["typescript", "jsx"]
893
- });
894
- } catch {
895
- return {
896
- imports: [],
897
- css: [],
898
- hasDefault: false,
899
- named: [],
900
- jsx: false,
901
- needsClient: false,
902
- isClient: false
508
+ this.#manifest[key] = {
509
+ css: [...cssSet],
510
+ layouts: layoutChain.map(l => l.name.toLowerCase()),
511
+ hydrationScript: null
903
512
  };
904
513
  }
905
514
 
906
- const isClient = ast.program.directives?.some(d => d.value?.value === "use client");
907
-
908
- const meta = {
909
- imports: new Set(),
910
- css: new Set(),
911
- hasDefault: false,
912
- named: [],
913
- jsx: false,
914
- needsClient: false,
915
- isClient,
916
- reactNamespace: null
917
- };
918
-
919
- this.#traverse(ast, {
920
- ImportDeclaration: (p) => {
921
- const source = p.node.source.value;
922
-
923
- if (source.startsWith(".")) {
924
- meta.imports.add(source);
925
- }
926
-
927
- if (source.endsWith(".css")) {
928
- const resolved = path.resolve(path.dirname(filePath), source);
929
- if (resolved.startsWith(Paths.project)) {
930
- meta.css.add(resolved);
931
- }
932
- }
933
-
934
- if (source === "react") {
935
- for (const specifier of p.node.specifiers) {
936
- if (specifier.type === "ImportSpecifier" && CLIENT_HOOKS.has(specifier.imported.name)) {
937
- meta.needsClient = true;
938
- }
939
- if (specifier.type === "ImportNamespaceSpecifier" || specifier.type === "ImportDefaultSpecifier") {
940
- meta.reactNamespace = specifier.local.name;
941
- }
942
- }
943
- }
944
- },
945
-
946
- MemberExpression: (p) => {
947
- if (!meta.needsClient &&
948
- p.node.object.name === meta.reactNamespace &&
949
- CLIENT_HOOKS.has(p.node.property.name)) {
950
- meta.needsClient = true;
951
- }
952
- },
953
-
954
- CallExpression: (p) => {
955
- if (!meta.needsClient &&
956
- p.node.callee.type === "Identifier" &&
957
- /^use[A-Z]/.test(p.node.callee.name)) {
958
- meta.needsClient = true;
959
- }
960
- },
961
-
962
- ExportNamedDeclaration: (p) => this.#extractNamedExports(p, meta),
963
- ExportDefaultDeclaration: () => { meta.hasDefault = true; },
964
- JSXElement: () => { meta.jsx = true; },
965
- JSXFragment: () => { meta.jsx = true; }
966
- });
967
-
968
- if (meta.needsClient && !meta.isClient) {
969
- throw this.#createError('Missing "use client"', {
970
- File: path.relative(Paths.project, filePath),
971
- Fix: 'Add "use client" at top'
972
- });
973
- }
974
-
975
- return {
976
- ...meta,
977
- imports: [...meta.imports],
978
- css: [...meta.css]
979
- };
980
- }
981
-
982
- #extractNamedExports(path, meta) {
983
- const declaration = path.node.declaration;
984
-
985
- if (declaration?.type === "VariableDeclaration") {
986
- for (const decl of declaration.declarations) {
987
- if (decl.id.type === "Identifier" && decl.init?.type === "ArrowFunctionExpression") {
988
- meta.named.push(decl.id.name);
989
- }
990
- }
991
- }
992
-
993
- if (declaration?.type === "FunctionDeclaration" && declaration.id?.name) {
994
- meta.named.push(declaration.id.name);
995
- }
996
- }
997
-
998
- #findExports(ast) {
999
- const exports = [];
1000
-
1001
- this.#traverse(ast, {
1002
- ExportDefaultDeclaration: (p) => {
1003
- const declaration = p.node.declaration;
1004
- let name;
1005
-
1006
- if (declaration.type === "Identifier") {
1007
- name = declaration.name;
1008
- } else if (declaration.type === "FunctionDeclaration" && declaration.id?.name) {
1009
- name = declaration.id.name;
1010
- } else if (declaration.type === "CallExpression" &&
1011
- ["memo", "forwardRef", "lazy"].includes(declaration.callee?.name)) {
1012
- name = declaration.arguments[0]?.name || "__default__";
1013
- } else {
1014
- name = "__default__";
1015
- }
1016
-
1017
- exports.push({ name, isDefault: true });
1018
- },
1019
-
1020
- ExportNamedDeclaration: (p) => {
1021
- for (const specifier of p.node.specifiers || []) {
1022
- if (specifier.type === "ExportSpecifier") {
1023
- const name = specifier.exported.name === "default"
1024
- ? specifier.local.name
1025
- : specifier.exported.name;
1026
- exports.push({
1027
- name,
1028
- isDefault: specifier.exported.name === "default"
1029
- });
1030
- }
1031
- }
1032
-
1033
- const declaration = p.node.declaration;
1034
-
1035
- if (declaration?.type === "FunctionDeclaration" && declaration.id?.name) {
1036
- exports.push({ name: declaration.id.name, isDefault: false });
1037
- }
1038
-
1039
- if (declaration?.type === "VariableDeclaration") {
1040
- for (const decl of declaration.declarations) {
1041
- if (decl.id.type === "Identifier" && decl.init?.type === "ArrowFunctionExpression") {
1042
- exports.push({ name: decl.id.name, isDefault: false });
1043
- }
1044
- }
1045
- }
1046
- }
1047
- });
1048
-
1049
- return exports;
1050
- }
1051
-
1052
- #validateGraph(graph, entries, importedBy) {
1053
- const entrySet = new Set(entries);
1054
- const relativePath = (p) => path.relative(Paths.project, p);
1055
-
1056
- for (const [filePath, meta] of graph.entries()) {
1057
- if (!meta.isClient) {
1058
- continue;
1059
- }
1060
-
1061
- if (entrySet.has(filePath) && meta.hasDefault && meta.jsx) {
1062
- const importers = importedBy.get(filePath);
1063
- if (importers?.length) {
1064
- throw this.#createError("Client Entry Imported", {
1065
- Entry: relativePath(filePath),
1066
- By: relativePath(importers[0]),
1067
- Fix: "Use as island"
1068
- });
1069
- }
1070
- }
1071
-
1072
- // Next.js pattern: Client component cannot import server component
1073
- // Server component can render client component as children
1074
- for (const importPath of meta.imports) {
1075
- const resolvedPath = this.#resolveImport(filePath, importPath);
1076
- const resolvedMeta = graph.get(resolvedPath);
1077
-
1078
- // If importing a non-client component (server component)
1079
- if (resolvedMeta && !resolvedMeta.isClient) {
1080
- throw this.#createError("Boundary Violation", {
1081
- Client: relativePath(filePath),
1082
- Server: relativePath(resolvedPath),
1083
- Fix: `Server components cannot be imported into client components. Use composition pattern: render "${path.basename(resolvedPath, ".tsx")}" as a parent and pass client component as children.`
1084
- });
1085
- }
1086
- }
1087
- }
1088
- }
1089
-
1090
- #addToManifest(entries, meta, baseDir, namespace) {
1091
- for (const file of entries) {
1092
- const fileMeta = meta.get(file);
1093
- const viewPath = path.relative(baseDir, file)
515
+ for (const layout of layouts) {
516
+ const layoutPath = path.relative(baseDir, layout)
1094
517
  .replace(/\.tsx$/, "")
1095
- .toLowerCase()
1096
518
  .replace(/\\/g, "/");
1097
519
 
1098
- const key = `${namespace}:${viewPath}`;
520
+ const key = `${namespace}:layout:${layoutPath.toLowerCase()}`;
1099
521
 
1100
522
  if (this.#manifest[key]) {
1101
- throw new Error(`Duplicate: ${key}`);
523
+ throw new Error(`Duplicate layout: ${key}`);
1102
524
  }
1103
525
 
1104
- const cssFiles = [...this.#collectCss(file, meta, new Set())]
526
+ const cssFiles = [...this.#analyzer.collectCss(layout, meta, new Set())]
1105
527
  .map(cssPath => `/css/${path.basename(cssPath)}`);
1106
528
 
1107
529
  this.#manifest[key] = {
1108
530
  css: cssFiles,
1109
- hydrationScript: null
531
+ isLayout: true
1110
532
  };
1111
533
  }
1112
534
  }
1113
535
 
1114
536
  #writeManifest() {
1115
- const manifestPath = path.join(Paths.project, "build", "manifest.json");
537
+ const manifestPath = path.join(Paths.build, "manifest.json");
1116
538
  fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
1117
539
  fs.writeFileSync(manifestPath, JSON.stringify(this.#manifest, null, 2));
1118
540
  }
1119
541
 
1120
- #collectCss(file, meta, seen, depth = 0) {
1121
- if (depth > MAX_DEPTH || seen.has(file)) {
1122
- return new Set();
1123
- }
1124
- seen.add(file);
1125
-
1126
- const fileMeta = meta.get(file);
1127
- if (!fileMeta) {
1128
- return new Set();
1129
- }
1130
-
1131
- const result = new Set(fileMeta.css);
1132
-
1133
- for (const importPath of fileMeta.imports) {
1134
- const resolvedPath = this.#resolveImport(file, importPath);
1135
-
1136
- if (meta.has(resolvedPath)) {
1137
- const childCss = this.#collectCss(resolvedPath, meta, seen, depth + 1);
1138
- for (const cssPath of childCss) {
1139
- result.add(cssPath);
1140
- }
1141
- }
1142
- }
1143
-
1144
- return result;
1145
- }
1146
-
1147
- #resolveImport(fromFile, relativePath) {
1148
- const cacheKey = `${fromFile}|${relativePath}`;
1149
- const cached = this.#cache.imports.get(cacheKey);
1150
-
1151
- if (cached) {
1152
- return cached;
1153
- }
1154
-
1155
- const resolved = path.resolve(path.dirname(fromFile), relativePath);
1156
-
1157
- if (!this.#isInRoot(resolved)) {
1158
- throw this.#createError("Path Traversal", {
1159
- Import: relativePath,
1160
- Outside: resolved
1161
- });
1162
- }
1163
-
1164
- const extensions = [".tsx", ".ts", ".jsx", ".js", "/index.tsx", "/index.ts", ""];
1165
-
1166
- for (const ext of extensions) {
1167
- const fullPath = resolved + ext;
1168
-
1169
- if (ext === "" && fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
1170
- this.#cache.imports.set(cacheKey, fullPath);
1171
- return fullPath;
1172
- }
1173
-
1174
- if (ext && fs.existsSync(fullPath)) {
1175
- this.#cache.imports.set(cacheKey, fullPath);
1176
- return fullPath;
1177
- }
1178
- }
1179
-
1180
- this.#cache.imports.set(cacheKey, resolved);
1181
- return resolved;
1182
- }
1183
-
1184
- #isInRoot(filePath) {
1185
- const normalized = path.normalize(filePath);
1186
- const projectNormalized = path.normalize(Paths.project);
1187
- const frameworkNormalized = path.normalize(Paths.framework);
1188
-
1189
- // Allow both project paths and framework paths
1190
- return normalized.startsWith(projectNormalized + path.sep) ||
1191
- normalized === projectNormalized ||
1192
- normalized.startsWith(frameworkNormalized + path.sep) ||
1193
- normalized === frameworkNormalized;
1194
- }
1195
-
1196
542
  #writeJsxRuntime() {
1197
543
  fs.mkdirSync(path.dirname(this.#paths.jsxRuntime), { recursive: true });
1198
544
  fs.writeFileSync(this.#paths.jsxRuntime, JSX_RUNTIME);
@@ -1205,8 +551,11 @@ class Builder {
1205
551
  this.#paths.nitronTemp
1206
552
  ];
1207
553
 
554
+ const projectDir = path.normalize(Paths.project) + path.sep;
555
+
1208
556
  for (const dir of dirsToClean) {
1209
- if (!dir.startsWith(Paths.project)) {
557
+ const normalizedDir = path.normalize(dir);
558
+ if (!normalizedDir.startsWith(projectDir)) {
1210
559
  throw new Error(`Unsafe path: ${dir}`);
1211
560
  }
1212
561
 
@@ -1217,22 +566,20 @@ class Builder {
1217
566
  }
1218
567
 
1219
568
  #cleanupTemp() {
1220
- if (fs.existsSync(this.#paths.nitronTemp)) {
1221
- if (!this.#paths.nitronTemp.startsWith(Paths.project)) {
1222
- throw new Error(`Unsafe path: ${this.#paths.nitronTemp}`);
1223
- }
1224
- fs.rmSync(this.#paths.nitronTemp, { recursive: true, force: true });
1225
- }
1226
- }
1227
-
1228
- #createError(title, details = {}) {
1229
- let message = `\n${COLORS.red}✖ ${title}${COLORS.reset}\n\n`;
569
+ const projectDir = path.normalize(Paths.project) + path.sep;
570
+ const normalizedTemp = path.normalize(this.#paths.nitronTemp);
1230
571
 
1231
- for (const [key, value] of Object.entries(details)) {
1232
- message += ` ${key}: ${COLORS.yellow}${value}${COLORS.reset}\n`;
572
+ if (!normalizedTemp.startsWith(projectDir)) {
573
+ throw new Error(`Unsafe path: ${this.#paths.nitronTemp}`);
1233
574
  }
1234
575
 
1235
- return new Error(message);
576
+ if (fs.existsSync(this.#paths.nitronTemp)) {
577
+ for (const entry of fs.readdirSync(this.#paths.nitronTemp)) {
578
+ if (entry === "build-cache.json") continue;
579
+ const fullPath = path.join(this.#paths.nitronTemp, entry);
580
+ fs.rmSync(fullPath, { recursive: true, force: true });
581
+ }
582
+ }
1236
583
  }
1237
584
  }
1238
585