bertui 1.1.0 β 1.1.1
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 +26 -6
- package/src/build/compiler/file-transpiler.js +171 -0
- package/src/build/compiler/index.js +45 -0
- package/src/build/compiler/route-discoverer.js +46 -0
- package/src/build/compiler/router-generator.js +104 -0
- package/src/build/generators/html-generator.js +259 -0
- package/src/build/generators/robots-generator.js +58 -0
- package/src/build/generators/sitemap-generator.js +63 -0
- package/src/build/processors/asset-processor.js +19 -0
- package/src/build/processors/css-builder.js +35 -0
- package/src/build.js +75 -697
- package/src/config/defaultConfig.js +26 -6
- package/src/config/loadConfig.js +21 -5
- package/src/utils/meta-extractor.js +61 -0
- package/types/config.d.ts +80 -0
- package/types/index.d.ts +116 -0
- package/types/react.d.ts +13 -0
- package/types/router.d.ts +79 -0
package/src/build.js
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
// bertui/src/build.js -
|
|
2
|
-
import { join
|
|
3
|
-
import { existsSync, mkdirSync, rmSync
|
|
1
|
+
// bertui/src/build.js - FINAL ORCHESTRATOR
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync } from 'fs';
|
|
4
4
|
import logger from './logger/logger.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { loadEnvVariables } from './utils/env.js';
|
|
6
|
+
|
|
7
|
+
// Import modular components
|
|
8
|
+
import { compileForBuild } from './build/compiler/index.js';
|
|
9
|
+
import { buildAllCSS } from './build/processors/css-builder.js';
|
|
10
|
+
import { copyAllStaticAssets } from './build/processors/asset-processor.js';
|
|
11
|
+
import { generateProductionHTML } from './build/generators/html-generator.js';
|
|
12
|
+
import { generateSitemap } from './build/generators/sitemap-generator.js';
|
|
13
|
+
import { generateRobots } from './build/generators/robots-generator.js';
|
|
14
|
+
|
|
15
|
+
|
|
8
16
|
|
|
9
17
|
export async function buildProduction(options = {}) {
|
|
10
18
|
const root = options.root || process.cwd();
|
|
@@ -14,18 +22,20 @@ export async function buildProduction(options = {}) {
|
|
|
14
22
|
logger.bigLog('BUILDING WITH SERVER ISLANDS ποΈ', { color: 'green' });
|
|
15
23
|
logger.info('π₯ OPTIONAL SERVER CONTENT - THE GAME CHANGER');
|
|
16
24
|
|
|
25
|
+
// Clean directories
|
|
17
26
|
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true });
|
|
18
27
|
if (existsSync(outDir)) rmSync(outDir, { recursive: true });
|
|
19
|
-
|
|
20
28
|
mkdirSync(buildDir, { recursive: true });
|
|
21
29
|
mkdirSync(outDir, { recursive: true });
|
|
22
30
|
|
|
23
31
|
const startTime = Date.now();
|
|
24
32
|
|
|
25
33
|
try {
|
|
34
|
+
// Step 0: Environment
|
|
26
35
|
logger.info('Step 0: Loading environment variables...');
|
|
27
36
|
const envVars = loadEnvVariables(root);
|
|
28
37
|
|
|
38
|
+
// Step 1: Compilation
|
|
29
39
|
logger.info('Step 1: Compiling and detecting Server Islands...');
|
|
30
40
|
const { routes, serverIslands, clientRoutes } = await compileForBuild(root, buildDir, envVars);
|
|
31
41
|
|
|
@@ -38,70 +48,39 @@ export async function buildProduction(options = {}) {
|
|
|
38
48
|
})));
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
logger.info(`Client-only routes: ${clientRoutes.length}`);
|
|
43
|
-
}
|
|
44
|
-
|
|
51
|
+
// Step 2: CSS Processing
|
|
45
52
|
logger.info('Step 2: Combining CSS...');
|
|
46
53
|
await buildAllCSS(root, outDir);
|
|
47
54
|
|
|
55
|
+
// Step 3: Assets
|
|
48
56
|
logger.info('Step 3: Copying static assets...');
|
|
49
57
|
await copyAllStaticAssets(root, outDir);
|
|
50
58
|
|
|
59
|
+
// Step 4: JavaScript Bundling
|
|
51
60
|
logger.info('Step 4: Bundling JavaScript...');
|
|
52
61
|
const buildEntry = join(buildDir, 'main.js');
|
|
62
|
+
const result = await bundleJavaScript(buildEntry, outDir, envVars);
|
|
53
63
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
splitting: true,
|
|
60
|
-
sourcemap: 'external',
|
|
61
|
-
naming: {
|
|
62
|
-
entry: '[name]-[hash].js',
|
|
63
|
-
chunk: 'chunks/[name]-[hash].js',
|
|
64
|
-
asset: '[name]-[hash].[ext]'
|
|
65
|
-
},
|
|
66
|
-
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
|
67
|
-
define: {
|
|
68
|
-
'process.env.NODE_ENV': '"production"',
|
|
69
|
-
...Object.fromEntries(
|
|
70
|
-
Object.entries(envVars).map(([key, value]) => [
|
|
71
|
-
`process.env.${key}`,
|
|
72
|
-
JSON.stringify(value)
|
|
73
|
-
])
|
|
74
|
-
)
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (!result.success) {
|
|
79
|
-
logger.error('JavaScript build failed!');
|
|
80
|
-
result.logs.forEach(log => logger.error(log.message));
|
|
81
|
-
process.exit(1);
|
|
82
|
-
}
|
|
64
|
+
// Step 5: HTML Generation
|
|
65
|
+
logger.info('Step 5: Generating HTML with Server Islands...');
|
|
66
|
+
const { loadConfig } = await import('./config/loadConfig.js');
|
|
67
|
+
const config = await loadConfig(root);
|
|
68
|
+
await generateProductionHTML(root, outDir, result, routes, serverIslands, config);
|
|
83
69
|
|
|
84
|
-
|
|
70
|
+
// Step 6: Sitemap
|
|
71
|
+
logger.info('Step 6: Generating sitemap.xml...');
|
|
72
|
+
await generateSitemap(routes, config, outDir);
|
|
85
73
|
|
|
86
|
-
|
|
87
|
-
|
|
74
|
+
// Step 7: Robots.txt
|
|
75
|
+
logger.info('Step 7: Generating robots.txt...');
|
|
76
|
+
await generateRobots(config, outDir, routes);
|
|
88
77
|
|
|
78
|
+
// Cleanup
|
|
89
79
|
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true });
|
|
90
80
|
|
|
81
|
+
// Summary
|
|
91
82
|
const duration = Date.now() - startTime;
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// Show summary
|
|
95
|
-
logger.bigLog('BUILD SUMMARY', { color: 'green' });
|
|
96
|
-
logger.info(`π Total routes: ${routes.length}`);
|
|
97
|
-
logger.info(`ποΈ Server Islands (SSG): ${serverIslands.length}`);
|
|
98
|
-
logger.info(`β‘ Client-only: ${clientRoutes.length}`);
|
|
99
|
-
|
|
100
|
-
if (serverIslands.length > 0) {
|
|
101
|
-
logger.success('β
Server Islands enabled - INSTANT content delivery!');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
logger.bigLog('READY TO DEPLOY π', { color: 'green' });
|
|
83
|
+
showBuildSummary(routes, serverIslands, clientRoutes, duration);
|
|
105
84
|
|
|
106
85
|
} catch (error) {
|
|
107
86
|
logger.error(`Build failed: ${error.message}`);
|
|
@@ -111,654 +90,53 @@ export async function buildProduction(options = {}) {
|
|
|
111
90
|
}
|
|
112
91
|
}
|
|
113
92
|
|
|
114
|
-
async function
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
logger.success(`ποΈ Server Island: ${route.route}`);
|
|
137
|
-
} else {
|
|
138
|
-
clientRoutes.push(route);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
await compileBuildDirectory(srcDir, buildDir, root, envVars);
|
|
144
|
-
|
|
145
|
-
if (routes.length > 0) {
|
|
146
|
-
await generateBuildRouter(routes, buildDir);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return { routes, serverIslands, clientRoutes };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function generateProductionHTML(root, outDir, buildResult, routes, serverIslands) {
|
|
153
|
-
const mainBundle = buildResult.outputs.find(o =>
|
|
154
|
-
o.path.includes('main') && o.kind === 'entry-point'
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
if (!mainBundle) {
|
|
158
|
-
logger.error('β Could not find main bundle');
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/');
|
|
163
|
-
|
|
164
|
-
const { loadConfig } = await import('./config/loadConfig.js');
|
|
165
|
-
const config = await loadConfig(root);
|
|
166
|
-
const defaultMeta = config.meta || {};
|
|
167
|
-
|
|
168
|
-
for (const route of routes) {
|
|
169
|
-
try {
|
|
170
|
-
const sourceCode = await Bun.file(route.path).text();
|
|
171
|
-
const pageMeta = extractMetaFromSource(sourceCode);
|
|
172
|
-
const meta = { ...defaultMeta, ...pageMeta };
|
|
173
|
-
|
|
174
|
-
// ποΈ CHECK IF THIS IS A SERVER ISLAND
|
|
175
|
-
const isServerIsland = serverIslands.find(si => si.route === route.route);
|
|
176
|
-
|
|
177
|
-
let staticHTML = '';
|
|
178
|
-
|
|
179
|
-
if (isServerIsland) {
|
|
180
|
-
logger.info(`ποΈ Extracting static content: ${route.route}`);
|
|
181
|
-
|
|
182
|
-
// ποΈ CRITICAL: Server Islands are PURE HTML
|
|
183
|
-
// We extract the return statement and convert JSX to HTML
|
|
184
|
-
// NO react-dom/server needed - this is the beauty of it!
|
|
185
|
-
|
|
186
|
-
staticHTML = await extractStaticHTMLFromComponent(sourceCode, route.path);
|
|
187
|
-
|
|
188
|
-
if (staticHTML) {
|
|
189
|
-
logger.success(`β
Server Island rendered: ${route.route}`);
|
|
190
|
-
} else {
|
|
191
|
-
logger.warn(`β οΈ Could not extract HTML, falling back to client-only`);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
const html = generateHTML(meta, route, bundlePath, staticHTML, isServerIsland);
|
|
196
|
-
|
|
197
|
-
let htmlPath;
|
|
198
|
-
if (route.route === '/') {
|
|
199
|
-
htmlPath = join(outDir, 'index.html');
|
|
200
|
-
} else {
|
|
201
|
-
const routeDir = join(outDir, route.route.replace(/^\//, ''));
|
|
202
|
-
mkdirSync(routeDir, { recursive: true });
|
|
203
|
-
htmlPath = join(routeDir, 'index.html');
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
await Bun.write(htmlPath, html);
|
|
207
|
-
|
|
208
|
-
if (isServerIsland) {
|
|
209
|
-
logger.success(`β
Server Island: ${route.route} (instant content!)`);
|
|
210
|
-
} else {
|
|
211
|
-
logger.success(`β
Client-only: ${route.route}`);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
} catch (error) {
|
|
215
|
-
logger.error(`Failed HTML for ${route.route}: ${error.message}`);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// ποΈ NEW: Extract static HTML from Server Island component
|
|
221
|
-
// This converts JSX to HTML WITHOUT using react-dom/server
|
|
222
|
-
// ποΈ SMARTER VALIDATOR - Ignores strings in JSX content
|
|
223
|
-
async function extractStaticHTMLFromComponent(sourceCode, filePath) {
|
|
224
|
-
try {
|
|
225
|
-
// STEP 1: Extract only the ACTUAL CODE (before the return statement)
|
|
226
|
-
// This is where imports and hooks would be
|
|
227
|
-
const returnMatch = sourceCode.match(/return\s*\(/);
|
|
228
|
-
if (!returnMatch) {
|
|
229
|
-
logger.warn(`β οΈ Could not find return statement in ${filePath}`);
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const codeBeforeReturn = sourceCode.substring(0, returnMatch.index);
|
|
234
|
-
const jsxContent = sourceCode.substring(returnMatch.index);
|
|
235
|
-
|
|
236
|
-
// VALIDATE: Check only the CODE part (not JSX/text content)
|
|
237
|
-
|
|
238
|
-
// Rule 1: No React hooks (in actual code only)
|
|
239
|
-
const hookPatterns = [
|
|
240
|
-
'useState', 'useEffect', 'useContext', 'useReducer',
|
|
241
|
-
'useCallback', 'useMemo', 'useRef', 'useImperativeHandle',
|
|
242
|
-
'useLayoutEffect', 'useDebugValue'
|
|
243
|
-
];
|
|
244
|
-
|
|
245
|
-
let hasHooks = false;
|
|
246
|
-
for (const hook of hookPatterns) {
|
|
247
|
-
// Only check the code BEFORE the JSX return
|
|
248
|
-
const regex = new RegExp(`\\b${hook}\\s*\\(`, 'g');
|
|
249
|
-
if (regex.test(codeBeforeReturn)) {
|
|
250
|
-
logger.error(`β Server Island at ${filePath} contains React hooks!`);
|
|
251
|
-
logger.error(` Server Islands must be pure HTML - no ${hook}, etc.`);
|
|
252
|
-
hasHooks = true;
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (hasHooks) return null;
|
|
258
|
-
|
|
259
|
-
// Rule 2: No bertui/router imports (in actual code only)
|
|
260
|
-
// Only check ACTUAL imports at the top of the file, not in template literals
|
|
261
|
-
// Match: import X from 'bertui/router'
|
|
262
|
-
// Don't match: {`import X from 'bertui/router'`} (inside backticks)
|
|
263
|
-
const importLines = codeBeforeReturn.split('\n')
|
|
264
|
-
.filter(line => line.trim().startsWith('import'))
|
|
265
|
-
.join('\n');
|
|
266
|
-
|
|
267
|
-
const hasRouterImport = /from\s+['"]bertui\/router['"]/m.test(importLines);
|
|
268
|
-
|
|
269
|
-
if (hasRouterImport) {
|
|
270
|
-
logger.error(`β Server Island at ${filePath} imports from 'bertui/router'!`);
|
|
271
|
-
logger.error(` Server Islands cannot use Link - use <a> tags instead.`);
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Rule 3: No event handlers in JSX (these are actual attributes)
|
|
276
|
-
const eventHandlers = [
|
|
277
|
-
'onClick=',
|
|
278
|
-
'onChange=',
|
|
279
|
-
'onSubmit=',
|
|
280
|
-
'onInput=',
|
|
281
|
-
'onFocus=',
|
|
282
|
-
'onBlur=',
|
|
283
|
-
'onMouseEnter=',
|
|
284
|
-
'onMouseLeave=',
|
|
285
|
-
'onKeyDown=',
|
|
286
|
-
'onKeyUp=',
|
|
287
|
-
'onScroll='
|
|
288
|
-
];
|
|
289
|
-
|
|
290
|
-
for (const handler of eventHandlers) {
|
|
291
|
-
if (jsxContent.includes(handler)) {
|
|
292
|
-
logger.error(`β Server Island uses event handler: ${handler.replace('=', '')}`);
|
|
293
|
-
logger.error(` Server Islands are static HTML - no interactivity allowed`);
|
|
294
|
-
return null;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// NOW EXTRACT THE JSX
|
|
299
|
-
const fullReturnMatch = sourceCode.match(/return\s*\(([\s\S]*?)\);?\s*}/);
|
|
300
|
-
if (!fullReturnMatch) {
|
|
301
|
-
logger.warn(`β οΈ Could not extract JSX from ${filePath}`);
|
|
302
|
-
return null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
let html = fullReturnMatch[1].trim();
|
|
306
|
-
|
|
307
|
-
// STEP 2: Remove JSX comments {/* ... */}
|
|
308
|
-
html = html.replace(/\{\/\*[\s\S]*?\*\/\}/g, '');
|
|
309
|
-
|
|
310
|
-
// STEP 3: Convert className to class
|
|
311
|
-
html = html.replace(/className=/g, 'class=');
|
|
312
|
-
|
|
313
|
-
// STEP 4: Convert style objects to inline styles
|
|
314
|
-
// Match style={{...}} and convert to style="..."
|
|
315
|
-
html = html.replace(/style=\{\{([^}]+)\}\}/g, (match, styleObj) => {
|
|
316
|
-
// Split by comma, but be careful of commas inside values like rgba()
|
|
317
|
-
const props = [];
|
|
318
|
-
let currentProp = '';
|
|
319
|
-
let depth = 0;
|
|
320
|
-
|
|
321
|
-
for (let i = 0; i < styleObj.length; i++) {
|
|
322
|
-
const char = styleObj[i];
|
|
323
|
-
if (char === '(') depth++;
|
|
324
|
-
if (char === ')') depth--;
|
|
325
|
-
|
|
326
|
-
if (char === ',' && depth === 0) {
|
|
327
|
-
props.push(currentProp.trim());
|
|
328
|
-
currentProp = '';
|
|
329
|
-
} else {
|
|
330
|
-
currentProp += char;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
if (currentProp.trim()) props.push(currentProp.trim());
|
|
334
|
-
|
|
335
|
-
// Convert each property
|
|
336
|
-
const cssString = props
|
|
337
|
-
.map(prop => {
|
|
338
|
-
const colonIndex = prop.indexOf(':');
|
|
339
|
-
if (colonIndex === -1) return '';
|
|
340
|
-
|
|
341
|
-
const key = prop.substring(0, colonIndex).trim();
|
|
342
|
-
const value = prop.substring(colonIndex + 1).trim();
|
|
343
|
-
|
|
344
|
-
if (!key || !value) return '';
|
|
345
|
-
|
|
346
|
-
// Convert camelCase to kebab-case
|
|
347
|
-
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
348
|
-
// Remove quotes from value
|
|
349
|
-
const cssValue = value.replace(/['"]/g, '');
|
|
350
|
-
|
|
351
|
-
return `${cssKey}: ${cssValue}`;
|
|
352
|
-
})
|
|
353
|
-
.filter(Boolean)
|
|
354
|
-
.join('; ');
|
|
355
|
-
|
|
356
|
-
return `style="${cssString}"`;
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
// STEP 5: Handle self-closing tags
|
|
360
|
-
const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
|
|
361
|
-
'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
|
|
362
|
-
|
|
363
|
-
html = html.replace(/<(\w+)([^>]*)\s*\/>/g, (match, tag, attrs) => {
|
|
364
|
-
if (voidElements.includes(tag.toLowerCase())) {
|
|
365
|
-
return match; // Keep void elements self-closing
|
|
366
|
-
} else {
|
|
367
|
-
return `<${tag}${attrs}></${tag}>`; // Convert to opening + closing
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// STEP 6: Clean up JSX expressions
|
|
372
|
-
// Template literals: {`text`} -> text
|
|
373
|
-
html = html.replace(/\{`([^`]*)`\}/g, '$1');
|
|
374
|
-
// String literals: {'text'} or {"text"} -> text
|
|
375
|
-
html = html.replace(/\{(['"])(.*?)\1\}/g, '$2');
|
|
376
|
-
// Numbers: {123} -> 123
|
|
377
|
-
html = html.replace(/\{(\d+)\}/g, '$1');
|
|
378
|
-
|
|
379
|
-
logger.info(` Extracted ${html.length} chars of static HTML`);
|
|
380
|
-
return html;
|
|
381
|
-
|
|
382
|
-
} catch (error) {
|
|
383
|
-
logger.error(`Failed to extract HTML: ${error.message}`);
|
|
384
|
-
return null;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
// Example of how the style regex should work:
|
|
388
|
-
// Input: style={{ background: 'rgba(0,0,0,0.05)', padding: '1.5rem', borderRadius: '8px' }}
|
|
389
|
-
// Output: style="background: rgba(0,0,0,0.05); padding: 1.5rem; border-radius: 8px"
|
|
390
|
-
function generateHTML(meta, route, bundlePath, staticHTML = '', isServerIsland = false) {
|
|
391
|
-
const rootContent = staticHTML
|
|
392
|
-
? `<div id="root">${staticHTML}</div>`
|
|
393
|
-
: '<div id="root"></div>';
|
|
394
|
-
|
|
395
|
-
const comment = isServerIsland
|
|
396
|
-
? '<!-- ποΈ Server Island: Static content rendered at build time -->'
|
|
397
|
-
: '<!-- β‘ Client-only: Content rendered by JavaScript -->';
|
|
398
|
-
|
|
399
|
-
return `<!DOCTYPE html>
|
|
400
|
-
<html lang="${meta.lang || 'en'}">
|
|
401
|
-
<head>
|
|
402
|
-
<meta charset="UTF-8">
|
|
403
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
404
|
-
<title>${meta.title || 'BertUI App'}</title>
|
|
405
|
-
|
|
406
|
-
<meta name="description" content="${meta.description || 'Built with BertUI'}">
|
|
407
|
-
${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
|
|
408
|
-
${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
|
|
409
|
-
${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
|
|
410
|
-
|
|
411
|
-
<meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
|
|
412
|
-
<meta property="og:description" content="${meta.ogDescription || meta.description || 'Built with BertUI'}">
|
|
413
|
-
${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
|
|
414
|
-
|
|
415
|
-
<link rel="stylesheet" href="/styles/bertui.min.css">
|
|
416
|
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
417
|
-
|
|
418
|
-
<script type="importmap">
|
|
419
|
-
{
|
|
420
|
-
"imports": {
|
|
421
|
-
"react": "https://esm.sh/react@18.2.0",
|
|
422
|
-
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
|
423
|
-
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
424
|
-
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime"
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
</script>
|
|
428
|
-
</head>
|
|
429
|
-
<body>
|
|
430
|
-
${comment}
|
|
431
|
-
${rootContent}
|
|
432
|
-
<script type="module" src="/${bundlePath}"></script>
|
|
433
|
-
</body>
|
|
434
|
-
</html>`;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// Helper functions from original build.js
|
|
438
|
-
async function copyAllStaticAssets(root, outDir) {
|
|
439
|
-
const publicDir = join(root, 'public');
|
|
440
|
-
const srcImagesDir = join(root, 'src', 'images');
|
|
441
|
-
|
|
442
|
-
if (existsSync(publicDir)) {
|
|
443
|
-
copyImages(publicDir, outDir);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (existsSync(srcImagesDir)) {
|
|
447
|
-
const distImagesDir = join(outDir, 'images');
|
|
448
|
-
mkdirSync(distImagesDir, { recursive: true });
|
|
449
|
-
copyImages(srcImagesDir, distImagesDir);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
async function buildAllCSS(root, outDir) {
|
|
454
|
-
const srcStylesDir = join(root, 'src', 'styles');
|
|
455
|
-
const stylesOutDir = join(outDir, 'styles');
|
|
456
|
-
|
|
457
|
-
mkdirSync(stylesOutDir, { recursive: true });
|
|
458
|
-
|
|
459
|
-
if (existsSync(srcStylesDir)) {
|
|
460
|
-
const cssFiles = readdirSync(srcStylesDir).filter(f => f.endsWith('.css'));
|
|
461
|
-
|
|
462
|
-
if (cssFiles.length === 0) {
|
|
463
|
-
await Bun.write(join(stylesOutDir, 'bertui.min.css'), '/* No CSS */');
|
|
464
|
-
return;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
let combinedCSS = '';
|
|
468
|
-
for (const cssFile of cssFiles) {
|
|
469
|
-
const srcPath = join(srcStylesDir, cssFile);
|
|
470
|
-
const file = Bun.file(srcPath);
|
|
471
|
-
const cssContent = await file.text();
|
|
472
|
-
combinedCSS += `/* ${cssFile} */\n${cssContent}\n\n`;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const combinedPath = join(stylesOutDir, 'bertui.min.css');
|
|
476
|
-
await Bun.write(combinedPath, combinedCSS);
|
|
477
|
-
await buildCSS(combinedPath, combinedPath);
|
|
478
|
-
|
|
479
|
-
logger.success(`β
Combined ${cssFiles.length} CSS files`);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
async function discoverRoutes(pagesDir) {
|
|
484
|
-
const routes = [];
|
|
485
|
-
|
|
486
|
-
async function scanDirectory(dir, basePath = '') {
|
|
487
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
488
|
-
|
|
489
|
-
for (const entry of entries) {
|
|
490
|
-
const fullPath = join(dir, entry.name);
|
|
491
|
-
const relativePath = join(basePath, entry.name);
|
|
492
|
-
|
|
493
|
-
if (entry.isDirectory()) {
|
|
494
|
-
await scanDirectory(fullPath, relativePath);
|
|
495
|
-
} else if (entry.isFile()) {
|
|
496
|
-
const ext = extname(entry.name);
|
|
497
|
-
if (ext === '.css') continue;
|
|
498
|
-
|
|
499
|
-
if (['.jsx', '.tsx', '.js', '.ts'].includes(ext)) {
|
|
500
|
-
const fileName = entry.name.replace(ext, '');
|
|
501
|
-
let route = '/' + relativePath.replace(/\\/g, '/').replace(ext, '');
|
|
502
|
-
|
|
503
|
-
if (fileName === 'index') {
|
|
504
|
-
route = route.replace('/index', '') || '/';
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const isDynamic = fileName.includes('[') && fileName.includes(']');
|
|
508
|
-
|
|
509
|
-
routes.push({
|
|
510
|
-
route: route === '' ? '/' : route,
|
|
511
|
-
file: relativePath.replace(/\\/g, '/'),
|
|
512
|
-
path: fullPath,
|
|
513
|
-
type: isDynamic ? 'dynamic' : 'static'
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
}
|
|
93
|
+
async function bundleJavaScript(buildEntry, outDir, envVars) {
|
|
94
|
+
const result = await Bun.build({
|
|
95
|
+
entrypoints: [buildEntry],
|
|
96
|
+
outdir: join(outDir, 'assets'),
|
|
97
|
+
target: 'browser',
|
|
98
|
+
minify: true,
|
|
99
|
+
splitting: true,
|
|
100
|
+
sourcemap: 'external',
|
|
101
|
+
naming: {
|
|
102
|
+
entry: '[name]-[hash].js',
|
|
103
|
+
chunk: 'chunks/[name]-[hash].js',
|
|
104
|
+
asset: '[name]-[hash].[ext]'
|
|
105
|
+
},
|
|
106
|
+
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
|
107
|
+
define: {
|
|
108
|
+
'process.env.NODE_ENV': '"production"',
|
|
109
|
+
...Object.fromEntries(
|
|
110
|
+
Object.entries(envVars).map(([key, value]) => [
|
|
111
|
+
`process.env.${key}`,
|
|
112
|
+
JSON.stringify(value)
|
|
113
|
+
])
|
|
114
|
+
)
|
|
517
115
|
}
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
await scanDirectory(pagesDir);
|
|
521
|
-
routes.sort((a, b) => a.type === b.type ? a.route.localeCompare(b.route) : a.type === 'static' ? -1 : 1);
|
|
522
|
-
|
|
523
|
-
return routes;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
527
|
-
const files = readdirSync(srcDir);
|
|
528
|
-
|
|
529
|
-
for (const file of files) {
|
|
530
|
-
const srcPath = join(srcDir, file);
|
|
531
|
-
const stat = statSync(srcPath);
|
|
532
|
-
|
|
533
|
-
if (stat.isDirectory()) {
|
|
534
|
-
const subBuildDir = join(buildDir, file);
|
|
535
|
-
mkdirSync(subBuildDir, { recursive: true });
|
|
536
|
-
await compileBuildDirectory(srcPath, subBuildDir, root, envVars);
|
|
537
|
-
} else {
|
|
538
|
-
const ext = extname(file);
|
|
539
|
-
if (ext === '.css') continue;
|
|
540
|
-
|
|
541
|
-
if (['.jsx', '.tsx', '.ts'].includes(ext)) {
|
|
542
|
-
await compileBuildFile(srcPath, buildDir, file, root, envVars);
|
|
543
|
-
} else if (ext === '.js') {
|
|
544
|
-
const outPath = join(buildDir, file);
|
|
545
|
-
let code = await Bun.file(srcPath).text();
|
|
546
|
-
code = removeCSSImports(code);
|
|
547
|
-
code = replaceEnvInCode(code, envVars);
|
|
548
|
-
code = fixBuildImports(code, srcPath, outPath, root);
|
|
549
|
-
if (usesJSX(code) && !code.includes('import React')) {
|
|
550
|
-
code = `import React from 'react';\n${code}`;
|
|
551
|
-
}
|
|
552
|
-
await Bun.write(outPath, code);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
async function compileBuildFile(srcPath, buildDir, filename, root, envVars) {
|
|
559
|
-
const ext = extname(filename);
|
|
560
|
-
const loader = ext === '.tsx' ? 'tsx' : ext === '.ts' ? 'ts' : 'jsx';
|
|
116
|
+
});
|
|
561
117
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
const outFilename = filename.replace(/\.(jsx|tsx|ts)$/, '.js');
|
|
568
|
-
const outPath = join(buildDir, outFilename);
|
|
569
|
-
code = fixBuildImports(code, srcPath, outPath, root);
|
|
570
|
-
|
|
571
|
-
const transpiler = new Bun.Transpiler({
|
|
572
|
-
loader,
|
|
573
|
-
tsconfig: {
|
|
574
|
-
compilerOptions: {
|
|
575
|
-
jsx: 'react',
|
|
576
|
-
jsxFactory: 'React.createElement',
|
|
577
|
-
jsxFragmentFactory: 'React.Fragment'
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
});
|
|
581
|
-
|
|
582
|
-
let compiled = await transpiler.transform(code);
|
|
583
|
-
if (usesJSX(compiled) && !compiled.includes('import React')) {
|
|
584
|
-
compiled = `import React from 'react';\n${compiled}`;
|
|
585
|
-
}
|
|
586
|
-
compiled = fixRelativeImports(compiled);
|
|
587
|
-
await Bun.write(outPath, compiled);
|
|
588
|
-
} catch (error) {
|
|
589
|
-
logger.error(`Failed to compile ${filename}: ${error.message}`);
|
|
590
|
-
throw error;
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
logger.error('JavaScript build failed!');
|
|
120
|
+
result.logs.forEach(log => logger.error(log.message));
|
|
121
|
+
process.exit(1);
|
|
591
122
|
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
function usesJSX(code) {
|
|
595
|
-
return code.includes('React.createElement') ||
|
|
596
|
-
code.includes('React.Fragment') ||
|
|
597
|
-
/<[A-Z]/.test(code) ||
|
|
598
|
-
code.includes('jsx(') ||
|
|
599
|
-
code.includes('jsxs(');
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
function removeCSSImports(code) {
|
|
603
|
-
code = code.replace(/import\s+['"][^'"]*\.css['"];?\s*/g, '');
|
|
604
|
-
code = code.replace(/import\s+['"]bertui\/styles['"]\s*;?\s*/g, '');
|
|
605
|
-
return code;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function fixBuildImports(code, srcPath, outPath, root) {
|
|
609
|
-
const buildDir = join(root, '.bertuibuild');
|
|
610
|
-
const routerPath = join(buildDir, 'router.js');
|
|
611
|
-
const relativeToRouter = relative(dirname(outPath), routerPath).replace(/\\/g, '/');
|
|
612
|
-
const routerImport = relativeToRouter.startsWith('.') ? relativeToRouter : './' + relativeToRouter;
|
|
613
123
|
|
|
614
|
-
|
|
615
|
-
return
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function fixRelativeImports(code) {
|
|
619
|
-
const importRegex = /from\s+['"](\.\.[\/\\]|\.\/)((?:[^'"]+?)(?<!\.js|\.jsx|\.ts|\.tsx|\.json))['"];?/g;
|
|
620
|
-
code = code.replace(importRegex, (match, prefix, path) => {
|
|
621
|
-
if (path.endsWith('/') || /\.\w+$/.test(path)) return match;
|
|
622
|
-
return `from '${prefix}${path}.js';`;
|
|
623
|
-
});
|
|
624
|
-
return code;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function extractMetaFromSource(code) {
|
|
628
|
-
try {
|
|
629
|
-
const metaMatch = code.match(/export\s+const\s+meta\s*=\s*\{/);
|
|
630
|
-
if (!metaMatch) return null;
|
|
631
|
-
|
|
632
|
-
const startIndex = metaMatch.index + metaMatch[0].length - 1;
|
|
633
|
-
let braceCount = 0;
|
|
634
|
-
let endIndex = startIndex;
|
|
635
|
-
|
|
636
|
-
for (let i = startIndex; i < code.length; i++) {
|
|
637
|
-
if (code[i] === '{') braceCount++;
|
|
638
|
-
if (code[i] === '}') {
|
|
639
|
-
braceCount--;
|
|
640
|
-
if (braceCount === 0) {
|
|
641
|
-
endIndex = i;
|
|
642
|
-
break;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
if (endIndex === startIndex) return null;
|
|
648
|
-
|
|
649
|
-
const metaString = code.substring(startIndex, endIndex + 1);
|
|
650
|
-
const meta = {};
|
|
651
|
-
const pairRegex = /(\w+)\s*:\s*(['"`])((?:(?!\2).)*)\2/g;
|
|
652
|
-
let match;
|
|
653
|
-
|
|
654
|
-
while ((match = pairRegex.exec(metaString)) !== null) {
|
|
655
|
-
meta[match[1]] = match[3];
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
return Object.keys(meta).length > 0 ? meta : null;
|
|
659
|
-
} catch (error) {
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
124
|
+
logger.success('JavaScript bundled');
|
|
125
|
+
return result;
|
|
662
126
|
}
|
|
663
127
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
128
|
+
function showBuildSummary(routes, serverIslands, clientRoutes, duration) {
|
|
129
|
+
logger.success(`β¨ Build complete in ${duration}ms`);
|
|
130
|
+
logger.bigLog('BUILD SUMMARY', { color: 'green' });
|
|
131
|
+
logger.info(`π Total routes: ${routes.length}`);
|
|
132
|
+
logger.info(`ποΈ Server Islands (SSG): ${serverIslands.length}`);
|
|
133
|
+
logger.info(`β‘ Client-only: ${clientRoutes.length}`);
|
|
134
|
+
logger.info(`πΊοΈ Sitemap: dist/sitemap.xml`);
|
|
135
|
+
logger.info(`π€ robots.txt: dist/robots.txt`);
|
|
670
136
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
return ` { path: '${route.route}', component: ${componentName}, type: '${route.type}' }`;
|
|
674
|
-
}).join(',\n');
|
|
675
|
-
|
|
676
|
-
const routerCode = `import React, { useState, useEffect, createContext, useContext } from 'react';
|
|
677
|
-
|
|
678
|
-
const RouterContext = createContext(null);
|
|
679
|
-
|
|
680
|
-
export function useRouter() {
|
|
681
|
-
const context = useContext(RouterContext);
|
|
682
|
-
if (!context) throw new Error('useRouter must be used within a Router');
|
|
683
|
-
return context;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
export function Router({ routes }) {
|
|
687
|
-
const [currentRoute, setCurrentRoute] = useState(null);
|
|
688
|
-
const [params, setParams] = useState({});
|
|
689
|
-
|
|
690
|
-
useEffect(() => {
|
|
691
|
-
matchAndSetRoute(window.location.pathname);
|
|
692
|
-
const handlePopState = () => matchAndSetRoute(window.location.pathname);
|
|
693
|
-
window.addEventListener('popstate', handlePopState);
|
|
694
|
-
return () => window.removeEventListener('popstate', handlePopState);
|
|
695
|
-
}, [routes]);
|
|
696
|
-
|
|
697
|
-
function matchAndSetRoute(pathname) {
|
|
698
|
-
for (const route of routes) {
|
|
699
|
-
if (route.type === 'static' && route.path === pathname) {
|
|
700
|
-
setCurrentRoute(route);
|
|
701
|
-
setParams({});
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
for (const route of routes) {
|
|
706
|
-
if (route.type === 'dynamic') {
|
|
707
|
-
const pattern = route.path.replace(/\\[([^\\]]+)\\]/g, '([^/]+)');
|
|
708
|
-
const regex = new RegExp('^' + pattern + '$');
|
|
709
|
-
const match = pathname.match(regex);
|
|
710
|
-
if (match) {
|
|
711
|
-
const paramNames = [...route.path.matchAll(/\\[([^\\]]+)\\]/g)].map(m => m[1]);
|
|
712
|
-
const extractedParams = {};
|
|
713
|
-
paramNames.forEach((name, i) => { extractedParams[name] = match[i + 1]; });
|
|
714
|
-
setCurrentRoute(route);
|
|
715
|
-
setParams(extractedParams);
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
setCurrentRoute(null);
|
|
721
|
-
setParams({});
|
|
137
|
+
if (serverIslands.length > 0) {
|
|
138
|
+
logger.success('β
Server Islands enabled - INSTANT content delivery!');
|
|
722
139
|
}
|
|
723
|
-
|
|
724
|
-
function navigate(path) {
|
|
725
|
-
window.history.pushState({}, '', path);
|
|
726
|
-
matchAndSetRoute(path);
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const Component = currentRoute?.component;
|
|
730
|
-
return React.createElement(
|
|
731
|
-
RouterContext.Provider,
|
|
732
|
-
{ value: { currentRoute, params, navigate, pathname: window.location.pathname } },
|
|
733
|
-
Component ? React.createElement(Component, { params }) : React.createElement(NotFound)
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
export function Link({ to, children, ...props }) {
|
|
738
|
-
const { navigate } = useRouter();
|
|
739
|
-
return React.createElement('a', {
|
|
740
|
-
href: to,
|
|
741
|
-
onClick: (e) => { e.preventDefault(); navigate(to); },
|
|
742
|
-
...props
|
|
743
|
-
}, children);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
function NotFound() {
|
|
747
|
-
return React.createElement('div', {
|
|
748
|
-
style: { display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
749
|
-
justifyContent: 'center', minHeight: '100vh', fontFamily: 'system-ui' }
|
|
750
|
-
},
|
|
751
|
-
React.createElement('h1', { style: { fontSize: '6rem', margin: 0 } }, '404'),
|
|
752
|
-
React.createElement('p', { style: { fontSize: '1.5rem', color: '#666' } }, 'Page not found'),
|
|
753
|
-
React.createElement('a', { href: '/', style: { color: '#10b981', textDecoration: 'none' } }, 'Go home')
|
|
754
|
-
);
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
${imports}
|
|
758
|
-
|
|
759
|
-
export const routes = [
|
|
760
|
-
${routeConfigs}
|
|
761
|
-
];`;
|
|
762
140
|
|
|
763
|
-
|
|
141
|
+
logger.bigLog('READY TO DEPLOY π', { color: 'green' });
|
|
764
142
|
}
|