@nitronjs/framework 0.1.23 → 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 -936
  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 +15 -16
  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
@@ -0,0 +1,129 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import postcss from "postcss";
5
+ import tailwindPostcss from "@tailwindcss/postcss";
6
+
7
+ class CssBuilder {
8
+ #cache;
9
+ #isDev;
10
+ #cssInput;
11
+ #cssOutput;
12
+ #hasTailwind = null;
13
+
14
+ constructor(cache, isDev, cssInput, cssOutput) {
15
+ this.#cache = cache;
16
+ this.#isDev = isDev;
17
+ this.#cssInput = cssInput;
18
+ this.#cssOutput = cssOutput;
19
+ }
20
+
21
+ async build(viewsChanged) {
22
+ if (!fs.existsSync(this.#cssInput)) {
23
+ return 0;
24
+ }
25
+
26
+ const cssFiles = fs.readdirSync(this.#cssInput).filter(f => f.endsWith(".css"));
27
+ if (!cssFiles.length) {
28
+ return 0;
29
+ }
30
+
31
+ const hasTailwind = this.#detectTailwind();
32
+ const filesToProcess = [];
33
+
34
+ for (const filename of cssFiles) {
35
+ const filePath = path.join(this.#cssInput, filename);
36
+ const content = await fs.promises.readFile(filePath, "utf8");
37
+ const hash = crypto.createHash("md5").update(content).digest("hex");
38
+ const cachedHash = this.#cache.cssHashes.get(filePath);
39
+ const outputPath = path.join(this.#cssOutput, filename);
40
+ const outputExists = fs.existsSync(outputPath);
41
+
42
+ if (cachedHash !== hash || !outputExists) {
43
+ this.#cache.css.set(filePath, content);
44
+ this.#cache.cssHashes.set(filePath, hash);
45
+ filesToProcess.push(filename);
46
+ }
47
+ }
48
+
49
+ // If Tailwind is used and views changed, force rebuild all CSS
50
+ if (hasTailwind && viewsChanged && !filesToProcess.length) {
51
+ filesToProcess.push(...cssFiles);
52
+ }
53
+
54
+ if (!filesToProcess.length) return 0;
55
+
56
+ if (hasTailwind && !this.#cache.tailwindProcessor) {
57
+ this.#cache.tailwindProcessor = postcss([tailwindPostcss()]);
58
+ }
59
+ const processor = hasTailwind ? this.#cache.tailwindProcessor : null;
60
+
61
+ await Promise.all(filesToProcess.map(filename => this.#processCss(filename, hasTailwind, processor)));
62
+
63
+ return filesToProcess.length;
64
+ }
65
+
66
+ async #processCss(filename, hasTailwind, processor) {
67
+ const inputPath = path.join(this.#cssInput, filename);
68
+ const outputPath = path.join(this.#cssOutput, filename);
69
+
70
+ await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
71
+
72
+ let content = this.#cache.css.get(inputPath);
73
+ if (!content) {
74
+ content = await fs.promises.readFile(inputPath, "utf8");
75
+ this.#cache.css.set(inputPath, content);
76
+ }
77
+
78
+ if (!hasTailwind) {
79
+ await fs.promises.writeFile(outputPath, content);
80
+ return;
81
+ }
82
+
83
+ const result = await processor.process(content, {
84
+ from: inputPath,
85
+ to: outputPath,
86
+ map: this.#isDev ? { inline: false } : false
87
+ });
88
+
89
+ await fs.promises.writeFile(outputPath, result.css);
90
+
91
+ if (result.map) {
92
+ await fs.promises.writeFile(`${outputPath}.map`, result.map.toString());
93
+ }
94
+ }
95
+
96
+ #detectTailwind() {
97
+ // Return cached result if available
98
+ if (this.#hasTailwind !== null) {
99
+ return this.#hasTailwind;
100
+ }
101
+
102
+ if (!fs.existsSync(this.#cssInput)) {
103
+ this.#hasTailwind = false;
104
+ return false;
105
+ }
106
+
107
+ const tailwindPattern = /@(import\s+["']tailwindcss["']|tailwind\s+(base|components|utilities))/;
108
+
109
+ for (const filename of fs.readdirSync(this.#cssInput).filter(f => f.endsWith(".css"))) {
110
+ const filePath = path.join(this.#cssInput, filename);
111
+
112
+ let content = this.#cache.css.get(filePath);
113
+ if (!content) {
114
+ content = fs.readFileSync(filePath, "utf8");
115
+ this.#cache.css.set(filePath, content);
116
+ }
117
+
118
+ if (tailwindPattern.test(content)) {
119
+ this.#hasTailwind = true;
120
+ return true;
121
+ }
122
+ }
123
+
124
+ this.#hasTailwind = false;
125
+ return false;
126
+ }
127
+ }
128
+
129
+ export default CssBuilder;
@@ -0,0 +1,395 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { parse } from "@babel/parser";
4
+ import traverse from "@babel/traverse";
5
+ import Paths from "../Core/Paths.js";
6
+ import Layout from "../View/Layout.js";
7
+ import COLORS from "./colors.js";
8
+
9
+ const CLIENT_HOOKS = new Set([
10
+ "useState",
11
+ "useEffect",
12
+ "useRef",
13
+ "useReducer",
14
+ "useLayoutEffect",
15
+ "useCallback",
16
+ "useMemo"
17
+ ]);
18
+
19
+ const MAX_DEPTH = 50;
20
+
21
+ class FileAnalyzer {
22
+ #traverse = traverse.default;
23
+ #cache;
24
+
25
+ constructor(cache) {
26
+ this.#cache = cache;
27
+ }
28
+
29
+ discoverEntries(baseDir) {
30
+ if (!fs.existsSync(baseDir)) {
31
+ return { entries: [], layouts: [], meta: new Map() };
32
+ }
33
+
34
+ const files = this.#findTsxFiles(baseDir);
35
+ const { graph, imported, importedBy } = this.#buildDependencyGraph(files);
36
+
37
+ const layouts = [];
38
+ const entries = [];
39
+
40
+ for (const [file, meta] of graph.entries()) {
41
+ if (Layout.isLayout(file)) {
42
+ if (meta.hasDefault && meta.jsx) {
43
+ layouts.push(file);
44
+ }
45
+ continue;
46
+ }
47
+
48
+ if (!imported.has(file) && meta.hasDefault && meta.jsx) {
49
+ entries.push(file);
50
+ }
51
+ }
52
+
53
+ this.#validateGraph(graph, entries, importedBy);
54
+
55
+ return { entries, layouts, meta: graph };
56
+ }
57
+
58
+ #findTsxFiles(dir, result = []) {
59
+ if (!fs.existsSync(dir)) {
60
+ return result;
61
+ }
62
+
63
+ for (const item of fs.readdirSync(dir)) {
64
+ const fullPath = path.join(dir, item);
65
+ const stat = fs.lstatSync(fullPath);
66
+
67
+ if (stat.isSymbolicLink()) {
68
+ continue;
69
+ }
70
+
71
+ if (stat.isDirectory()) {
72
+ this.#findTsxFiles(fullPath, result);
73
+ } else if (fullPath.endsWith(".tsx")) {
74
+ result.push(fullPath);
75
+ }
76
+ }
77
+
78
+ return result;
79
+ }
80
+
81
+ #buildDependencyGraph(files) {
82
+ const graph = new Map();
83
+ const imported = new Set();
84
+ const importedBy = new Map();
85
+
86
+ for (const file of files) {
87
+ const meta = this.analyzeFile(file);
88
+ graph.set(file, meta);
89
+
90
+ for (const importPath of meta.imports) {
91
+ const resolvedPath = this.resolveImport(file, importPath);
92
+
93
+ if (this.#isInRoot(resolvedPath)) {
94
+ imported.add(resolvedPath);
95
+
96
+ if (!importedBy.has(resolvedPath)) {
97
+ importedBy.set(resolvedPath, []);
98
+ }
99
+ importedBy.get(resolvedPath).push(file);
100
+ }
101
+ }
102
+ }
103
+
104
+ return { graph, imported, importedBy };
105
+ }
106
+
107
+ analyzeFile(filePath) {
108
+ const stat = fs.statSync(filePath);
109
+ const mtime = stat.mtimeMs;
110
+ const cachedMtime = this.#cache.fileHashes.get(filePath + ":mtime");
111
+
112
+ if (cachedMtime === mtime && this.#cache.fileMeta.has(filePath)) {
113
+ return this.#cache.fileMeta.get(filePath);
114
+ }
115
+
116
+ const source = fs.readFileSync(filePath, "utf8");
117
+ this.#cache.fileHashes.set(filePath + ":mtime", mtime);
118
+
119
+ let ast;
120
+ try {
121
+ ast = parse(source, {
122
+ sourceType: "module",
123
+ plugins: ["typescript", "jsx"]
124
+ });
125
+ } catch {
126
+ const emptyMeta = {
127
+ imports: [],
128
+ css: [],
129
+ hasDefault: false,
130
+ named: [],
131
+ jsx: false,
132
+ needsClient: false,
133
+ isClient: false
134
+ };
135
+ this.#cache.fileMeta.set(filePath, emptyMeta);
136
+ return emptyMeta;
137
+ }
138
+
139
+ const isClient = ast.program.directives?.some(d => d.value?.value === "use client");
140
+
141
+ const meta = {
142
+ imports: new Set(),
143
+ css: new Set(),
144
+ hasDefault: false,
145
+ named: [],
146
+ jsx: false,
147
+ needsClient: false,
148
+ isClient,
149
+ reactNamespace: null,
150
+ layoutDisabled: false
151
+ };
152
+
153
+ this.#traverse(ast, {
154
+ ImportDeclaration: (p) => {
155
+ const source = p.node.source.value;
156
+
157
+ if (source.startsWith(".")) {
158
+ meta.imports.add(source);
159
+ }
160
+
161
+ if (source.endsWith(".css")) {
162
+ const resolved = path.resolve(path.dirname(filePath), source);
163
+ if (resolved.startsWith(Paths.project)) {
164
+ meta.css.add(resolved);
165
+ }
166
+ }
167
+
168
+ if (source === "react") {
169
+ for (const specifier of p.node.specifiers) {
170
+ if (specifier.type === "ImportSpecifier" && CLIENT_HOOKS.has(specifier.imported.name)) {
171
+ meta.needsClient = true;
172
+ }
173
+ if (specifier.type === "ImportNamespaceSpecifier" || specifier.type === "ImportDefaultSpecifier") {
174
+ meta.reactNamespace = specifier.local.name;
175
+ }
176
+ }
177
+ }
178
+ },
179
+
180
+ MemberExpression: (p) => {
181
+ if (!meta.needsClient &&
182
+ p.node.object.name === meta.reactNamespace &&
183
+ CLIENT_HOOKS.has(p.node.property.name)) {
184
+ meta.needsClient = true;
185
+ }
186
+ },
187
+
188
+ CallExpression: (p) => {
189
+ if (!meta.needsClient &&
190
+ p.node.callee.type === "Identifier" &&
191
+ /^use[A-Z]/.test(p.node.callee.name)) {
192
+ meta.needsClient = true;
193
+ }
194
+ },
195
+
196
+ ExportNamedDeclaration: (p) => this.#extractNamedExports(p, meta),
197
+ ExportDefaultDeclaration: () => { meta.hasDefault = true; },
198
+ JSXElement: () => { meta.jsx = true; },
199
+ JSXFragment: () => { meta.jsx = true; }
200
+ });
201
+
202
+ if (meta.needsClient && !meta.isClient) {
203
+ throw this.#createError('Missing "use client"', {
204
+ File: path.relative(Paths.project, filePath),
205
+ Fix: 'Add "use client" at top'
206
+ });
207
+ }
208
+
209
+ const result = {
210
+ ...meta,
211
+ imports: [...meta.imports],
212
+ css: [...meta.css]
213
+ };
214
+
215
+ this.#cache.fileMeta.set(filePath, result);
216
+
217
+ return result;
218
+ }
219
+
220
+ #extractNamedExports(path, meta) {
221
+ const declaration = path.node.declaration;
222
+
223
+ if (declaration?.type === "VariableDeclaration") {
224
+ for (const decl of declaration.declarations) {
225
+ if (decl.id.type === "Identifier") {
226
+ if (decl.id.name === "layout" &&
227
+ decl.init?.type === "BooleanLiteral" &&
228
+ decl.init.value === false) {
229
+ meta.layoutDisabled = true;
230
+ }
231
+ if (decl.init?.type === "ArrowFunctionExpression") {
232
+ meta.named.push(decl.id.name);
233
+ }
234
+ }
235
+ }
236
+ }
237
+
238
+ if (declaration?.type === "FunctionDeclaration" && declaration.id?.name) {
239
+ meta.named.push(declaration.id.name);
240
+ }
241
+ }
242
+
243
+ resolveImport(fromFile, relativePath) {
244
+ const cacheKey = `${fromFile}|${relativePath}`;
245
+ const cached = this.#cache.imports.get(cacheKey);
246
+
247
+ if (cached) {
248
+ return cached;
249
+ }
250
+
251
+ const resolved = path.resolve(path.dirname(fromFile), relativePath);
252
+
253
+ if (!this.#isInRoot(resolved)) {
254
+ throw this.#createError("Path Traversal", {
255
+ Import: relativePath,
256
+ Outside: resolved
257
+ });
258
+ }
259
+
260
+ const extensions = [".tsx", ".ts", ".jsx", ".js", "/index.tsx", "/index.ts", ""];
261
+
262
+ for (const ext of extensions) {
263
+ const fullPath = resolved + ext;
264
+
265
+ if (ext === "" && fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
266
+ this.#cache.imports.set(cacheKey, fullPath);
267
+ return fullPath;
268
+ }
269
+
270
+ if (ext && fs.existsSync(fullPath)) {
271
+ this.#cache.imports.set(cacheKey, fullPath);
272
+ return fullPath;
273
+ }
274
+ }
275
+
276
+ this.#cache.imports.set(cacheKey, resolved);
277
+ return resolved;
278
+ }
279
+
280
+ findClientComponents(file, meta, seen, depth = 0) {
281
+ const result = new Set();
282
+
283
+ if (depth > MAX_DEPTH || seen.has(file)) {
284
+ return result;
285
+ }
286
+ seen.add(file);
287
+
288
+ const fileMeta = meta.get(file);
289
+ if (!fileMeta) {
290
+ return result;
291
+ }
292
+
293
+ if (fileMeta.isClient) {
294
+ result.add(file);
295
+ }
296
+
297
+ for (const importPath of fileMeta.imports) {
298
+ const resolvedPath = this.resolveImport(file, importPath);
299
+
300
+ if (meta.has(resolvedPath)) {
301
+ const childComponents = this.findClientComponents(resolvedPath, meta, seen, depth + 1);
302
+ for (const component of childComponents) {
303
+ result.add(component);
304
+ }
305
+ }
306
+ }
307
+
308
+ return result;
309
+ }
310
+
311
+ collectCss(file, meta, seen, depth = 0) {
312
+ if (depth > MAX_DEPTH || seen.has(file)) {
313
+ return new Set();
314
+ }
315
+ seen.add(file);
316
+
317
+ const fileMeta = meta.get(file);
318
+ if (!fileMeta) {
319
+ return new Set();
320
+ }
321
+
322
+ const result = new Set(fileMeta.css);
323
+
324
+ for (const importPath of fileMeta.imports) {
325
+ const resolvedPath = this.resolveImport(file, importPath);
326
+
327
+ if (meta.has(resolvedPath)) {
328
+ const childCss = this.collectCss(resolvedPath, meta, seen, depth + 1);
329
+ for (const cssPath of childCss) {
330
+ result.add(cssPath);
331
+ }
332
+ }
333
+ }
334
+
335
+ return result;
336
+ }
337
+
338
+ #validateGraph(graph, entries, importedBy) {
339
+ const entrySet = new Set(entries);
340
+ const relativePath = (p) => path.relative(Paths.project, p);
341
+
342
+ for (const [filePath, meta] of graph.entries()) {
343
+ if (!meta.isClient) {
344
+ continue;
345
+ }
346
+
347
+ if (entrySet.has(filePath) && meta.hasDefault && meta.jsx) {
348
+ const importers = importedBy.get(filePath);
349
+ if (importers?.length) {
350
+ throw this.#createError("Client Entry Imported", {
351
+ Entry: relativePath(filePath),
352
+ By: relativePath(importers[0]),
353
+ Fix: "Use as island"
354
+ });
355
+ }
356
+ }
357
+
358
+ for (const importPath of meta.imports) {
359
+ const resolvedPath = this.resolveImport(filePath, importPath);
360
+ const resolvedMeta = graph.get(resolvedPath);
361
+
362
+ if (resolvedMeta && !resolvedMeta.isClient) {
363
+ throw this.#createError("Boundary Violation", {
364
+ Client: relativePath(filePath),
365
+ Server: relativePath(resolvedPath),
366
+ 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.`
367
+ });
368
+ }
369
+ }
370
+ }
371
+ }
372
+
373
+ #isInRoot(filePath) {
374
+ const normalized = path.normalize(filePath);
375
+ const projectNormalized = path.normalize(Paths.project);
376
+ const frameworkNormalized = path.normalize(Paths.framework);
377
+
378
+ return normalized.startsWith(projectNormalized + path.sep) ||
379
+ normalized === projectNormalized ||
380
+ normalized.startsWith(frameworkNormalized + path.sep) ||
381
+ normalized === frameworkNormalized;
382
+ }
383
+
384
+ #createError(title, details = {}) {
385
+ let message = `\n${COLORS.red}✖ ${title}${COLORS.reset}\n\n`;
386
+
387
+ for (const [key, value] of Object.entries(details)) {
388
+ message += ` ${key}: ${COLORS.yellow}${value}${COLORS.reset}\n`;
389
+ }
390
+
391
+ return new Error(message);
392
+ }
393
+ }
394
+
395
+ export default FileAnalyzer;
@@ -0,0 +1,173 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import Paths from "../Core/Paths.js";
5
+ import Layout from "../View/Layout.js";
6
+
7
+ function sanitizeName(name) {
8
+ return name.replace(/[^a-zA-Z0-9_$]/g, "_");
9
+ }
10
+
11
+ class HydrationBuilder {
12
+ #cache;
13
+ #isDev;
14
+ #templatesPath;
15
+ #analyzer;
16
+
17
+ constructor(cache, isDev, templatesPath, analyzer) {
18
+ this.#cache = cache;
19
+ this.#isDev = isDev;
20
+ this.#templatesPath = templatesPath;
21
+ this.#analyzer = analyzer;
22
+ }
23
+
24
+ async build(userBundle, frameworkBundle, manifest, changedViews = null) {
25
+ const hydrationFiles = [];
26
+ const allChangedFiles = new Set();
27
+
28
+ if (changedViews) {
29
+ for (const v of changedViews) allChangedFiles.add(v);
30
+ }
31
+
32
+ for (const bundle of [userBundle, frameworkBundle]) {
33
+ if (!bundle?.meta) continue;
34
+ for (const [filePath, fileMeta] of bundle.meta.entries()) {
35
+ if (fileMeta.isClient || fileMeta.needsClient) {
36
+ const content = fs.readFileSync(filePath, "utf8");
37
+ const hash = crypto.createHash("md5").update(content).digest("hex");
38
+ const cacheKey = `hydration:${filePath}`;
39
+ const cachedHash = this.#cache.fileHashes.get(cacheKey);
40
+
41
+ if (cachedHash !== hash) {
42
+ this.#cache.fileHashes.set(cacheKey, hash);
43
+ allChangedFiles.add(filePath);
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ for (const bundle of [userBundle, frameworkBundle]) {
50
+ if (!bundle?.entries.length) continue;
51
+
52
+ for (const viewPath of bundle.entries) {
53
+ const viewRelative = path.relative(bundle.srcDir, viewPath)
54
+ .replace(/\.tsx$/, "")
55
+ .replace(/\\/g, "/")
56
+ .toLowerCase();
57
+ const manifestKey = `${bundle.namespace}:${viewRelative}`;
58
+ const hydrationScriptPath = `/js/${viewRelative}.js`;
59
+ const hydrationOutputPath = path.join(Paths.publicJs, viewRelative + ".js");
60
+
61
+ const viewMeta = bundle.meta.get(viewPath);
62
+ const clientComponents = new Set();
63
+
64
+ if (!viewMeta?.layoutDisabled) {
65
+ const viewRelativeForLayout = path.relative(bundle.srcDir, viewPath).replace(/\\/g, "/");
66
+ const layoutChain = Layout.resolve(viewRelativeForLayout, bundle.srcDir);
67
+
68
+ for (const layout of layoutChain) {
69
+ const layoutClients = this.#analyzer.findClientComponents(layout.path, bundle.meta, new Set());
70
+ for (const c of layoutClients) {
71
+ clientComponents.add(c);
72
+ }
73
+ }
74
+ }
75
+
76
+ const viewClients = this.#analyzer.findClientComponents(viewPath, bundle.meta, new Set());
77
+ for (const c of viewClients) {
78
+ clientComponents.add(c);
79
+ }
80
+
81
+ const needsRebuild = !changedViews ||
82
+ allChangedFiles.has(viewPath) ||
83
+ [...clientComponents].some(c => allChangedFiles.has(c));
84
+
85
+ if (clientComponents.size) {
86
+ if (needsRebuild || !fs.existsSync(hydrationOutputPath)) {
87
+ const hydrationFile = this.#generateHydrationFile(
88
+ viewPath,
89
+ bundle.srcDir,
90
+ clientComponents,
91
+ bundle.meta
92
+ );
93
+ hydrationFiles.push(hydrationFile);
94
+ }
95
+ manifest[manifestKey].hydrationScript = hydrationScriptPath;
96
+ }
97
+ }
98
+ }
99
+
100
+ return hydrationFiles;
101
+ }
102
+
103
+ #generateHydrationFile(viewPath, srcDir, clientComponents, meta) {
104
+ const templateName = this.#isDev ? "page-hydration-dev.tsx" : "page-hydration.tsx";
105
+ const cacheKey = this.#isDev ? "hydrationTemplateDev" : "hydrationTemplate";
106
+
107
+ if (!this.#cache[cacheKey]) {
108
+ const templatePath = path.join(this.#templatesPath, templateName);
109
+ this.#cache[cacheKey] = fs.readFileSync(templatePath, "utf8");
110
+ }
111
+
112
+ const viewRelative = path.relative(srcDir, viewPath).replace(/\.tsx$/, "").toLowerCase();
113
+ const outputDir = path.join(Paths.project, ".nitron/hydration", path.dirname(viewRelative));
114
+ const outputFile = path.join(outputDir, path.basename(viewRelative) + ".tsx");
115
+ const moduleId = viewRelative.replace(/[\/\\]/g, "_");
116
+
117
+ fs.mkdirSync(outputDir, { recursive: true });
118
+
119
+ const imports = [];
120
+ const manifestEntries = [];
121
+ const registrations = [];
122
+ let index = 0;
123
+
124
+ for (const componentPath of clientComponents) {
125
+ const componentMeta = meta.get(componentPath);
126
+ if (!componentMeta) continue;
127
+
128
+ const baseName = path.basename(componentPath, ".tsx");
129
+ const relativePath = path.relative(outputDir, componentPath)
130
+ .replace(/\\/g, "/")
131
+ .replace(/\.tsx$/, "");
132
+
133
+ if (componentMeta.hasDefault) {
134
+ const importName = sanitizeName(baseName) + "_" + index++;
135
+ imports.push(`import ${importName} from "${relativePath}";`);
136
+ manifestEntries.push(` "${baseName}": ${importName}`);
137
+ manifestEntries.push(` [${importName}.displayName || ${importName}.name || "${baseName}"]: ${importName}`);
138
+ if (this.#isDev) {
139
+ registrations.push(`if (window.__NITRON_REFRESH__) window.__NITRON_REFRESH__.register(${importName}, "${moduleId}_${importName}");`);
140
+ }
141
+ }
142
+
143
+ for (const namedExport of componentMeta.named || []) {
144
+ const importName = sanitizeName(namedExport) + "_" + index++;
145
+ imports.push(`import { ${namedExport} as ${importName} } from "${relativePath}";`);
146
+ manifestEntries.push(` "${namedExport}": ${importName}`);
147
+ manifestEntries.push(` [${importName}.displayName || ${importName}.name || "${namedExport}"]: ${importName}`);
148
+ if (this.#isDev) {
149
+ registrations.push(`if (window.__NITRON_REFRESH__) window.__NITRON_REFRESH__.register(${importName}, "${moduleId}_${importName}");`);
150
+ }
151
+ }
152
+ }
153
+
154
+ let code = this.#cache[cacheKey]
155
+ .replace("// __COMPONENT_IMPORTS__", imports.join("\n"))
156
+ .replace(
157
+ "// __COMPONENT_MANIFEST__",
158
+ `Object.assign(componentManifest, {\n${manifestEntries.join(",\n")}\n});`
159
+ );
160
+
161
+ if (this.#isDev) {
162
+ code = code
163
+ .replace("__NITRON_MODULE_ID__", moduleId)
164
+ .replace("// __COMPONENT_REGISTRATIONS__", registrations.join("\n"));
165
+ }
166
+
167
+ fs.writeFileSync(outputFile, code);
168
+
169
+ return outputFile;
170
+ }
171
+ }
172
+
173
+ export default HydrationBuilder;