bertui 1.0.2 ā 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/index.js +1 -1
- package/package.json +2 -2
- package/src/build/server-island-validator.js +156 -0
- package/src/build.js +468 -366
- 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,77 +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;
|
|
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
|
+
}
|
|
128
426
|
}
|
|
427
|
+
</script>
|
|
428
|
+
</head>
|
|
429
|
+
<body>
|
|
430
|
+
${comment}
|
|
431
|
+
${rootContent}
|
|
432
|
+
<script type="module" src="/${bundlePath}"></script>
|
|
433
|
+
</body>
|
|
434
|
+
</html>`;
|
|
129
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
453
|
async function buildAllCSS(root, outDir) {
|
|
161
454
|
const srcStylesDir = join(root, 'src', 'styles');
|
|
162
455
|
const stylesOutDir = join(outDir, 'styles');
|
|
@@ -165,56 +458,28 @@ async function buildAllCSS(root, outDir) {
|
|
|
165
458
|
|
|
166
459
|
if (existsSync(srcStylesDir)) {
|
|
167
460
|
const cssFiles = readdirSync(srcStylesDir).filter(f => f.endsWith('.css'));
|
|
168
|
-
logger.info(`š¦ Found ${cssFiles.length} CSS files to combine`);
|
|
169
461
|
|
|
170
|
-
|
|
171
|
-
|
|
462
|
+
if (cssFiles.length === 0) {
|
|
463
|
+
await Bun.write(join(stylesOutDir, 'bertui.min.css'), '/* No CSS */');
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
172
466
|
|
|
467
|
+
let combinedCSS = '';
|
|
173
468
|
for (const cssFile of cssFiles) {
|
|
174
469
|
const srcPath = join(srcStylesDir, cssFile);
|
|
175
|
-
const
|
|
176
|
-
|
|
470
|
+
const file = Bun.file(srcPath);
|
|
471
|
+
const cssContent = await file.text();
|
|
472
|
+
combinedCSS += `/* ${cssFile} */\n${cssContent}\n\n`;
|
|
177
473
|
}
|
|
178
474
|
|
|
179
|
-
// Write combined CSS
|
|
180
475
|
const combinedPath = join(stylesOutDir, 'bertui.min.css');
|
|
181
476
|
await Bun.write(combinedPath, combinedCSS);
|
|
182
|
-
|
|
183
|
-
// Minify it
|
|
184
477
|
await buildCSS(combinedPath, combinedPath);
|
|
185
478
|
|
|
186
|
-
|
|
187
|
-
logger.success(`ā
Combined ${cssFiles.length} CSS files ā bertui.min.css (${size.toFixed(1)}KB)`);
|
|
188
|
-
|
|
189
|
-
} else {
|
|
190
|
-
logger.warn('ā ļø No src/styles/ directory found');
|
|
479
|
+
logger.success(`ā
Combined ${cssFiles.length} CSS files`);
|
|
191
480
|
}
|
|
192
481
|
}
|
|
193
482
|
|
|
194
|
-
async function compileForBuild(root, buildDir, envVars) {
|
|
195
|
-
const srcDir = join(root, 'src');
|
|
196
|
-
const pagesDir = join(srcDir, 'pages');
|
|
197
|
-
|
|
198
|
-
if (!existsSync(srcDir)) {
|
|
199
|
-
throw new Error('src/ directory not found!');
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
let routes = [];
|
|
203
|
-
if (existsSync(pagesDir)) {
|
|
204
|
-
routes = await discoverRoutes(pagesDir);
|
|
205
|
-
logger.info(`Found ${routes.length} routes`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
await compileBuildDirectory(srcDir, buildDir, root, envVars);
|
|
209
|
-
|
|
210
|
-
if (routes.length > 0) {
|
|
211
|
-
await generateBuildRouter(routes, buildDir);
|
|
212
|
-
logger.info('Generated router for build');
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return { routes };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
483
|
async function discoverRoutes(pagesDir) {
|
|
219
484
|
const routes = [];
|
|
220
485
|
|
|
@@ -229,12 +494,10 @@ async function discoverRoutes(pagesDir) {
|
|
|
229
494
|
await scanDirectory(fullPath, relativePath);
|
|
230
495
|
} else if (entry.isFile()) {
|
|
231
496
|
const ext = extname(entry.name);
|
|
232
|
-
|
|
233
497
|
if (ext === '.css') continue;
|
|
234
498
|
|
|
235
499
|
if (['.jsx', '.tsx', '.js', '.ts'].includes(ext)) {
|
|
236
500
|
const fileName = entry.name.replace(ext, '');
|
|
237
|
-
|
|
238
501
|
let route = '/' + relativePath.replace(/\\/g, '/').replace(ext, '');
|
|
239
502
|
|
|
240
503
|
if (fileName === 'index') {
|
|
@@ -242,13 +505,12 @@ async function discoverRoutes(pagesDir) {
|
|
|
242
505
|
}
|
|
243
506
|
|
|
244
507
|
const isDynamic = fileName.includes('[') && fileName.includes(']');
|
|
245
|
-
const type = isDynamic ? 'dynamic' : 'static';
|
|
246
508
|
|
|
247
509
|
routes.push({
|
|
248
510
|
route: route === '' ? '/' : route,
|
|
249
511
|
file: relativePath.replace(/\\/g, '/'),
|
|
250
512
|
path: fullPath,
|
|
251
|
-
type
|
|
513
|
+
type: isDynamic ? 'dynamic' : 'static'
|
|
252
514
|
});
|
|
253
515
|
}
|
|
254
516
|
}
|
|
@@ -256,153 +518,11 @@ async function discoverRoutes(pagesDir) {
|
|
|
256
518
|
}
|
|
257
519
|
|
|
258
520
|
await scanDirectory(pagesDir);
|
|
259
|
-
|
|
260
|
-
routes.sort((a, b) => {
|
|
261
|
-
if (a.type === b.type) {
|
|
262
|
-
return a.route.localeCompare(b.route);
|
|
263
|
-
}
|
|
264
|
-
return a.type === 'static' ? -1 : 1;
|
|
265
|
-
});
|
|
521
|
+
routes.sort((a, b) => a.type === b.type ? a.route.localeCompare(b.route) : a.type === 'static' ? -1 : 1);
|
|
266
522
|
|
|
267
523
|
return routes;
|
|
268
524
|
}
|
|
269
525
|
|
|
270
|
-
async function generateBuildRouter(routes, buildDir) {
|
|
271
|
-
const imports = routes.map((route, i) => {
|
|
272
|
-
const componentName = `Page${i}`;
|
|
273
|
-
const importPath = `./pages/${route.file.replace(/\.(jsx|tsx|ts)$/, '.js')}`;
|
|
274
|
-
return `import ${componentName} from '${importPath}';`;
|
|
275
|
-
}).join('\n');
|
|
276
|
-
|
|
277
|
-
const routeConfigs = routes.map((route, i) => {
|
|
278
|
-
const componentName = `Page${i}`;
|
|
279
|
-
return ` { path: '${route.route}', component: ${componentName}, type: '${route.type}' }`;
|
|
280
|
-
}).join(',\n');
|
|
281
|
-
|
|
282
|
-
const routerCode = `import React, { useState, useEffect, createContext, useContext } from 'react';
|
|
283
|
-
|
|
284
|
-
const RouterContext = createContext(null);
|
|
285
|
-
|
|
286
|
-
export function useRouter() {
|
|
287
|
-
const context = useContext(RouterContext);
|
|
288
|
-
if (!context) {
|
|
289
|
-
throw new Error('useRouter must be used within a Router component');
|
|
290
|
-
}
|
|
291
|
-
return context;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
export function Router({ routes }) {
|
|
295
|
-
const [currentRoute, setCurrentRoute] = useState(null);
|
|
296
|
-
const [params, setParams] = useState({});
|
|
297
|
-
|
|
298
|
-
useEffect(() => {
|
|
299
|
-
matchAndSetRoute(window.location.pathname);
|
|
300
|
-
|
|
301
|
-
const handlePopState = () => {
|
|
302
|
-
matchAndSetRoute(window.location.pathname);
|
|
303
|
-
};
|
|
304
|
-
|
|
305
|
-
window.addEventListener('popstate', handlePopState);
|
|
306
|
-
return () => window.removeEventListener('popstate', handlePopState);
|
|
307
|
-
}, [routes]);
|
|
308
|
-
|
|
309
|
-
function matchAndSetRoute(pathname) {
|
|
310
|
-
for (const route of routes) {
|
|
311
|
-
if (route.type === 'static' && route.path === pathname) {
|
|
312
|
-
setCurrentRoute(route);
|
|
313
|
-
setParams({});
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
for (const route of routes) {
|
|
319
|
-
if (route.type === 'dynamic') {
|
|
320
|
-
const pattern = route.path.replace(/\\[([^\\]]+)\\]/g, '([^/]+)');
|
|
321
|
-
const regex = new RegExp('^' + pattern + '$');
|
|
322
|
-
const match = pathname.match(regex);
|
|
323
|
-
|
|
324
|
-
if (match) {
|
|
325
|
-
const paramNames = [...route.path.matchAll(/\\[([^\\]]+)\\]/g)].map(m => m[1]);
|
|
326
|
-
const extractedParams = {};
|
|
327
|
-
paramNames.forEach((name, i) => {
|
|
328
|
-
extractedParams[name] = match[i + 1];
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
setCurrentRoute(route);
|
|
332
|
-
setParams(extractedParams);
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
setCurrentRoute(null);
|
|
339
|
-
setParams({});
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function navigate(path) {
|
|
343
|
-
window.history.pushState({}, '', path);
|
|
344
|
-
matchAndSetRoute(path);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const routerValue = {
|
|
348
|
-
currentRoute,
|
|
349
|
-
params,
|
|
350
|
-
navigate,
|
|
351
|
-
pathname: window.location.pathname
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
const Component = currentRoute?.component;
|
|
355
|
-
|
|
356
|
-
return React.createElement(
|
|
357
|
-
RouterContext.Provider,
|
|
358
|
-
{ value: routerValue },
|
|
359
|
-
Component ? React.createElement(Component, { params }) : React.createElement(NotFound, null)
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function Link({ to, children, ...props }) {
|
|
364
|
-
const { navigate } = useRouter();
|
|
365
|
-
|
|
366
|
-
function handleClick(e) {
|
|
367
|
-
e.preventDefault();
|
|
368
|
-
navigate(to);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return React.createElement('a', { href: to, onClick: handleClick, ...props }, children);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function NotFound() {
|
|
375
|
-
return React.createElement(
|
|
376
|
-
'div',
|
|
377
|
-
{
|
|
378
|
-
style: {
|
|
379
|
-
display: 'flex',
|
|
380
|
-
flexDirection: 'column',
|
|
381
|
-
alignItems: 'center',
|
|
382
|
-
justifyContent: 'center',
|
|
383
|
-
minHeight: '100vh',
|
|
384
|
-
fontFamily: 'system-ui'
|
|
385
|
-
}
|
|
386
|
-
},
|
|
387
|
-
React.createElement('h1', { style: { fontSize: '6rem', margin: 0 } }, '404'),
|
|
388
|
-
React.createElement('p', { style: { fontSize: '1.5rem', color: '#666' } }, 'Page not found'),
|
|
389
|
-
React.createElement('a', {
|
|
390
|
-
href: '/',
|
|
391
|
-
style: { color: '#10b981', textDecoration: 'none', fontSize: '1.2rem' }
|
|
392
|
-
}, 'Go home')
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
${imports}
|
|
397
|
-
|
|
398
|
-
export const routes = [
|
|
399
|
-
${routeConfigs}
|
|
400
|
-
];
|
|
401
|
-
`;
|
|
402
|
-
|
|
403
|
-
await Bun.write(join(buildDir, 'router.js'), routerCode);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
526
|
async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
407
527
|
const files = readdirSync(srcDir);
|
|
408
528
|
|
|
@@ -416,7 +536,6 @@ async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
|
416
536
|
await compileBuildDirectory(srcPath, subBuildDir, root, envVars);
|
|
417
537
|
} else {
|
|
418
538
|
const ext = extname(file);
|
|
419
|
-
|
|
420
539
|
if (ext === '.css') continue;
|
|
421
540
|
|
|
422
541
|
if (['.jsx', '.tsx', '.ts'].includes(ext)) {
|
|
@@ -424,15 +543,12 @@ async function compileBuildDirectory(srcDir, buildDir, root, envVars) {
|
|
|
424
543
|
} else if (ext === '.js') {
|
|
425
544
|
const outPath = join(buildDir, file);
|
|
426
545
|
let code = await Bun.file(srcPath).text();
|
|
427
|
-
|
|
428
546
|
code = removeCSSImports(code);
|
|
429
547
|
code = replaceEnvInCode(code, envVars);
|
|
430
548
|
code = fixBuildImports(code, srcPath, outPath, root);
|
|
431
|
-
|
|
432
549
|
if (usesJSX(code) && !code.includes('import React')) {
|
|
433
550
|
code = `import React from 'react';\n${code}`;
|
|
434
551
|
}
|
|
435
|
-
|
|
436
552
|
await Bun.write(outPath, code);
|
|
437
553
|
}
|
|
438
554
|
}
|
|
@@ -445,13 +561,11 @@ async function compileBuildFile(srcPath, buildDir, filename, root, envVars) {
|
|
|
445
561
|
|
|
446
562
|
try {
|
|
447
563
|
let code = await Bun.file(srcPath).text();
|
|
448
|
-
|
|
449
564
|
code = removeCSSImports(code);
|
|
450
565
|
code = replaceEnvInCode(code, envVars);
|
|
451
566
|
|
|
452
567
|
const outFilename = filename.replace(/\.(jsx|tsx|ts)$/, '.js');
|
|
453
568
|
const outPath = join(buildDir, outFilename);
|
|
454
|
-
|
|
455
569
|
code = fixBuildImports(code, srcPath, outPath, root);
|
|
456
570
|
|
|
457
571
|
const transpiler = new Bun.Transpiler({
|
|
@@ -466,13 +580,10 @@ async function compileBuildFile(srcPath, buildDir, filename, root, envVars) {
|
|
|
466
580
|
});
|
|
467
581
|
|
|
468
582
|
let compiled = await transpiler.transform(code);
|
|
469
|
-
|
|
470
583
|
if (usesJSX(compiled) && !compiled.includes('import React')) {
|
|
471
584
|
compiled = `import React from 'react';\n${compiled}`;
|
|
472
585
|
}
|
|
473
|
-
|
|
474
586
|
compiled = fixRelativeImports(compiled);
|
|
475
|
-
|
|
476
587
|
await Bun.write(outPath, compiled);
|
|
477
588
|
} catch (error) {
|
|
478
589
|
logger.error(`Failed to compile ${filename}: ${error.message}`);
|
|
@@ -497,28 +608,19 @@ function removeCSSImports(code) {
|
|
|
497
608
|
function fixBuildImports(code, srcPath, outPath, root) {
|
|
498
609
|
const buildDir = join(root, '.bertuibuild');
|
|
499
610
|
const routerPath = join(buildDir, 'router.js');
|
|
500
|
-
|
|
501
611
|
const relativeToRouter = relative(dirname(outPath), routerPath).replace(/\\/g, '/');
|
|
502
612
|
const routerImport = relativeToRouter.startsWith('.') ? relativeToRouter : './' + relativeToRouter;
|
|
503
613
|
|
|
504
|
-
code = code.replace(
|
|
505
|
-
/from\s+['"]bertui\/router['"]/g,
|
|
506
|
-
`from '${routerImport}'`
|
|
507
|
-
);
|
|
508
|
-
|
|
614
|
+
code = code.replace(/from\s+['"]bertui\/router['"]/g, `from '${routerImport}'`);
|
|
509
615
|
return code;
|
|
510
616
|
}
|
|
511
617
|
|
|
512
618
|
function fixRelativeImports(code) {
|
|
513
619
|
const importRegex = /from\s+['"](\.\.[\/\\]|\.\/)((?:[^'"]+?)(?<!\.js|\.jsx|\.ts|\.tsx|\.json))['"];?/g;
|
|
514
|
-
|
|
515
620
|
code = code.replace(importRegex, (match, prefix, path) => {
|
|
516
|
-
if (path.endsWith('/') || /\.\w+$/.test(path))
|
|
517
|
-
return match;
|
|
518
|
-
}
|
|
621
|
+
if (path.endsWith('/') || /\.\w+$/.test(path)) return match;
|
|
519
622
|
return `from '${prefix}${path}.js';`;
|
|
520
623
|
});
|
|
521
|
-
|
|
522
624
|
return code;
|
|
523
625
|
}
|
|
524
626
|
|
|
@@ -550,113 +652,113 @@ function extractMetaFromSource(code) {
|
|
|
550
652
|
let match;
|
|
551
653
|
|
|
552
654
|
while ((match = pairRegex.exec(metaString)) !== null) {
|
|
553
|
-
|
|
554
|
-
const value = match[3];
|
|
555
|
-
meta[key] = value;
|
|
655
|
+
meta[match[1]] = match[3];
|
|
556
656
|
}
|
|
557
657
|
|
|
558
658
|
return Object.keys(meta).length > 0 ? meta : null;
|
|
559
659
|
} catch (error) {
|
|
560
|
-
logger.warn(`Could not extract meta: ${error.message}`);
|
|
561
660
|
return null;
|
|
562
661
|
}
|
|
563
662
|
}
|
|
564
663
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
);
|
|
572
|
-
|
|
573
|
-
if (!mainBundle) {
|
|
574
|
-
logger.error('ā Could not find main bundle');
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/');
|
|
579
|
-
logger.info(`Main JS bundle: /${bundlePath}`);
|
|
580
|
-
|
|
581
|
-
// Load config
|
|
582
|
-
const { loadConfig } = await import('./config/loadConfig.js');
|
|
583
|
-
const config = await loadConfig(root);
|
|
584
|
-
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');
|
|
585
670
|
|
|
586
|
-
|
|
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');
|
|
587
675
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
+
}
|
|
603
718
|
}
|
|
604
|
-
|
|
605
|
-
await Bun.write(htmlPath, html);
|
|
606
|
-
logger.success(`ā
Generated: ${route.route === '/' ? '/' : route.route}`);
|
|
607
|
-
|
|
608
|
-
} catch (error) {
|
|
609
|
-
logger.error(`Failed to generate HTML for ${route.route}: ${error.message}`);
|
|
610
719
|
}
|
|
720
|
+
setCurrentRoute(null);
|
|
721
|
+
setParams({});
|
|
611
722
|
}
|
|
612
|
-
|
|
613
|
-
|
|
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
|
+
);
|
|
614
735
|
}
|
|
615
736
|
|
|
616
|
-
function
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
<link rel="stylesheet" href="/styles/bertui.min.css">
|
|
642
|
-
|
|
643
|
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
644
|
-
<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
|
+
];`;
|
|
645
762
|
|
|
646
|
-
|
|
647
|
-
{
|
|
648
|
-
"imports": {
|
|
649
|
-
"react": "https://esm.sh/react@18.2.0",
|
|
650
|
-
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
|
651
|
-
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
652
|
-
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime"
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
</script>
|
|
656
|
-
</head>
|
|
657
|
-
<body>
|
|
658
|
-
<div id="root"></div>
|
|
659
|
-
<script type="module" src="/${bundlePath}"></script>
|
|
660
|
-
</body>
|
|
661
|
-
</html>`;
|
|
763
|
+
await Bun.write(join(buildDir, 'router.js'), routerCode);
|
|
662
764
|
}
|