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