bertui 1.2.5 → 1.2.7
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/package.json +1 -1
- package/src/build/compiler/file-transpiler.js +6 -1
- package/src/build/compiler/router-generator.js +14 -1
- package/src/build/generators/html-generator.js +36 -209
- package/src/build/server-island-validator.js +65 -46
- package/src/build.js +31 -8
- package/src/logger/logger.js +29 -5
- package/src/serve.js +2 -4
- package/src/server/dev-server-utils.js +9 -6
package/package.json
CHANGED
|
@@ -187,11 +187,16 @@ function _usesJSX(code) {
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
function _removeCSSImports(code) {
|
|
190
|
+
// Replace CSS module imports with a Proxy so styles.foo = 'foo' at runtime
|
|
191
|
+
code = code.replace(
|
|
192
|
+
/import\s+(\w+)\s+from\s+['"][^'"]*\.module\.css['"];?\s*/g,
|
|
193
|
+
(_, varName) => `const ${varName} = new Proxy({}, { get: (_, k) => k });\n`
|
|
194
|
+
);
|
|
195
|
+
// Strip plain CSS imports entirely
|
|
190
196
|
code = code.replace(/import\s+['"][^'"]*\.css['"];?\s*/g, '');
|
|
191
197
|
code = code.replace(/import\s+['"]bertui\/styles['"]\s*;?\s*/g, '');
|
|
192
198
|
return code;
|
|
193
199
|
}
|
|
194
|
-
|
|
195
200
|
function _fixBuildImports(code, srcPath, outPath, root) {
|
|
196
201
|
const buildDir = join(root, '.bertuibuild');
|
|
197
202
|
const routerPath = join(buildDir, 'router.js');
|
|
@@ -14,6 +14,7 @@ export async function generateBuildRouter(routes, buildDir) {
|
|
|
14
14
|
}).join(',\n');
|
|
15
15
|
|
|
16
16
|
const routerCode = `import React, { useState, useEffect, createContext, useContext } from 'react';
|
|
17
|
+
import { createRoot } from 'react-dom/client';
|
|
17
18
|
|
|
18
19
|
const RouterContext = createContext(null);
|
|
19
20
|
|
|
@@ -98,7 +99,19 @@ ${imports}
|
|
|
98
99
|
|
|
99
100
|
export const routes = [
|
|
100
101
|
${routeConfigs}
|
|
101
|
-
]
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
// Guard against double-mount. router.js is imported by main.js — if it ever
|
|
105
|
+
// gets evaluated more than once (e.g. duplicate script tags, HMR quirks),
|
|
106
|
+
// this ensures React never calls createRoot on the same container twice,
|
|
107
|
+
// which causes "Node.removeChild: The node to be removed is not a child" crashes.
|
|
108
|
+
if (!window.__BERTUI_MOUNTED__) {
|
|
109
|
+
window.__BERTUI_MOUNTED__ = true;
|
|
110
|
+
const container = document.getElementById('root');
|
|
111
|
+
const app = React.createElement(Router, { routes });
|
|
112
|
+
createRoot(container).render(app);
|
|
113
|
+
}
|
|
114
|
+
`;
|
|
102
115
|
|
|
103
116
|
await Bun.write(join(buildDir, 'router.js'), routerCode);
|
|
104
117
|
}
|
|
@@ -1,54 +1,45 @@
|
|
|
1
|
-
// bertui/src/build/generators/html-generator.js
|
|
1
|
+
// bertui/src/build/generators/html-generator.js
|
|
2
2
|
import { join, relative } from 'path';
|
|
3
3
|
import { mkdirSync, existsSync, cpSync } from 'fs';
|
|
4
4
|
import logger from '../../logger/logger.js';
|
|
5
5
|
import { extractMetaFromSource } from '../../utils/meta-extractor.js';
|
|
6
6
|
|
|
7
|
-
export async function generateProductionHTML(root, outDir, buildResult, routes, serverIslands, config) {
|
|
8
|
-
const mainBundle = buildResult.outputs.find(o =>
|
|
7
|
+
export async function generateProductionHTML(root, outDir, buildDir, buildResult, routes, serverIslands, config) {
|
|
8
|
+
const mainBundle = buildResult.outputs.find(o =>
|
|
9
9
|
o.path.includes('main') && o.kind === 'entry-point'
|
|
10
10
|
);
|
|
11
|
-
|
|
11
|
+
|
|
12
12
|
if (!mainBundle) {
|
|
13
13
|
logger.error('❌ Could not find main bundle');
|
|
14
14
|
return;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
|
|
17
17
|
const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/');
|
|
18
18
|
const defaultMeta = config.meta || {};
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
const bertuiPackages = await copyBertuiPackagesToProduction(root, outDir);
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
logger.info(`📄 Generating HTML for ${routes.length} routes...`);
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
const BATCH_SIZE = 5;
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
for (let i = 0; i < routes.length; i += BATCH_SIZE) {
|
|
27
27
|
const batch = routes.slice(i, i + BATCH_SIZE);
|
|
28
|
-
logger.debug(`Processing batch ${Math.floor(i/BATCH_SIZE) + 1}/${Math.ceil(routes.length/BATCH_SIZE)}`);
|
|
29
|
-
|
|
28
|
+
logger.debug(`Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(routes.length / BATCH_SIZE)}`);
|
|
30
29
|
for (const route of batch) {
|
|
31
|
-
await processSingleRoute(route,
|
|
30
|
+
await processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages);
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
|
-
|
|
33
|
+
|
|
35
34
|
logger.success(`✅ HTML generation complete for ${routes.length} routes`);
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
async function copyBertuiPackagesToProduction(root, outDir) {
|
|
39
38
|
const nodeModulesDir = join(root, 'node_modules');
|
|
40
|
-
const packages = {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
if (!existsSync(nodeModulesDir)) {
|
|
47
|
-
logger.debug('node_modules not found, skipping package copy');
|
|
48
|
-
return packages;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Copy bertui-icons
|
|
39
|
+
const packages = { bertuiIcons: false, bertuiAnimate: false, elysiaEden: false };
|
|
40
|
+
|
|
41
|
+
if (!existsSync(nodeModulesDir)) return packages;
|
|
42
|
+
|
|
52
43
|
const bertuiIconsSource = join(nodeModulesDir, 'bertui-icons');
|
|
53
44
|
if (existsSync(bertuiIconsSource)) {
|
|
54
45
|
try {
|
|
@@ -61,14 +52,12 @@ async function copyBertuiPackagesToProduction(root, outDir) {
|
|
|
61
52
|
logger.error(`Failed to copy bertui-icons: ${error.message}`);
|
|
62
53
|
}
|
|
63
54
|
}
|
|
64
|
-
|
|
65
|
-
// Copy bertui-animate CSS files
|
|
55
|
+
|
|
66
56
|
const bertuiAnimateSource = join(nodeModulesDir, 'bertui-animate', 'dist');
|
|
67
57
|
if (existsSync(bertuiAnimateSource)) {
|
|
68
58
|
try {
|
|
69
59
|
const bertuiAnimateDest = join(outDir, 'css');
|
|
70
60
|
mkdirSync(bertuiAnimateDest, { recursive: true });
|
|
71
|
-
|
|
72
61
|
const minCSSPath = join(bertuiAnimateSource, 'bertui-animate.min.css');
|
|
73
62
|
if (existsSync(minCSSPath)) {
|
|
74
63
|
cpSync(minCSSPath, join(bertuiAnimateDest, 'bertui-animate.min.css'));
|
|
@@ -80,7 +69,6 @@ async function copyBertuiPackagesToProduction(root, outDir) {
|
|
|
80
69
|
}
|
|
81
70
|
}
|
|
82
71
|
|
|
83
|
-
// Copy @elysiajs/eden
|
|
84
72
|
const elysiaEdenSource = join(nodeModulesDir, '@elysiajs', 'eden');
|
|
85
73
|
if (existsSync(elysiaEdenSource)) {
|
|
86
74
|
try {
|
|
@@ -93,33 +81,18 @@ async function copyBertuiPackagesToProduction(root, outDir) {
|
|
|
93
81
|
logger.error(`Failed to copy @elysiajs/eden: ${error.message}`);
|
|
94
82
|
}
|
|
95
83
|
}
|
|
96
|
-
|
|
84
|
+
|
|
97
85
|
return packages;
|
|
98
86
|
}
|
|
99
87
|
|
|
100
|
-
async function processSingleRoute(route,
|
|
88
|
+
async function processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages) {
|
|
101
89
|
try {
|
|
102
90
|
const sourceCode = await Bun.file(route.path).text();
|
|
103
91
|
const pageMeta = extractMetaFromSource(sourceCode);
|
|
104
92
|
const meta = { ...defaultMeta, ...pageMeta };
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
let staticHTML = '';
|
|
109
|
-
|
|
110
|
-
if (isServerIsland) {
|
|
111
|
-
logger.info(`🏝️ Extracting static content: ${route.route}`);
|
|
112
|
-
staticHTML = await extractStaticHTMLFromComponent(sourceCode, route.path);
|
|
113
|
-
|
|
114
|
-
if (staticHTML) {
|
|
115
|
-
logger.success(`✅ Server Island rendered: ${route.route}`);
|
|
116
|
-
} else {
|
|
117
|
-
logger.warn(`⚠️ Could not extract HTML, falling back to client-only`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const html = generateHTML(meta, route, bundlePath, staticHTML, isServerIsland, bertuiPackages);
|
|
122
|
-
|
|
93
|
+
|
|
94
|
+
const html = generateHTML(meta, bundlePath, bertuiPackages);
|
|
95
|
+
|
|
123
96
|
let htmlPath;
|
|
124
97
|
if (route.route === '/') {
|
|
125
98
|
htmlPath = join(outDir, 'index.html');
|
|
@@ -128,208 +101,62 @@ async function processSingleRoute(route, serverIslands, config, defaultMeta, bun
|
|
|
128
101
|
mkdirSync(routeDir, { recursive: true });
|
|
129
102
|
htmlPath = join(routeDir, 'index.html');
|
|
130
103
|
}
|
|
131
|
-
|
|
104
|
+
|
|
132
105
|
await Bun.write(htmlPath, html);
|
|
133
|
-
|
|
134
|
-
if (isServerIsland) {
|
|
135
|
-
logger.success(`✅ Server Island: ${route.route} (instant content!)`);
|
|
136
|
-
} else {
|
|
137
|
-
logger.success(`✅ Client-only: ${route.route}`);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
} catch (error) {
|
|
141
|
-
logger.error(`Failed HTML for ${route.route}: ${error.message}`);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
106
|
+
logger.success(`✅ ${route.route}`);
|
|
144
107
|
|
|
145
|
-
async function extractStaticHTMLFromComponent(sourceCode, filePath) {
|
|
146
|
-
try {
|
|
147
|
-
const returnMatch = sourceCode.match(/return\s*\(/);
|
|
148
|
-
if (!returnMatch) {
|
|
149
|
-
logger.warn(`⚠️ Could not find return statement in ${filePath}`);
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const codeBeforeReturn = sourceCode.substring(0, returnMatch.index);
|
|
154
|
-
const jsxContent = sourceCode.substring(returnMatch.index);
|
|
155
|
-
|
|
156
|
-
const hookPatterns = [
|
|
157
|
-
'useState', 'useEffect', 'useContext', 'useReducer',
|
|
158
|
-
'useCallback', 'useMemo', 'useRef', 'useImperativeHandle',
|
|
159
|
-
'useLayoutEffect', 'useDebugValue'
|
|
160
|
-
];
|
|
161
|
-
|
|
162
|
-
let hasHooks = false;
|
|
163
|
-
for (const hook of hookPatterns) {
|
|
164
|
-
const regex = new RegExp(`\\b${hook}\\s*\\(`, 'g');
|
|
165
|
-
if (regex.test(codeBeforeReturn)) {
|
|
166
|
-
logger.error(`❌ Server Island at ${filePath} contains React hooks!`);
|
|
167
|
-
logger.error(` Server Islands must be pure HTML - no ${hook}, etc.`);
|
|
168
|
-
hasHooks = true;
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (hasHooks) return null;
|
|
174
|
-
|
|
175
|
-
const importLines = codeBeforeReturn.split('\n')
|
|
176
|
-
.filter(line => line.trim().startsWith('import'))
|
|
177
|
-
.join('\n');
|
|
178
|
-
|
|
179
|
-
const hasRouterImport = /from\s+['"]bertui\/router['"]/m.test(importLines);
|
|
180
|
-
|
|
181
|
-
if (hasRouterImport) {
|
|
182
|
-
logger.error(`❌ Server Island at ${filePath} imports from 'bertui/router'!`);
|
|
183
|
-
logger.error(` Server Islands cannot use Link - use <a> tags instead.`);
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const eventHandlers = [
|
|
188
|
-
'onClick=', 'onChange=', 'onSubmit=', 'onInput=', 'onFocus=',
|
|
189
|
-
'onBlur=', 'onMouseEnter=', 'onMouseLeave=', 'onKeyDown=',
|
|
190
|
-
'onKeyUp=', 'onScroll='
|
|
191
|
-
];
|
|
192
|
-
|
|
193
|
-
for (const handler of eventHandlers) {
|
|
194
|
-
if (jsxContent.includes(handler)) {
|
|
195
|
-
logger.error(`❌ Server Island uses event handler: ${handler.replace('=', '')}`);
|
|
196
|
-
logger.error(` Server Islands are static HTML - no interactivity allowed`);
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const fullReturnMatch = sourceCode.match(/return\s*\(([\s\S]*?)\);?\s*}/);
|
|
202
|
-
if (!fullReturnMatch) {
|
|
203
|
-
logger.warn(`⚠️ Could not extract JSX from ${filePath}`);
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
let html = fullReturnMatch[1].trim();
|
|
208
|
-
|
|
209
|
-
html = html.replace(/\{\/\*[\s\S]*?\*\/\}/g, '');
|
|
210
|
-
html = html.replace(/className=/g, 'class=');
|
|
211
|
-
|
|
212
|
-
html = html.replace(/style=\{\{([^}]+)\}\}/g, (match, styleObj) => {
|
|
213
|
-
const props = [];
|
|
214
|
-
let currentProp = '';
|
|
215
|
-
let depth = 0;
|
|
216
|
-
|
|
217
|
-
for (let i = 0; i < styleObj.length; i++) {
|
|
218
|
-
const char = styleObj[i];
|
|
219
|
-
if (char === '(') depth++;
|
|
220
|
-
if (char === ')') depth--;
|
|
221
|
-
|
|
222
|
-
if (char === ',' && depth === 0) {
|
|
223
|
-
props.push(currentProp.trim());
|
|
224
|
-
currentProp = '';
|
|
225
|
-
} else {
|
|
226
|
-
currentProp += char;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
if (currentProp.trim()) props.push(currentProp.trim());
|
|
230
|
-
|
|
231
|
-
const cssString = props
|
|
232
|
-
.map(prop => {
|
|
233
|
-
const colonIndex = prop.indexOf(':');
|
|
234
|
-
if (colonIndex === -1) return '';
|
|
235
|
-
|
|
236
|
-
const key = prop.substring(0, colonIndex).trim();
|
|
237
|
-
const value = prop.substring(colonIndex + 1).trim();
|
|
238
|
-
|
|
239
|
-
if (!key || !value) return '';
|
|
240
|
-
|
|
241
|
-
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
242
|
-
const cssValue = value.replace(/['"]/g, '');
|
|
243
|
-
|
|
244
|
-
return `${cssKey}: ${cssValue}`;
|
|
245
|
-
})
|
|
246
|
-
.filter(Boolean)
|
|
247
|
-
.join('; ');
|
|
248
|
-
|
|
249
|
-
return `style="${cssString}"`;
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
|
|
253
|
-
'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
|
254
|
-
|
|
255
|
-
html = html.replace(/<(\w+)([^>]*)\s*\/>/g, (match, tag, attrs) => {
|
|
256
|
-
if (voidElements.includes(tag.toLowerCase())) {
|
|
257
|
-
return match;
|
|
258
|
-
} else {
|
|
259
|
-
return `<${tag}${attrs}></${tag}>`;
|
|
260
|
-
}
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
html = html.replace(/\{`([^`]*)`\}/g, '$1');
|
|
264
|
-
html = html.replace(/\{(['"])(.*?)\1\}/g, '$2');
|
|
265
|
-
html = html.replace(/\{(\d+)\}/g, '$1');
|
|
266
|
-
|
|
267
|
-
logger.info(` Extracted ${html.length} chars of static HTML`);
|
|
268
|
-
return html;
|
|
269
|
-
|
|
270
108
|
} catch (error) {
|
|
271
|
-
logger.error(`Failed
|
|
272
|
-
|
|
109
|
+
logger.error(`Failed HTML for ${route.route}: ${error.message}`);
|
|
110
|
+
console.error(error);
|
|
273
111
|
}
|
|
274
112
|
}
|
|
275
113
|
|
|
276
|
-
function generateHTML(meta,
|
|
277
|
-
const
|
|
278
|
-
? `<div id="root">${staticHTML}</div>`
|
|
279
|
-
: '<div id="root"></div>';
|
|
280
|
-
|
|
281
|
-
const comment = isServerIsland
|
|
282
|
-
? '<!-- 🏝️ Server Island: Static content rendered at build time -->'
|
|
283
|
-
: '<!-- ⚡ Client-only: Content rendered by JavaScript -->';
|
|
284
|
-
|
|
285
|
-
const bertuiIconsImport = bertuiPackages.bertuiIcons
|
|
114
|
+
function generateHTML(meta, bundlePath, bertuiPackages = {}) {
|
|
115
|
+
const bertuiIconsImport = bertuiPackages.bertuiIcons
|
|
286
116
|
? ',\n "bertui-icons": "/node_modules/bertui-icons/generated/index.js"'
|
|
287
117
|
: '';
|
|
288
|
-
|
|
118
|
+
|
|
289
119
|
const bertuiAnimateCSS = bertuiPackages.bertuiAnimate
|
|
290
120
|
? ' <link rel="stylesheet" href="/css/bertui-animate.min.css">'
|
|
291
121
|
: '';
|
|
292
122
|
|
|
293
|
-
// ✅ NEW: @elysiajs/eden local import map
|
|
294
123
|
const elysiaEdenImport = bertuiPackages.elysiaEden
|
|
295
124
|
? ',\n "@elysiajs/eden": "/node_modules/@elysiajs/eden/dist/index.mjs"'
|
|
296
125
|
: '';
|
|
297
|
-
|
|
126
|
+
|
|
298
127
|
return `<!DOCTYPE html>
|
|
299
128
|
<html lang="${meta.lang || 'en'}">
|
|
300
129
|
<head>
|
|
301
130
|
<meta charset="UTF-8">
|
|
302
131
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
303
132
|
<title>${meta.title || 'BertUI App'}</title>
|
|
304
|
-
|
|
305
133
|
<meta name="description" content="${meta.description || 'Built with BertUI'}">
|
|
306
134
|
${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
|
|
307
135
|
${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
|
|
308
136
|
${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
|
|
309
|
-
|
|
310
137
|
<meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
|
|
311
138
|
<meta property="og:description" content="${meta.ogDescription || meta.description || 'Built with BertUI'}">
|
|
312
139
|
${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
|
|
313
|
-
|
|
314
140
|
<link rel="stylesheet" href="/styles/bertui.min.css">
|
|
315
141
|
${bertuiAnimateCSS}
|
|
316
142
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
317
|
-
|
|
318
143
|
<script type="importmap">
|
|
319
144
|
{
|
|
320
145
|
"imports": {
|
|
321
146
|
"react": "https://esm.sh/react@18.2.0",
|
|
322
147
|
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
|
323
|
-
"@bunnyx/api": "/bunnyx-api/api-client.js",
|
|
324
148
|
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
325
|
-
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime"
|
|
149
|
+
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
|
|
150
|
+
"react/jsx-dev-runtime": "https://esm.sh/react@18.2.0/jsx-dev-runtime",
|
|
151
|
+
"@bunnyx/api": "/bunnyx-api/api-client.js",
|
|
152
|
+
"@elysiajs/eden": "/node_modules/@elysiajs/eden/dist/index.mjs"${bertuiIconsImport}${elysiaEdenImport}
|
|
326
153
|
}
|
|
327
154
|
}
|
|
328
155
|
</script>
|
|
156
|
+
<script>window.__BERTUI_HYDRATE__ = false;</script>
|
|
329
157
|
</head>
|
|
330
158
|
<body>
|
|
331
|
-
|
|
332
|
-
${rootContent}
|
|
159
|
+
<div id="root"></div>
|
|
333
160
|
<script type="module" src="/${bundlePath}"></script>
|
|
334
161
|
</body>
|
|
335
162
|
</html>`;
|
|
@@ -12,30 +12,52 @@ import logger from '../logger/logger.js';
|
|
|
12
12
|
export function validateServerIsland(sourceCode, filePath) {
|
|
13
13
|
const errors = [];
|
|
14
14
|
|
|
15
|
-
//
|
|
15
|
+
// SUPER AGGRESSIVE STRIPPING: Remove EVERYTHING that could be a false positive
|
|
16
|
+
|
|
17
|
+
// First, remove all JSX prop values that contain code examples
|
|
18
|
+
let cleanedCode = sourceCode
|
|
19
|
+
// Remove the entire content of <Code> components (most common culprit)
|
|
20
|
+
.replace(/<Code[^>]*>[\s\S]*?<\/Code>/g, '')
|
|
21
|
+
// Remove the entire content of <InlineCode> components
|
|
22
|
+
.replace(/<InlineCode[^>]*>[\s\S]*?<\/InlineCode>/g, '')
|
|
23
|
+
// Remove any JSX expression that looks like it contains code
|
|
24
|
+
.replace(/\{`[\s\S]*?`\}/g, '{}')
|
|
25
|
+
.replace(/\{[\s\S]*?import[\s\S]*?\}/g, '{}')
|
|
26
|
+
.replace(/\{[\s\S]*?useState[\s\S]*?\}/g, '{}')
|
|
27
|
+
.replace(/\{[\s\S]*?useEffect[\s\S]*?\}/g, '{}')
|
|
28
|
+
.replace(/\{[\s\S]*?fetch\([\s\S]*?\}/g, '{}');
|
|
29
|
+
|
|
30
|
+
// Then strip all string literals
|
|
31
|
+
cleanedCode = cleanedCode
|
|
32
|
+
.replace(/`[\s\S]*?`/g, '""')
|
|
33
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
|
34
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
35
|
+
// Remove comments
|
|
36
|
+
.replace(/\/\/.*$/gm, '')
|
|
37
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
38
|
+
|
|
39
|
+
// Rule 1: No React hooks (check the cleaned code only)
|
|
16
40
|
const hookPatterns = [
|
|
17
|
-
'useState',
|
|
18
|
-
'
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'useCallback',
|
|
22
|
-
'useMemo',
|
|
23
|
-
'useRef',
|
|
24
|
-
'useImperativeHandle',
|
|
25
|
-
'useLayoutEffect',
|
|
26
|
-
'useDebugValue',
|
|
27
|
-
'useId',
|
|
28
|
-
'useDeferredValue',
|
|
29
|
-
'useTransition',
|
|
30
|
-
'useSyncExternalStore'
|
|
41
|
+
'useState', 'useEffect', 'useContext', 'useReducer',
|
|
42
|
+
'useCallback', 'useMemo', 'useRef', 'useImperativeHandle',
|
|
43
|
+
'useLayoutEffect', 'useDebugValue', 'useId', 'useDeferredValue',
|
|
44
|
+
'useTransition', 'useSyncExternalStore'
|
|
31
45
|
];
|
|
32
46
|
|
|
33
47
|
for (const hook of hookPatterns) {
|
|
34
|
-
//
|
|
35
|
-
// Looks for: useState( or const [x] = useState(
|
|
48
|
+
// Look for the hook as a function call, but only in the cleaned code
|
|
36
49
|
const regex = new RegExp(`\\b${hook}\\s*\\(`, 'g');
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
|
|
51
|
+
// Also check that it's not preceded by "import" or part of a comment
|
|
52
|
+
const matches = cleanedCode.match(regex);
|
|
53
|
+
if (matches) {
|
|
54
|
+
// Verify this isn't in an import statement by checking context
|
|
55
|
+
const hookIndex = cleanedCode.indexOf(matches[0]);
|
|
56
|
+
const contextBefore = cleanedCode.substring(Math.max(0, hookIndex - 50), hookIndex);
|
|
57
|
+
|
|
58
|
+
if (!contextBefore.includes('import')) {
|
|
59
|
+
errors.push(`❌ Uses React hook: ${hook}`);
|
|
60
|
+
}
|
|
39
61
|
}
|
|
40
62
|
}
|
|
41
63
|
|
|
@@ -45,26 +67,27 @@ export function validateServerIsland(sourceCode, filePath) {
|
|
|
45
67
|
errors.push('❌ Imports from \'bertui/router\' (use <a> tags instead of Link)');
|
|
46
68
|
}
|
|
47
69
|
|
|
48
|
-
// Rule 3: No browser APIs (
|
|
70
|
+
// Rule 3: No browser APIs (check cleaned code)
|
|
49
71
|
const browserAPIs = [
|
|
50
|
-
{ pattern: '
|
|
51
|
-
{ pattern: '
|
|
52
|
-
{ pattern: '
|
|
53
|
-
{ pattern: '
|
|
54
|
-
{ pattern: '
|
|
55
|
-
{ pattern: '
|
|
56
|
-
{ pattern: '
|
|
57
|
-
{ pattern: '
|
|
72
|
+
{ pattern: '\\bwindow\\.(?!location)', name: 'window' },
|
|
73
|
+
{ pattern: '\\bdocument\\.', name: 'document' },
|
|
74
|
+
{ pattern: '\\blocalStorage\\.', name: 'localStorage' },
|
|
75
|
+
{ pattern: '\\bsessionStorage\\.', name: 'sessionStorage' },
|
|
76
|
+
{ pattern: '\\bnavigator\\.', name: 'navigator' },
|
|
77
|
+
{ pattern: '\\blocation\\.(?!href)', name: 'location' },
|
|
78
|
+
{ pattern: '\\bhistory\\.', name: 'history' },
|
|
79
|
+
{ pattern: '\\bfetch\\s*\\(', name: 'fetch' },
|
|
58
80
|
{ pattern: '\\.addEventListener\\s*\\(', name: 'addEventListener' },
|
|
59
81
|
{ pattern: '\\.removeEventListener\\s*\\(', name: 'removeEventListener' },
|
|
60
82
|
{ pattern: '\\bsetTimeout\\s*\\(', name: 'setTimeout' },
|
|
61
83
|
{ pattern: '\\bsetInterval\\s*\\(', name: 'setInterval' },
|
|
62
|
-
{ pattern: '\\brequestAnimationFrame\\s*\\(', name: 'requestAnimationFrame' }
|
|
84
|
+
{ pattern: '\\brequestAnimationFrame\\s*\\(', name: 'requestAnimationFrame' },
|
|
85
|
+
{ pattern: '\\bconsole\\.', name: 'console' }
|
|
63
86
|
];
|
|
64
87
|
|
|
65
88
|
for (const api of browserAPIs) {
|
|
66
89
|
const regex = new RegExp(api.pattern, 'g');
|
|
67
|
-
if (regex.test(
|
|
90
|
+
if (regex.test(cleanedCode)) {
|
|
68
91
|
if (api.name === 'console') {
|
|
69
92
|
logger.warn(`⚠️ ${filePath} uses console.log (will not work in static HTML)`);
|
|
70
93
|
} else {
|
|
@@ -73,34 +96,28 @@ export function validateServerIsland(sourceCode, filePath) {
|
|
|
73
96
|
}
|
|
74
97
|
}
|
|
75
98
|
|
|
76
|
-
// Rule 4: No event handlers (
|
|
99
|
+
// Rule 4: No event handlers (check cleaned code)
|
|
77
100
|
const eventHandlers = [
|
|
78
|
-
'onClick=',
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'onInput=',
|
|
82
|
-
'onFocus=',
|
|
83
|
-
'onBlur=',
|
|
84
|
-
'onMouseEnter=',
|
|
85
|
-
'onMouseLeave=',
|
|
86
|
-
'onKeyDown=',
|
|
87
|
-
'onKeyUp=',
|
|
88
|
-
'onScroll='
|
|
101
|
+
'onClick=', 'onChange=', 'onSubmit=', 'onInput=', 'onFocus=',
|
|
102
|
+
'onBlur=', 'onMouseEnter=', 'onMouseLeave=', 'onKeyDown=',
|
|
103
|
+
'onKeyUp=', 'onScroll='
|
|
89
104
|
];
|
|
90
105
|
|
|
91
106
|
for (const handler of eventHandlers) {
|
|
92
|
-
|
|
107
|
+
const escapedHandler = handler.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
108
|
+
const regex = new RegExp(`\\b${escapedHandler}\\s*{`, 'g');
|
|
109
|
+
if (regex.test(cleanedCode)) {
|
|
93
110
|
errors.push(`❌ Uses event handler: ${handler.replace('=', '')} (Server Islands are static HTML)`);
|
|
94
111
|
}
|
|
95
112
|
}
|
|
96
113
|
|
|
97
114
|
// Rule 5: Check for dynamic imports
|
|
98
|
-
if (/import\s*\(/.test(
|
|
115
|
+
if (/import\s*\(/.test(cleanedCode)) {
|
|
99
116
|
errors.push('❌ Uses dynamic import() (not supported in Server Islands)');
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
// Rule 6: Check for async/await
|
|
103
|
-
if (/async\s+function|async\s*\(|async\s+\w+\s*\(/.test(
|
|
119
|
+
// Rule 6: Check for async/await
|
|
120
|
+
if (/async\s+function|async\s*\(|async\s+\w+\s*\(/.test(cleanedCode)) {
|
|
104
121
|
errors.push('❌ Uses async/await (Server Islands must be synchronous)');
|
|
105
122
|
}
|
|
106
123
|
|
|
@@ -109,6 +126,8 @@ export function validateServerIsland(sourceCode, filePath) {
|
|
|
109
126
|
return { valid, errors };
|
|
110
127
|
}
|
|
111
128
|
|
|
129
|
+
|
|
130
|
+
|
|
112
131
|
/**
|
|
113
132
|
* Display validation errors in a clear format
|
|
114
133
|
*/
|
package/src/build.js
CHANGED
|
@@ -78,15 +78,14 @@ export async function buildProduction(options = {}) {
|
|
|
78
78
|
// ── Step 8: Bundle JS ────────────────────────────────────────────────────
|
|
79
79
|
logger.step(8, TOTAL_STEPS, 'Bundling JS');
|
|
80
80
|
const buildEntry = join(buildDir, 'main.js');
|
|
81
|
-
const routerPath = join(buildDir, 'router.js');
|
|
82
81
|
if (!existsSync(buildEntry)) throw new Error('main.js not found in build dir');
|
|
83
|
-
const result = await bundleJavaScript(buildEntry,
|
|
82
|
+
const result = await bundleJavaScript(buildEntry, outDir, envVars, buildDir, analyzedRoutes, importhow, root, config);
|
|
84
83
|
totalKB = (result.outputs.reduce((a, o) => a + (o.size || 0), 0) / 1024).toFixed(1);
|
|
85
84
|
logger.stepDone('Bundling JS', `${totalKB} KB · tree-shaken`);
|
|
86
85
|
|
|
87
86
|
// ── Step 9: HTML ─────────────────────────────────────────────────────────
|
|
88
87
|
logger.step(9, TOTAL_STEPS, 'Generating HTML');
|
|
89
|
-
await generateProductionHTML(root, outDir, result, routes, serverIslands, config);
|
|
88
|
+
await generateProductionHTML(root, outDir, buildDir, result, routes, serverIslands, config);
|
|
90
89
|
logger.stepDone('Generating HTML', `${routes.length} pages`);
|
|
91
90
|
|
|
92
91
|
// ── Step 10: Sitemap + robots ────────────────────────────────────────────
|
|
@@ -95,10 +94,15 @@ export async function buildProduction(options = {}) {
|
|
|
95
94
|
await generateRobots(config, outDir, routes);
|
|
96
95
|
logger.stepDone('Sitemap & robots');
|
|
97
96
|
|
|
97
|
+
// Delete build dir AFTER HTML generation
|
|
98
98
|
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true });
|
|
99
99
|
|
|
100
|
-
//
|
|
101
|
-
|
|
100
|
+
// Generate bundle report
|
|
101
|
+
try {
|
|
102
|
+
await analyzeBuild(outDir, { outputFile: join(outDir, 'bundle-report.html') });
|
|
103
|
+
} catch (reportErr) {
|
|
104
|
+
logger.debug(`Bundle report generation skipped: ${reportErr.message}`);
|
|
105
|
+
}
|
|
102
106
|
|
|
103
107
|
// ── Summary ──────────────────────────────────────────────────────────────
|
|
104
108
|
logger.printSummary({
|
|
@@ -110,6 +114,8 @@ export async function buildProduction(options = {}) {
|
|
|
110
114
|
outDir: 'dist/',
|
|
111
115
|
});
|
|
112
116
|
|
|
117
|
+
logger.cleanup();
|
|
118
|
+
|
|
113
119
|
return { success: true };
|
|
114
120
|
|
|
115
121
|
} catch (error) {
|
|
@@ -173,13 +179,15 @@ async function copyNodeModulesToDist(root, outDir, importMap) {
|
|
|
173
179
|
}
|
|
174
180
|
}
|
|
175
181
|
|
|
176
|
-
async function bundleJavaScript(buildEntry,
|
|
182
|
+
async function bundleJavaScript(buildEntry, outDir, envVars, buildDir, analyzedRoutes, importhow, root, config) {
|
|
177
183
|
const originalCwd = process.cwd();
|
|
178
184
|
process.chdir(buildDir);
|
|
179
185
|
|
|
180
186
|
try {
|
|
187
|
+
// Only main.js as entrypoint — router.js is imported by main.js already.
|
|
188
|
+
// Adding router.js as a second entrypoint causes it to mount twice on
|
|
189
|
+
// the same #root which triggers "Node.removeChild" DOM crashes in React.
|
|
181
190
|
const entrypoints = [buildEntry];
|
|
182
|
-
if (existsSync(routerPath)) entrypoints.push(routerPath);
|
|
183
191
|
|
|
184
192
|
const importMap = await generateProductionImportMap(root, config);
|
|
185
193
|
await Bun.write(join(outDir, 'import-map.json'), JSON.stringify({ imports: importMap }, null, 2));
|
|
@@ -192,11 +200,26 @@ async function bundleJavaScript(buildEntry, routerPath, outDir, envVars, buildDi
|
|
|
192
200
|
await Bun.write(join(outDir, 'bunnyx-api', 'api-client.js'), Bun.file(bunnyxSrc));
|
|
193
201
|
}
|
|
194
202
|
|
|
203
|
+
const cssModulePlugin = {
|
|
204
|
+
name: 'css-modules',
|
|
205
|
+
setup(build) {
|
|
206
|
+
build.onLoad({ filter: /\.module\.css$/ }, () => ({
|
|
207
|
+
contents: 'export default new Proxy({}, { get: (_, k) => k });',
|
|
208
|
+
loader: 'js',
|
|
209
|
+
}));
|
|
210
|
+
build.onLoad({ filter: /\.css$/ }, () => ({
|
|
211
|
+
contents: '',
|
|
212
|
+
loader: 'js',
|
|
213
|
+
}));
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
195
217
|
const result = await Bun.build({
|
|
196
218
|
entrypoints,
|
|
197
219
|
outdir: join(outDir, 'assets'),
|
|
198
220
|
target: 'browser',
|
|
199
221
|
format: 'esm',
|
|
222
|
+
plugins: [cssModulePlugin],
|
|
200
223
|
minify: {
|
|
201
224
|
whitespace: true,
|
|
202
225
|
syntax: true,
|
|
@@ -237,9 +260,9 @@ async function bundleJavaScript(buildEntry, routerPath, outDir, envVars, buildDi
|
|
|
237
260
|
export async function build(options = {}) {
|
|
238
261
|
try {
|
|
239
262
|
await buildProduction(options);
|
|
263
|
+
process.exit(0);
|
|
240
264
|
} catch (error) {
|
|
241
265
|
console.error(error);
|
|
242
266
|
process.exit(1);
|
|
243
267
|
}
|
|
244
|
-
process.exit(0);
|
|
245
268
|
}
|
package/src/logger/logger.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createWriteStream } from 'fs';
|
|
5
5
|
import { join } from 'path';
|
|
6
|
+
import { mkdirSync, existsSync } from 'fs';
|
|
6
7
|
|
|
7
8
|
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
8
9
|
const C = {
|
|
@@ -121,12 +122,10 @@ export function fileProgress(current, total, filename) {
|
|
|
121
122
|
|
|
122
123
|
// ── Simple log levels (used internally, suppressed in compact mode) ───────────
|
|
123
124
|
export function info(msg) {
|
|
124
|
-
// In compact mode swallow routine info — only pass through to debug log
|
|
125
125
|
_debugLog('INFO', msg);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
export function success(msg) {
|
|
129
|
-
// Swallow — stepDone() is the visual replacement
|
|
130
129
|
_debugLog('SUCCESS', msg);
|
|
131
130
|
}
|
|
132
131
|
|
|
@@ -185,6 +184,9 @@ export function bigLog(title, opts = {}) {
|
|
|
185
184
|
// ── Build/Dev summary ─────────────────────────────────────────────────────────
|
|
186
185
|
export function printSummary(stats = {}) {
|
|
187
186
|
_stopSpinner();
|
|
187
|
+
// Close the log stream before printing summary
|
|
188
|
+
_closeLogStream();
|
|
189
|
+
|
|
188
190
|
process.stdout.write('\n');
|
|
189
191
|
|
|
190
192
|
const dur = _startTime ? `${((Date.now() - _startTime) / 1000).toFixed(2)}s` : '';
|
|
@@ -268,17 +270,38 @@ let _logStream = null;
|
|
|
268
270
|
function _debugLog(level, msg) {
|
|
269
271
|
if (!_logStream) {
|
|
270
272
|
try {
|
|
273
|
+
const logDir = join(process.cwd(), '.bertui');
|
|
274
|
+
if (!existsSync(logDir)) {
|
|
275
|
+
mkdirSync(logDir, { recursive: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
271
278
|
_logStream = createWriteStream(
|
|
272
|
-
join(
|
|
279
|
+
join(logDir, 'dev.log'),
|
|
273
280
|
{ flags: 'a' }
|
|
274
281
|
);
|
|
275
|
-
} catch {
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
276
285
|
}
|
|
277
286
|
const ts = new Date().toISOString().substring(11, 23);
|
|
278
287
|
_logStream.write(`[${ts}] [${level}] ${msg}\n`);
|
|
279
288
|
}
|
|
280
289
|
|
|
281
|
-
//
|
|
290
|
+
// NEW: Function to close the log stream
|
|
291
|
+
function _closeLogStream() {
|
|
292
|
+
if (_logStream) {
|
|
293
|
+
_logStream.end();
|
|
294
|
+
_logStream = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// NEW: Cleanup function to be called when done
|
|
299
|
+
export function cleanup() {
|
|
300
|
+
_stopSpinner();
|
|
301
|
+
_closeLogStream();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Default export ────────────────────────────────────────────────────────────
|
|
282
305
|
export default {
|
|
283
306
|
printHeader,
|
|
284
307
|
step,
|
|
@@ -293,4 +316,5 @@ export default {
|
|
|
293
316
|
table,
|
|
294
317
|
bigLog,
|
|
295
318
|
printSummary,
|
|
319
|
+
cleanup, // Export cleanup function
|
|
296
320
|
};
|
package/src/serve.js
CHANGED
|
@@ -42,10 +42,8 @@ export async function startPreviewServer(options = {}) {
|
|
|
42
42
|
process.exit(1);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
logger.info(`🌐 URL: http://localhost:${port}`);
|
|
48
|
-
logger.info(`⚡ Press Ctrl+C to stop`);
|
|
45
|
+
console.log(`\n 🚀 Preview running at http://localhost:${port}`);
|
|
46
|
+
console.log(` Press Ctrl+C to stop\n`);
|
|
49
47
|
|
|
50
48
|
// Track connections for graceful shutdown
|
|
51
49
|
const connections = new Set();
|
|
@@ -72,14 +72,17 @@ export async function buildDevImportMap(root) {
|
|
|
72
72
|
|
|
73
73
|
logger.info('🔄 Rebuilding dev import map (new packages detected)...');
|
|
74
74
|
|
|
75
|
+
// Initialize importMap with default mappings
|
|
75
76
|
const importMap = {
|
|
76
|
-
'react':
|
|
77
|
-
'react-dom':
|
|
78
|
-
'react-dom/client':
|
|
79
|
-
'react/jsx-runtime':
|
|
80
|
-
|
|
81
|
-
'@
|
|
77
|
+
'react': 'https://esm.sh/react@18.2.0',
|
|
78
|
+
'react-dom': 'https://esm.sh/react-dom@18.2.0',
|
|
79
|
+
'react-dom/client': 'https://esm.sh/react-dom@18.2.0/client',
|
|
80
|
+
'react/jsx-runtime': 'https://esm.sh/react@18.2.0/jsx-runtime',
|
|
81
|
+
'react/jsx-dev-runtime': 'https://esm.sh/react@18.2.0/jsx-dev-runtime',
|
|
82
|
+
'@bunnyx/api': '/bunnyx-api/api-client.js',
|
|
83
|
+
'@elysiajs/eden': '/node_modules/@elysiajs/eden/dist/index.mjs',
|
|
82
84
|
};
|
|
85
|
+
|
|
83
86
|
const SKIP = new Set(['react', 'react-dom', '.bin', '.cache', '.package-lock.json', '.yarn']);
|
|
84
87
|
|
|
85
88
|
if (existsSync(nodeModulesDir)) {
|