bertui 1.0.3 ā 1.1.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/README.md +551 -132
- package/package.json +2 -2
- package/src/build/server-island-validator.js +156 -0
- package/src/build.js +463 -377
- package/src/router/Router.js +38 -5
- package/src/router/SSRRouter.js +156 -0
package/src/build.js
CHANGED
|
@@ -1,27 +1,21 @@
|
|
|
1
|
-
// src/build.js -
|
|
1
|
+
// bertui/src/build.js - SERVER ISLANDS IMPLEMENTATION
|
|
2
2
|
import { join, relative, basename, extname, dirname } from 'path';
|
|
3
3
|
import { existsSync, mkdirSync, rmSync, cpSync, readdirSync, statSync } from 'fs';
|
|
4
4
|
import logger from './logger/logger.js';
|
|
5
5
|
import { buildCSS } from './build/css-builder.js';
|
|
6
6
|
import { loadEnvVariables, replaceEnvInCode } from './utils/env.js';
|
|
7
|
-
import {
|
|
7
|
+
import { copyImages } from './build/image-optimizer.js';
|
|
8
8
|
|
|
9
9
|
export async function buildProduction(options = {}) {
|
|
10
10
|
const root = options.root || process.cwd();
|
|
11
11
|
const buildDir = join(root, '.bertuibuild');
|
|
12
12
|
const outDir = join(root, 'dist');
|
|
13
13
|
|
|
14
|
-
logger.bigLog('BUILDING
|
|
15
|
-
logger.info('š„
|
|
14
|
+
logger.bigLog('BUILDING WITH SERVER ISLANDS šļø', { color: 'green' });
|
|
15
|
+
logger.info('š„ OPTIONAL SERVER CONTENT - THE GAME CHANGER');
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
if (existsSync(
|
|
19
|
-
rmSync(buildDir, { recursive: true });
|
|
20
|
-
}
|
|
21
|
-
if (existsSync(outDir)) {
|
|
22
|
-
rmSync(outDir, { recursive: true });
|
|
23
|
-
logger.info('Cleaned dist/');
|
|
24
|
-
}
|
|
17
|
+
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true });
|
|
18
|
+
if (existsSync(outDir)) rmSync(outDir, { recursive: true });
|
|
25
19
|
|
|
26
20
|
mkdirSync(buildDir, { recursive: true });
|
|
27
21
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -31,31 +25,32 @@ export async function buildProduction(options = {}) {
|
|
|
31
25
|
try {
|
|
32
26
|
logger.info('Step 0: Loading environment variables...');
|
|
33
27
|
const envVars = loadEnvVariables(root);
|
|
34
|
-
|
|
35
|
-
|
|
28
|
+
|
|
29
|
+
logger.info('Step 1: Compiling and detecting Server Islands...');
|
|
30
|
+
const { routes, serverIslands, clientRoutes } = await compileForBuild(root, buildDir, envVars);
|
|
31
|
+
|
|
32
|
+
if (serverIslands.length > 0) {
|
|
33
|
+
logger.bigLog('SERVER ISLANDS DETECTED šļø', { color: 'cyan' });
|
|
34
|
+
logger.table(serverIslands.map(r => ({
|
|
35
|
+
route: r.route,
|
|
36
|
+
file: r.file,
|
|
37
|
+
mode: 'šļø Server Island (SSG)'
|
|
38
|
+
})));
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
if (clientRoutes.length > 0) {
|
|
42
|
+
logger.info(`Client-only routes: ${clientRoutes.length}`);
|
|
43
|
+
}
|
|
41
44
|
|
|
42
|
-
logger.info('Step 2: Combining
|
|
45
|
+
logger.info('Step 2: Combining CSS...');
|
|
43
46
|
await buildAllCSS(root, outDir);
|
|
44
47
|
|
|
45
|
-
logger.info('Step 3:
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
logger.info('Step 4: Copying and optimizing static assets...');
|
|
49
|
-
await copyAllStaticAssets(root, outDir, false);
|
|
48
|
+
logger.info('Step 3: Copying static assets...');
|
|
49
|
+
await copyAllStaticAssets(root, outDir);
|
|
50
50
|
|
|
51
|
-
logger.info('Step
|
|
51
|
+
logger.info('Step 4: Bundling JavaScript...');
|
|
52
52
|
const buildEntry = join(buildDir, 'main.js');
|
|
53
53
|
|
|
54
|
-
if (!existsSync(buildEntry)) {
|
|
55
|
-
logger.error('Build entry point not found: .bertuibuild/main.js');
|
|
56
|
-
process.exit(1);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
54
|
const result = await Bun.build({
|
|
60
55
|
entrypoints: [buildEntry],
|
|
61
56
|
outdir: join(outDir, 'assets'),
|
|
@@ -86,78 +81,375 @@ export async function buildProduction(options = {}) {
|
|
|
86
81
|
process.exit(1);
|
|
87
82
|
}
|
|
88
83
|
|
|
89
|
-
logger.success('JavaScript bundled
|
|
84
|
+
logger.success('JavaScript bundled');
|
|
90
85
|
|
|
91
|
-
logger.info('Step
|
|
92
|
-
await generateProductionHTML(root, outDir, result, routes);
|
|
86
|
+
logger.info('Step 5: Generating HTML with Server Islands...');
|
|
87
|
+
await generateProductionHTML(root, outDir, result, routes, serverIslands);
|
|
93
88
|
|
|
94
|
-
|
|
95
|
-
if (existsSync(buildDir)) {
|
|
96
|
-
rmSync(buildDir, { recursive: true });
|
|
97
|
-
logger.info('Cleaned up .bertuibuild/');
|
|
98
|
-
}
|
|
89
|
+
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true });
|
|
99
90
|
|
|
100
91
|
const duration = Date.now() - startTime;
|
|
101
92
|
logger.success(`⨠Build complete in ${duration}ms`);
|
|
102
|
-
logger.info(`š¦ Output: ${outDir}`);
|
|
103
93
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
})
|
|
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
|
+
}
|
|
109
103
|
|
|
110
|
-
logger.bigLog('READY TO DEPLOY', { color: 'green' });
|
|
111
|
-
console.log('\nš¤ Deploy your app:\n');
|
|
112
|
-
console.log(' Vercel: bunx vercel');
|
|
113
|
-
console.log(' Netlify: bunx netlify deploy');
|
|
114
|
-
console.log('\nš Preview locally:\n');
|
|
115
|
-
console.log(' cd dist && bun run preview\n');
|
|
104
|
+
logger.bigLog('READY TO DEPLOY š', { color: 'green' });
|
|
116
105
|
|
|
117
106
|
} catch (error) {
|
|
118
107
|
logger.error(`Build failed: ${error.message}`);
|
|
119
|
-
if (error.stack)
|
|
120
|
-
|
|
108
|
+
if (error.stack) logger.error(error.stack);
|
|
109
|
+
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true });
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function compileForBuild(root, buildDir, envVars) {
|
|
115
|
+
const srcDir = join(root, 'src');
|
|
116
|
+
const pagesDir = join(srcDir, 'pages');
|
|
117
|
+
|
|
118
|
+
if (!existsSync(srcDir)) {
|
|
119
|
+
throw new Error('src/ directory not found!');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let routes = [];
|
|
123
|
+
let serverIslands = [];
|
|
124
|
+
let clientRoutes = [];
|
|
125
|
+
|
|
126
|
+
if (existsSync(pagesDir)) {
|
|
127
|
+
routes = await discoverRoutes(pagesDir);
|
|
128
|
+
|
|
129
|
+
// šļø DETECT SERVER ISLANDS
|
|
130
|
+
for (const route of routes) {
|
|
131
|
+
const sourceCode = await Bun.file(route.path).text();
|
|
132
|
+
const isServerIsland = sourceCode.includes('export const render = "server"');
|
|
133
|
+
|
|
134
|
+
if (isServerIsland) {
|
|
135
|
+
serverIslands.push(route);
|
|
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;
|
|
121
231
|
}
|
|
122
232
|
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|
|
125
255
|
}
|
|
126
256
|
|
|
127
|
-
|
|
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;
|
|
128
385
|
}
|
|
129
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
|
+
}
|
|
130
436
|
|
|
131
|
-
//
|
|
132
|
-
async function copyAllStaticAssets(root, outDir
|
|
437
|
+
// Helper functions from original build.js
|
|
438
|
+
async function copyAllStaticAssets(root, outDir) {
|
|
133
439
|
const publicDir = join(root, 'public');
|
|
134
440
|
const srcImagesDir = join(root, 'src', 'images');
|
|
135
441
|
|
|
136
|
-
logger.info('š¦ Copying static assets...');
|
|
137
|
-
|
|
138
|
-
// Copy from public/ to root of dist/
|
|
139
442
|
if (existsSync(publicDir)) {
|
|
140
|
-
logger.info(' Copying public/ directory...');
|
|
141
443
|
copyImages(publicDir, outDir);
|
|
142
|
-
} else {
|
|
143
|
-
logger.info(' No public/ directory found');
|
|
144
444
|
}
|
|
145
445
|
|
|
146
|
-
// Copy from src/images/ to dist/images/
|
|
147
446
|
if (existsSync(srcImagesDir)) {
|
|
148
|
-
logger.info(' Copying src/images/ to dist/images/...');
|
|
149
447
|
const distImagesDir = join(outDir, 'images');
|
|
150
448
|
mkdirSync(distImagesDir, { recursive: true });
|
|
151
449
|
copyImages(srcImagesDir, distImagesDir);
|
|
152
|
-
} else {
|
|
153
|
-
logger.info(' No src/images/ directory found');
|
|
154
450
|
}
|
|
155
|
-
|
|
156
|
-
logger.success('ā
All assets copied');
|
|
157
451
|
}
|
|
158
452
|
|
|
159
|
-
// COMBINE ALL CSS INTO ONE FILE
|
|
160
|
-
// COMBINE ALL CSS INTO ONE FILE - FIXED BUN API
|
|
161
453
|
async function buildAllCSS(root, outDir) {
|
|
162
454
|
const srcStylesDir = join(root, 'src', 'styles');
|
|
163
455
|
const stylesOutDir = join(outDir, 'styles');
|
|
@@ -166,70 +458,27 @@ async function buildAllCSS(root, outDir) {
|
|
|
166
458
|
|
|
167
459
|
if (existsSync(srcStylesDir)) {
|
|
168
460
|
const cssFiles = readdirSync(srcStylesDir).filter(f => f.endsWith('.css'));
|
|
169
|
-
logger.info(`š¦ Found ${cssFiles.length} CSS files to combine`);
|
|
170
461
|
|
|
171
462
|
if (cssFiles.length === 0) {
|
|
172
|
-
|
|
463
|
+
await Bun.write(join(stylesOutDir, 'bertui.min.css'), '/* No CSS */');
|
|
173
464
|
return;
|
|
174
465
|
}
|
|
175
466
|
|
|
176
|
-
// COMBINE ALL CSS INTO ONE FILE
|
|
177
467
|
let combinedCSS = '';
|
|
178
|
-
let totalOriginalSize = 0;
|
|
179
|
-
|
|
180
468
|
for (const cssFile of cssFiles) {
|
|
181
469
|
const srcPath = join(srcStylesDir, cssFile);
|
|
182
470
|
const file = Bun.file(srcPath);
|
|
183
471
|
const cssContent = await file.text();
|
|
184
|
-
|
|
185
|
-
combinedCSS += `/* === ${cssFile} === */\n${cssContent}\n\n`;
|
|
472
|
+
combinedCSS += `/* ${cssFile} */\n${cssContent}\n\n`;
|
|
186
473
|
}
|
|
187
474
|
|
|
188
|
-
// Write combined CSS
|
|
189
475
|
const combinedPath = join(stylesOutDir, 'bertui.min.css');
|
|
190
476
|
await Bun.write(combinedPath, combinedCSS);
|
|
477
|
+
await buildCSS(combinedPath, combinedPath);
|
|
191
478
|
|
|
192
|
-
|
|
193
|
-
const minified = await buildCSS(combinedPath, combinedPath);
|
|
194
|
-
|
|
195
|
-
// Get final size
|
|
196
|
-
const finalFile = Bun.file(combinedPath);
|
|
197
|
-
const finalSize = finalFile.size / 1024;
|
|
198
|
-
const originalSize = totalOriginalSize / 1024;
|
|
199
|
-
const savings = ((originalSize - finalSize) / originalSize * 100).toFixed(1);
|
|
200
|
-
|
|
201
|
-
logger.success(`ā
Combined ${cssFiles.length} CSS files (${originalSize.toFixed(1)}KB) ā bertui.min.css (${finalSize.toFixed(1)}KB, -${savings}%)`);
|
|
202
|
-
|
|
203
|
-
} else {
|
|
204
|
-
logger.warn('ā ļø No src/styles/ directory found');
|
|
205
|
-
// Create empty CSS file so build doesn't fail
|
|
206
|
-
const emptyPath = join(stylesOutDir, 'bertui.min.css');
|
|
207
|
-
await Bun.write(emptyPath, '/* No CSS files found */');
|
|
479
|
+
logger.success(`ā
Combined ${cssFiles.length} CSS files`);
|
|
208
480
|
}
|
|
209
481
|
}
|
|
210
|
-
async function compileForBuild(root, buildDir, envVars) {
|
|
211
|
-
const srcDir = join(root, 'src');
|
|
212
|
-
const pagesDir = join(srcDir, 'pages');
|
|
213
|
-
|
|
214
|
-
if (!existsSync(srcDir)) {
|
|
215
|
-
throw new Error('src/ directory not found!');
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
let routes = [];
|
|
219
|
-
if (existsSync(pagesDir)) {
|
|
220
|
-
routes = await discoverRoutes(pagesDir);
|
|
221
|
-
logger.info(`Found ${routes.length} routes`);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
await compileBuildDirectory(srcDir, buildDir, root, envVars);
|
|
225
|
-
|
|
226
|
-
if (routes.length > 0) {
|
|
227
|
-
await generateBuildRouter(routes, buildDir);
|
|
228
|
-
logger.info('Generated router for build');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
return { routes };
|
|
232
|
-
}
|
|
233
482
|
|
|
234
483
|
async function discoverRoutes(pagesDir) {
|
|
235
484
|
const routes = [];
|
|
@@ -245,12 +494,10 @@ async function discoverRoutes(pagesDir) {
|
|
|
245
494
|
await scanDirectory(fullPath, relativePath);
|
|
246
495
|
} else if (entry.isFile()) {
|
|
247
496
|
const ext = extname(entry.name);
|
|
248
|
-
|
|
249
497
|
if (ext === '.css') continue;
|
|
250
498
|
|
|
251
499
|
if (['.jsx', '.tsx', '.js', '.ts'].includes(ext)) {
|
|
252
500
|
const fileName = entry.name.replace(ext, '');
|
|
253
|
-
|
|
254
501
|
let route = '/' + relativePath.replace(/\\/g, '/').replace(ext, '');
|
|
255
502
|
|
|
256
503
|
if (fileName === 'index') {
|
|
@@ -258,13 +505,12 @@ async function discoverRoutes(pagesDir) {
|
|
|
258
505
|
}
|
|
259
506
|
|
|
260
507
|
const isDynamic = fileName.includes('[') && fileName.includes(']');
|
|
261
|
-
const type = isDynamic ? 'dynamic' : 'static';
|
|
262
508
|
|
|
263
509
|
routes.push({
|
|
264
510
|
route: route === '' ? '/' : route,
|
|
265
511
|
file: relativePath.replace(/\\/g, '/'),
|
|
266
512
|
path: fullPath,
|
|
267
|
-
type
|
|
513
|
+
type: isDynamic ? 'dynamic' : 'static'
|
|
268
514
|
});
|
|
269
515
|
}
|
|
270
516
|
}
|
|
@@ -272,153 +518,11 @@ async function discoverRoutes(pagesDir) {
|
|
|
272
518
|
}
|
|
273
519
|
|
|
274
520
|
await scanDirectory(pagesDir);
|
|
275
|
-
|
|
276
|
-
routes.sort((a, b) => {
|
|
277
|
-
if (a.type === b.type) {
|
|
278
|
-
return a.route.localeCompare(b.route);
|
|
279
|
-
}
|
|
280
|
-
return a.type === 'static' ? -1 : 1;
|
|
281
|
-
});
|
|
521
|
+
routes.sort((a, b) => a.type === b.type ? a.route.localeCompare(b.route) : a.type === 'static' ? -1 : 1);
|
|
282
522
|
|
|
283
523
|
return routes;
|
|
284
524
|
}
|
|
285
525
|
|
|
286
|
-
async function generateBuildRouter(routes, buildDir) {
|
|
287
|
-
const imports = routes.map((route, i) => {
|
|
288
|
-
const componentName = `Page${i}`;
|
|
289
|
-
const importPath = `./pages/${route.file.replace(/\.(jsx|tsx|ts)$/, '.js')}`;
|
|
290
|
-
return `import ${componentName} from '${importPath}';`;
|
|
291
|
-
}).join('\n');
|
|
292
|
-
|
|
293
|
-
const routeConfigs = routes.map((route, i) => {
|
|
294
|
-
const componentName = `Page${i}`;
|
|
295
|
-
return ` { path: '${route.route}', component: ${componentName}, type: '${route.type}' }`;
|
|
296
|
-
}).join(',\n');
|
|
297
|
-
|
|
298
|
-
const routerCode = `import React, { useState, useEffect, createContext, useContext } from 'react';
|
|
299
|
-
|
|
300
|
-
const RouterContext = createContext(null);
|
|
301
|
-
|
|
302
|
-
export function useRouter() {
|
|
303
|
-
const context = useContext(RouterContext);
|
|
304
|
-
if (!context) {
|
|
305
|
-
throw new Error('useRouter must be used within a Router component');
|
|
306
|
-
}
|
|
307
|
-
return context;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export function Router({ routes }) {
|
|
311
|
-
const [currentRoute, setCurrentRoute] = useState(null);
|
|
312
|
-
const [params, setParams] = useState({});
|
|
313
|
-
|
|
314
|
-
useEffect(() => {
|
|
315
|
-
matchAndSetRoute(window.location.pathname);
|
|
316
|
-
|
|
317
|
-
const handlePopState = () => {
|
|
318
|
-
matchAndSetRoute(window.location.pathname);
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
window.addEventListener('popstate', handlePopState);
|
|
322
|
-
return () => window.removeEventListener('popstate', handlePopState);
|
|
323
|
-
}, [routes]);
|
|
324
|
-
|
|
325
|
-
function matchAndSetRoute(pathname) {
|
|
326
|
-
for (const route of routes) {
|
|
327
|
-
if (route.type === 'static' && route.path === pathname) {
|
|
328
|
-
setCurrentRoute(route);
|
|
329
|
-
setParams({});
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
for (const route of routes) {
|
|
335
|
-
if (route.type === 'dynamic') {
|
|
336
|
-
const pattern = route.path.replace(/\\[([^\\]]+)\\]/g, '([^/]+)');
|
|
337
|
-
const regex = new RegExp('^' + pattern + '$');
|
|
338
|
-
const match = pathname.match(regex);
|
|
339
|
-
|
|
340
|
-
if (match) {
|
|
341
|
-
const paramNames = [...route.path.matchAll(/\\[([^\\]]+)\\]/g)].map(m => m[1]);
|
|
342
|
-
const extractedParams = {};
|
|
343
|
-
paramNames.forEach((name, i) => {
|
|
344
|
-
extractedParams[name] = match[i + 1];
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
setCurrentRoute(route);
|
|
348
|
-
setParams(extractedParams);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
setCurrentRoute(null);
|
|
355
|
-
setParams({});
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function navigate(path) {
|
|
359
|
-
window.history.pushState({}, '', path);
|
|
360
|
-
matchAndSetRoute(path);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const routerValue = {
|
|
364
|
-
currentRoute,
|
|
365
|
-
params,
|
|
366
|
-
navigate,
|
|
367
|
-
pathname: window.location.pathname
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
const Component = currentRoute?.component;
|
|
371
|
-
|
|
372
|
-
return React.createElement(
|
|
373
|
-
RouterContext.Provider,
|
|
374
|
-
{ value: routerValue },
|
|
375
|
-
Component ? React.createElement(Component, { params }) : React.createElement(NotFound, null)
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
export function Link({ to, children, ...props }) {
|
|
380
|
-
const { navigate } = useRouter();
|
|
381
|
-
|
|
382
|
-
function handleClick(e) {
|
|
383
|
-
e.preventDefault();
|
|
384
|
-
navigate(to);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
return React.createElement('a', { href: to, onClick: handleClick, ...props }, children);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function NotFound() {
|
|
391
|
-
return React.createElement(
|
|
392
|
-
'div',
|
|
393
|
-
{
|
|
394
|
-
style: {
|
|
395
|
-
display: 'flex',
|
|
396
|
-
flexDirection: 'column',
|
|
397
|
-
alignItems: 'center',
|
|
398
|
-
justifyContent: 'center',
|
|
399
|
-
minHeight: '100vh',
|
|
400
|
-
fontFamily: 'system-ui'
|
|
401
|
-
}
|
|
402
|
-
},
|
|
403
|
-
React.createElement('h1', { style: { fontSize: '6rem', margin: 0 } }, '404'),
|
|
404
|
-
React.createElement('p', { style: { fontSize: '1.5rem', color: '#666' } }, 'Page not found'),
|
|
405
|
-
React.createElement('a', {
|
|
406
|
-
href: '/',
|
|
407
|
-
style: { color: '#10b981', textDecoration: 'none', fontSize: '1.2rem' }
|
|
408
|
-
}, 'Go home')
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
${imports}
|
|
413
|
-
|
|
414
|
-
export const routes = [
|
|
415
|
-
${routeConfigs}
|
|
416
|
-
];
|
|
417
|
-
`;
|
|
418
|
-
|
|
419
|
-
await Bun.write(join(buildDir, 'router.js'), routerCode);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
526
|
async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
423
527
|
const files = readdirSync(srcDir);
|
|
424
528
|
|
|
@@ -432,7 +536,6 @@ async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
|
432
536
|
await compileBuildDirectory(srcPath, subBuildDir, root, envVars);
|
|
433
537
|
} else {
|
|
434
538
|
const ext = extname(file);
|
|
435
|
-
|
|
436
539
|
if (ext === '.css') continue;
|
|
437
540
|
|
|
438
541
|
if (['.jsx', '.tsx', '.ts'].includes(ext)) {
|
|
@@ -440,15 +543,12 @@ async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
|
440
543
|
} else if (ext === '.js') {
|
|
441
544
|
const outPath = join(buildDir, file);
|
|
442
545
|
let code = await Bun.file(srcPath).text();
|
|
443
|
-
|
|
444
546
|
code = removeCSSImports(code);
|
|
445
547
|
code = replaceEnvInCode(code, envVars);
|
|
446
548
|
code = fixBuildImports(code, srcPath, outPath, root);
|
|
447
|
-
|
|
448
549
|
if (usesJSX(code) && !code.includes('import React')) {
|
|
449
550
|
code = `import React from 'react';\n${code}`;
|
|
450
551
|
}
|
|
451
|
-
|
|
452
552
|
await Bun.write(outPath, code);
|
|
453
553
|
}
|
|
454
554
|
}
|
|
@@ -461,13 +561,11 @@ async function compileBuildFile(srcPath, buildDir, filename, root, envVars) {
|
|
|
461
561
|
|
|
462
562
|
try {
|
|
463
563
|
let code = await Bun.file(srcPath).text();
|
|
464
|
-
|
|
465
564
|
code = removeCSSImports(code);
|
|
466
565
|
code = replaceEnvInCode(code, envVars);
|
|
467
566
|
|
|
468
567
|
const outFilename = filename.replace(/\.(jsx|tsx|ts)$/, '.js');
|
|
469
568
|
const outPath = join(buildDir, outFilename);
|
|
470
|
-
|
|
471
569
|
code = fixBuildImports(code, srcPath, outPath, root);
|
|
472
570
|
|
|
473
571
|
const transpiler = new Bun.Transpiler({
|
|
@@ -482,13 +580,10 @@ async function compileBuildFile(srcPath, buildDir, filename, root, envVars) {
|
|
|
482
580
|
});
|
|
483
581
|
|
|
484
582
|
let compiled = await transpiler.transform(code);
|
|
485
|
-
|
|
486
583
|
if (usesJSX(compiled) && !compiled.includes('import React')) {
|
|
487
584
|
compiled = `import React from 'react';\n${compiled}`;
|
|
488
585
|
}
|
|
489
|
-
|
|
490
586
|
compiled = fixRelativeImports(compiled);
|
|
491
|
-
|
|
492
587
|
await Bun.write(outPath, compiled);
|
|
493
588
|
} catch (error) {
|
|
494
589
|
logger.error(`Failed to compile ${filename}: ${error.message}`);
|
|
@@ -513,28 +608,19 @@ function removeCSSImports(code) {
|
|
|
513
608
|
function fixBuildImports(code, srcPath, outPath, root) {
|
|
514
609
|
const buildDir = join(root, '.bertuibuild');
|
|
515
610
|
const routerPath = join(buildDir, 'router.js');
|
|
516
|
-
|
|
517
611
|
const relativeToRouter = relative(dirname(outPath), routerPath).replace(/\\/g, '/');
|
|
518
612
|
const routerImport = relativeToRouter.startsWith('.') ? relativeToRouter : './' + relativeToRouter;
|
|
519
613
|
|
|
520
|
-
code = code.replace(
|
|
521
|
-
/from\s+['"]bertui\/router['"]/g,
|
|
522
|
-
`from '${routerImport}'`
|
|
523
|
-
);
|
|
524
|
-
|
|
614
|
+
code = code.replace(/from\s+['"]bertui\/router['"]/g, `from '${routerImport}'`);
|
|
525
615
|
return code;
|
|
526
616
|
}
|
|
527
617
|
|
|
528
618
|
function fixRelativeImports(code) {
|
|
529
619
|
const importRegex = /from\s+['"](\.\.[\/\\]|\.\/)((?:[^'"]+?)(?<!\.js|\.jsx|\.ts|\.tsx|\.json))['"];?/g;
|
|
530
|
-
|
|
531
620
|
code = code.replace(importRegex, (match, prefix, path) => {
|
|
532
|
-
if (path.endsWith('/') || /\.\w+$/.test(path))
|
|
533
|
-
return match;
|
|
534
|
-
}
|
|
621
|
+
if (path.endsWith('/') || /\.\w+$/.test(path)) return match;
|
|
535
622
|
return `from '${prefix}${path}.js';`;
|
|
536
623
|
});
|
|
537
|
-
|
|
538
624
|
return code;
|
|
539
625
|
}
|
|
540
626
|
|
|
@@ -566,113 +652,113 @@ function extractMetaFromSource(code) {
|
|
|
566
652
|
let match;
|
|
567
653
|
|
|
568
654
|
while ((match = pairRegex.exec(metaString)) !== null) {
|
|
569
|
-
|
|
570
|
-
const value = match[3];
|
|
571
|
-
meta[key] = value;
|
|
655
|
+
meta[match[1]] = match[3];
|
|
572
656
|
}
|
|
573
657
|
|
|
574
658
|
return Object.keys(meta).length > 0 ? meta : null;
|
|
575
659
|
} catch (error) {
|
|
576
|
-
logger.warn(`Could not extract meta: ${error.message}`);
|
|
577
660
|
return null;
|
|
578
661
|
}
|
|
579
662
|
}
|
|
580
663
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
);
|
|
588
|
-
|
|
589
|
-
if (!mainBundle) {
|
|
590
|
-
logger.error('ā Could not find main bundle');
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/');
|
|
595
|
-
logger.info(`Main JS bundle: /${bundlePath}`);
|
|
596
|
-
|
|
597
|
-
// Load config
|
|
598
|
-
const { loadConfig } = await import('./config/loadConfig.js');
|
|
599
|
-
const config = await loadConfig(root);
|
|
600
|
-
const defaultMeta = config.meta || {};
|
|
664
|
+
async function generateBuildRouter(routes, buildDir) {
|
|
665
|
+
const imports = routes.map((route, i) => {
|
|
666
|
+
const componentName = `Page${i}`;
|
|
667
|
+
const importPath = `./pages/${route.file.replace(/\.(jsx|tsx|ts)$/, '.js')}`;
|
|
668
|
+
return `import ${componentName} from '${importPath}';`;
|
|
669
|
+
}).join('\n');
|
|
601
670
|
|
|
602
|
-
|
|
671
|
+
const routeConfigs = routes.map((route, i) => {
|
|
672
|
+
const componentName = `Page${i}`;
|
|
673
|
+
return ` { path: '${route.route}', component: ${componentName}, type: '${route.type}' }`;
|
|
674
|
+
}).join(',\n');
|
|
603
675
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
+
}
|
|
619
718
|
}
|
|
620
|
-
|
|
621
|
-
await Bun.write(htmlPath, html);
|
|
622
|
-
logger.success(`ā
Generated: ${route.route === '/' ? '/' : route.route}`);
|
|
623
|
-
|
|
624
|
-
} catch (error) {
|
|
625
|
-
logger.error(`Failed to generate HTML for ${route.route}: ${error.message}`);
|
|
626
719
|
}
|
|
720
|
+
setCurrentRoute(null);
|
|
721
|
+
setParams({});
|
|
627
722
|
}
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
);
|
|
630
735
|
}
|
|
631
736
|
|
|
632
|
-
function
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
<link rel="stylesheet" href="/styles/bertui.min.css">
|
|
658
|
-
|
|
659
|
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
660
|
-
<link rel="canonical" href="${route.route}">
|
|
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
|
+
];`;
|
|
661
762
|
|
|
662
|
-
|
|
663
|
-
{
|
|
664
|
-
"imports": {
|
|
665
|
-
"react": "https://esm.sh/react@18.2.0",
|
|
666
|
-
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
|
667
|
-
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
668
|
-
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime"
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
</script>
|
|
672
|
-
</head>
|
|
673
|
-
<body>
|
|
674
|
-
<div id="root"></div>
|
|
675
|
-
<script type="module" src="/${bundlePath}"></script>
|
|
676
|
-
</body>
|
|
677
|
-
</html>`;
|
|
763
|
+
await Bun.write(join(buildDir, 'router.js'), routerCode);
|
|
678
764
|
}
|