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/src/build.js CHANGED
@@ -1,27 +1,21 @@
1
- // src/build.js - COMPLETE v1.0.0 FIXED VERSION
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 { optimizeImages, checkOptimizationTools, copyImages } from './build/image-optimizer.js';
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 FOR PRODUCTION v1.0.0', { color: 'green' });
15
- logger.info('šŸ”„ SINGLE CSS FILE SOLUTION - NO BULLSHIT');
14
+ logger.bigLog('BUILDING WITH SERVER ISLANDS šŸļø', { color: 'green' });
15
+ logger.info('šŸ”„ OPTIONAL SERVER CONTENT - THE GAME CHANGER');
16
16
 
17
- // Clean up old builds
18
- if (existsSync(buildDir)) {
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
- if (Object.keys(envVars).length > 0) {
35
- logger.info(`Loaded ${Object.keys(envVars).length} environment variables`);
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
- logger.info('Step 1: Compiling for production...');
39
- const { routes } = await compileForBuild(root, buildDir, envVars);
40
- logger.success(`Production compilation complete - ${routes.length} routes`);
41
+ if (clientRoutes.length > 0) {
42
+ logger.info(`Client-only routes: ${clientRoutes.length}`);
43
+ }
41
44
 
42
- logger.info('Step 2: Combining ALL CSS into ONE file...');
45
+ logger.info('Step 2: Combining CSS...');
43
46
  await buildAllCSS(root, outDir);
44
47
 
45
- logger.info('Step 3: Checking image optimization tools...');
46
- const optimizationTools = await checkOptimizationTools();
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 5: Bundling JavaScript with Bun...');
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 successfully');
84
+ logger.success('JavaScript bundled');
90
85
 
91
- logger.info('Step 6: Generating HTML files with SINGLE CSS...');
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
- // Clean up build directory
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
- logger.table(result.outputs.map(o => ({
105
- file: o.path.replace(outDir, ''),
106
- size: `${(o.size / 1024).toFixed(2)} KB`,
107
- type: o.kind
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
- logger.error(error.stack);
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
- if (existsSync(buildDir)) {
124
- rmSync(buildDir, { recursive: true });
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
- process.exit(1);
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
- // SIMPLE asset copying
132
- async function copyAllStaticAssets(root, outDir, optimize = true) {
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
- // COMBINE ALL CSS INTO ONE FILE
171
- let combinedCSS = '';
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 cssContent = await Bun.file(srcPath).text();
176
- combinedCSS += `/* === ${cssFile} === */\n${cssContent}\n\n`;
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
- const size = (await Bun.file(combinedPath).size()) / 1024;
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
- const key = match[1];
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
- // GENERATE HTML WITH SINGLE CSS FILE
566
- async function generateProductionHTML(root, outDir, buildResult, routes) {
567
- logger.info('Step 6: Generating HTML files with SINGLE CSS...');
568
-
569
- const mainBundle = buildResult.outputs.find(o =>
570
- o.path.includes('main') && o.kind === 'entry-point'
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
- logger.info(`Generating HTML for ${routes.length} routes...`);
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
- for (const route of routes) {
589
- try {
590
- const sourceCode = await Bun.file(route.path).text();
591
- const pageMeta = extractMetaFromSource(sourceCode);
592
- const meta = { ...defaultMeta, ...pageMeta };
593
-
594
- const html = generateHTML(meta, route, bundlePath);
595
-
596
- let htmlPath;
597
- if (route.route === '/') {
598
- htmlPath = join(outDir, 'index.html');
599
- } else {
600
- const routeDir = join(outDir, route.route.replace(/^\//, ''));
601
- mkdirSync(routeDir, { recursive: true });
602
- htmlPath = join(routeDir, 'index.html');
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
- logger.success('✨ All HTML files generated with SINGLE CSS file!');
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 generateHTML(meta, route, bundlePath) {
617
- return `<!DOCTYPE html>
618
- <html lang="${meta.lang || 'en'}">
619
- <head>
620
- <meta charset="UTF-8">
621
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
622
- <title>${meta.title || 'BertUI App'}</title>
623
-
624
- <meta name="description" content="${meta.description || 'Built with BertUI - Lightning fast React development'}">
625
- ${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
626
- ${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
627
- ${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
628
-
629
- <meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
630
- <meta property="og:description" content="${meta.ogDescription || meta.description || 'Built with BertUI'}">
631
- ${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
632
- <meta property="og:type" content="website">
633
- <meta property="og:url" content="${route.route}">
634
-
635
- <meta name="twitter:card" content="summary_large_image">
636
- <meta name="twitter:title" content="${meta.twitterTitle || meta.ogTitle || meta.title || 'BertUI App'}">
637
- <meta name="twitter:description" content="${meta.twitterDescription || meta.ogDescription || meta.description || 'Built with BertUI'}">
638
- ${meta.twitterImage || meta.ogImage ? `<meta name="twitter:image" content="${meta.twitterImage || meta.ogImage}">` : ''}
639
-
640
- <!-- šŸ”„ ONE CSS FILE FOR ALL PAGES - NO BULLSHIT -->
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
- <script type="importmap">
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
  }