bertui 1.2.7 → 1.2.9

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 CHANGED
@@ -2,14 +2,13 @@
2
2
 
3
3
  **Zero-config React framework powered by Bun. File-based routing, Server Islands, and a build system that gets out of your way.**
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.2.2-blue)](https://www.npmjs.com/package/bertui)
5
+ [![Version](https://img.shields.io/badge/version-1.2.9-blue)](https://www.npmjs.com/package/bertui)
6
6
  [![Bun Powered](https://img.shields.io/badge/runtime-Bun-f472b6)](https://bun.sh)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
8
8
 
9
9
  ---
10
10
 
11
11
  ## Quick Start
12
-
13
12
  ```bash
14
13
  bunx create-bertui my-app
15
14
  cd my-app
@@ -18,11 +17,51 @@ bun run dev
18
17
 
19
18
  ---
20
19
 
20
+ ## What's New in v1.2.9
21
+
22
+ ### Server Islands — Rebuilt from the Ground Up
23
+
24
+ Server Islands are back and fully working. One export, zero config, pure HTML at build time.
25
+ ```jsx
26
+ // src/pages/about.jsx
27
+ export const render = "static"
28
+
29
+ export default function About() {
30
+ return (
31
+ <div>
32
+ <h1>About Us</h1>
33
+ <p>Rendered at build time. Zero JS in the browser.</p>
34
+ </div>
35
+ )
36
+ }
37
+ ```
38
+
39
+ BertUI runs `renderToString` on your page at build time and writes pure HTML to `dist/`. No React runtime ships to the browser. No hydration. Just HTML.
40
+
41
+ Three render modes, one export:
42
+ ```jsx
43
+ // Pure HTML — zero JS, zero React in the browser
44
+ export const render = "static"
45
+
46
+ // SSR HTML + JS bundle — pre-rendered for instant load, hydrated for interactivity
47
+ export const render = "server"
48
+
49
+ // Default — client-only React SPA (no export needed)
50
+ export default function Page() {}
51
+ ```
52
+
53
+ **Rules for static and server pages:**
54
+ - No React hooks (`useState`, `useEffect`, etc.)
55
+ - No event handlers (`onClick`, `onChange`, etc.)
56
+ - No browser APIs (`window`, `document`, `localStorage`)
57
+ - Violations are caught at build time with a clear error
58
+
59
+ ---
60
+
21
61
  ## What's New in v1.2.2
22
62
 
23
63
  ### Import Aliases (`importhow`)
24
64
  No more `../../../` chains. Define aliases in your config and import cleanly from anywhere.
25
-
26
65
  ```javascript
27
66
  // bertui.config.js
28
67
  export default {
@@ -32,7 +71,6 @@ export default {
32
71
  }
33
72
  }
34
73
  ```
35
-
36
74
  ```javascript
37
75
  // anywhere in your project
38
76
  import Button from 'amani/button';
@@ -43,7 +81,6 @@ Aliases are resolved at compile time — zero runtime overhead.
43
81
 
44
82
  ### Node Modules — Just Work
45
83
  Install a package, import it. That's it.
46
-
47
84
  ```javascript
48
85
  import { format } from 'date-fns';
49
86
  import confetti from 'canvas-confetti';
@@ -53,7 +90,6 @@ In dev, packages are served from your local filesystem. In production, only the
53
90
 
54
91
  ### CLI — New Look
55
92
  The build and dev output is now compact and step-based instead of verbose line-by-line logs.
56
-
57
93
  ```
58
94
  ██████╗ ███████╗██████╗ ████████╗██╗ ██╗██╗
59
95
  ██╔══██╗██╔════╝██╔══██╗╚══██╔══╝██║ ██║██║
@@ -64,7 +100,7 @@ The build and dev output is now compact and step-based instead of verbose line-b
64
100
  by Pease Ernest · BUILD
65
101
 
66
102
  [ 1/10] ✓ Loading env
67
- [ 2/10] ✓ Compiling 5 routes · 1 islands
103
+ [ 2/10] ✓ Compiling 5 routes
68
104
  [ 3/10] ⠸ Layouts ...
69
105
  ...
70
106
  ✓ Done 0.54s
@@ -82,7 +118,6 @@ Run `bun add some-package` and the dev server picks it up automatically. The imp
82
118
  ## Features
83
119
 
84
120
  ### File-Based Routing
85
-
86
121
  ```
87
122
  src/pages/index.jsx → /
88
123
  src/pages/about.jsx → /about
@@ -92,31 +127,45 @@ src/pages/blog/[slug].jsx → /blog/:slug
92
127
 
93
128
  ### Server Islands
94
129
 
95
- Add one line to opt a page into static generation at build time.
96
-
130
+ Three render modes. One export at the top of your page.
97
131
  ```jsx
98
- // src/pages/about.jsx
99
- export const render = "server";
100
- export const title = "About Us";
132
+ // render = "static" — pure HTML, zero JS
133
+ // Perfect for: marketing pages, blog posts, docs, any page without interactivity
134
+ export const render = "static"
135
+ export const title = "About Us"
101
136
 
102
137
  export default function About() {
103
138
  return (
104
139
  <div>
105
140
  <h1>About Us</h1>
106
- <p>Pre-rendered as static HTML at build time.</p>
141
+ <p>Pre-rendered at build time. Instant load, perfect SEO.</p>
107
142
  </div>
108
- );
143
+ )
144
+ }
145
+ ```
146
+ ```jsx
147
+ // render = "server" — SSR HTML in the body + JS bundle attached
148
+ // Perfect for: pages that need fast first paint AND interactivity after load
149
+ export const render = "server"
150
+
151
+ export default function Dashboard() {
152
+ return <div><h1>Dashboard</h1></div>
153
+ }
154
+ ```
155
+ ```jsx
156
+ // default — client-only React (no export needed)
157
+ // Perfect for: highly interactive pages, apps, anything with lots of state
158
+ export default function Editor() {
159
+ const [value, setValue] = useState('')
160
+ return <textarea onChange={e => setValue(e.target.value)} />
109
161
  }
110
162
  ```
111
163
 
112
- - Static HTML embedded in the output for instant load and perfect SEO
113
- - Pages with hooks or event handlers are rejected at build time with a clear error
114
- - All other pages are client-only by default
164
+ BertUI automatically detects the render mode and generates the right HTML for each page. Static pages get zero JS. Server pages get pre-rendered HTML with hydration. Default pages get the full SPA treatment.
115
165
 
116
166
  ### TypeScript
117
167
 
118
168
  `.tsx` and `.ts` files work with no setup. Mix them freely with `.jsx`.
119
-
120
169
  ```typescript
121
170
  // src/pages/blog/[slug].tsx
122
171
  import { useRouter } from 'bertui/router';
@@ -130,7 +179,6 @@ export default function BlogPost() {
130
179
  ### SEO
131
180
 
132
181
  `sitemap.xml` and `robots.txt` are generated automatically from your routes. Requires `baseUrl` in config.
133
-
134
182
  ```javascript
135
183
  export default {
136
184
  baseUrl: 'https://example.com',
@@ -148,7 +196,6 @@ Put your styles in `src/styles/`. They are combined and minified with LightningC
148
196
  ---
149
197
 
150
198
  ## Project Structure
151
-
152
199
  ```
153
200
  my-app/
154
201
  ├── src/
@@ -166,7 +213,6 @@ my-app/
166
213
  ---
167
214
 
168
215
  ## Configuration
169
-
170
216
  ```javascript
171
217
  // bertui.config.js
172
218
  export default {
@@ -209,6 +255,7 @@ Benchmarks on an Intel i3-2348M, 7.6GB RAM.
209
255
 
210
256
  - `bertui-elysia` — API routes, auth, database
211
257
  - `bertui-animate` — GPU-accelerated animations
258
+ - Partial hydration — `<Island>` component for mixed static/interactive pages
212
259
 
213
260
  ---
214
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bertui",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Lightning-fast React dev server powered by Bun - Now with Rust image optimization (WASM, no Rust required for users)",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -1,4 +1,4 @@
1
- // bertui/src/build/compiler/index.js - WITH IMPORTHOW + NODE MODULE SUPPORT
1
+ // bertui/src/build/compiler/index.js
2
2
  import { join } from 'path';
3
3
  import { existsSync } from 'fs';
4
4
  import logger from '../../logger/logger.js';
@@ -6,14 +6,8 @@ import { discoverRoutes } from './route-discoverer.js';
6
6
  import { compileBuildDirectory } from './file-transpiler.js';
7
7
  import { generateBuildRouter } from './router-generator.js';
8
8
 
9
- /**
10
- * @param {string} root
11
- * @param {string} buildDir
12
- * @param {Object} envVars
13
- * @param {Object} config - full bertui config (includes importhow)
14
- */
15
9
  export async function compileForBuild(root, buildDir, envVars, config = {}) {
16
- const srcDir = join(root, 'src');
10
+ const srcDir = join(root, 'src');
17
11
  const pagesDir = join(srcDir, 'pages');
18
12
 
19
13
  if (!existsSync(srcDir)) {
@@ -21,33 +15,17 @@ export async function compileForBuild(root, buildDir, envVars, config = {}) {
21
15
  }
22
16
 
23
17
  const importhow = config.importhow || {};
24
-
25
- let routes = [];
26
- let serverIslands = [];
27
- let clientRoutes = [];
18
+ let routes = [];
28
19
 
29
20
  if (existsSync(pagesDir)) {
30
21
  routes = await discoverRoutes(pagesDir);
31
-
32
- for (const route of routes) {
33
- const sourceCode = await Bun.file(route.path).text();
34
- const isServerIsland = sourceCode.includes('export const render = "server"');
35
-
36
- if (isServerIsland) {
37
- serverIslands.push(route);
38
- logger.success(`🏝️ Server Island: ${route.route}`);
39
- } else {
40
- clientRoutes.push(route);
41
- }
42
- }
43
22
  }
44
23
 
45
- // Pass importhow so alias dirs also get compiled
46
24
  await compileBuildDirectory(srcDir, buildDir, root, envVars, importhow);
47
25
 
48
26
  if (routes.length > 0) {
49
27
  await generateBuildRouter(routes, buildDir);
50
28
  }
51
29
 
52
- return { routes, serverIslands, clientRoutes };
30
+ return { routes };
53
31
  }
@@ -7,14 +7,13 @@ export async function generateBuildRouter(routes, buildDir) {
7
7
  const importPath = `./pages/${route.file.replace(/\.(jsx|tsx|ts)$/, '.js')}`;
8
8
  return `import ${componentName} from '${importPath}';`;
9
9
  }).join('\n');
10
-
10
+
11
11
  const routeConfigs = routes.map((route, i) => {
12
12
  const componentName = `Page${i}`;
13
13
  return ` { path: '${route.route}', component: ${componentName}, type: '${route.type}' }`;
14
14
  }).join(',\n');
15
-
15
+
16
16
  const routerCode = `import React, { useState, useEffect, createContext, useContext } from 'react';
17
- import { createRoot } from 'react-dom/client';
18
17
 
19
18
  const RouterContext = createContext(null);
20
19
 
@@ -77,16 +76,16 @@ export function Router({ routes }) {
77
76
 
78
77
  export function Link({ to, children, ...props }) {
79
78
  const { navigate } = useRouter();
80
- return React.createElement('a', {
81
- href: to,
82
- onClick: (e) => { e.preventDefault(); navigate(to); },
83
- ...props
79
+ return React.createElement('a', {
80
+ href: to,
81
+ onClick: (e) => { e.preventDefault(); navigate(to); },
82
+ ...props
84
83
  }, children);
85
84
  }
86
85
 
87
86
  function NotFound() {
88
87
  return React.createElement('div', {
89
- style: { display: 'flex', flexDirection: 'column', alignItems: 'center',
88
+ style: { display: 'flex', flexDirection: 'column', alignItems: 'center',
90
89
  justifyContent: 'center', minHeight: '100vh', fontFamily: 'system-ui' }
91
90
  },
92
91
  React.createElement('h1', { style: { fontSize: '6rem', margin: 0 } }, '404'),
@@ -100,18 +99,7 @@ ${imports}
100
99
  export const routes = [
101
100
  ${routeConfigs}
102
101
  ];
103
-
104
- // Guard against double-mount. router.js is imported by main.js — if it ever
105
- // gets evaluated more than once (e.g. duplicate script tags, HMR quirks),
106
- // this ensures React never calls createRoot on the same container twice,
107
- // which causes "Node.removeChild: The node to be removed is not a child" crashes.
108
- if (!window.__BERTUI_MOUNTED__) {
109
- window.__BERTUI_MOUNTED__ = true;
110
- const container = document.getElementById('root');
111
- const app = React.createElement(Router, { routes });
112
- createRoot(container).render(app);
113
- }
114
102
  `;
115
-
103
+
116
104
  await Bun.write(join(buildDir, 'router.js'), routerCode);
117
105
  }
@@ -1,128 +1,139 @@
1
1
  // bertui/src/build/generators/html-generator.js
2
- import { join, relative } from 'path';
3
- import { mkdirSync, existsSync, cpSync } from 'fs';
4
- import logger from '../../logger/logger.js';
5
- import { extractMetaFromSource } from '../../utils/meta-extractor.js';
2
+ import { join, relative } from 'path'
3
+ import { mkdirSync, existsSync, cpSync } from 'fs'
4
+ import logger from '../../logger/logger.js'
5
+ import { extractMetaFromSource } from '../../utils/meta-extractor.js'
6
+ import { renderPageToHTML, getPageRenderMode } from '../ssr-renderer.js'
6
7
 
7
- export async function generateProductionHTML(root, outDir, buildDir, buildResult, routes, serverIslands, config) {
8
+ export async function generateProductionHTML(root, outDir, buildResult, routes, config, buildDir) {
8
9
  const mainBundle = buildResult.outputs.find(o =>
9
10
  o.path.includes('main') && o.kind === 'entry-point'
10
- );
11
+ )
11
12
 
12
13
  if (!mainBundle) {
13
- logger.error('Could not find main bundle');
14
- return;
14
+ logger.error('Could not find main bundle')
15
+ return
15
16
  }
16
17
 
17
- const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/');
18
- const defaultMeta = config.meta || {};
18
+ const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/')
19
+ const defaultMeta = config.meta || {}
20
+ const bertuiPackages = await copyBertuiPackagesToProduction(root, outDir)
19
21
 
20
- const bertuiPackages = await copyBertuiPackagesToProduction(root, outDir);
21
22
 
22
- logger.info(`📄 Generating HTML for ${routes.length} routes...`);
23
+ logger.info(`Generating HTML for ${routes.length} routes...`)
23
24
 
24
- const BATCH_SIZE = 5;
25
-
26
- for (let i = 0; i < routes.length; i += BATCH_SIZE) {
27
- const batch = routes.slice(i, i + BATCH_SIZE);
28
- logger.debug(`Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(routes.length / BATCH_SIZE)}`);
29
- for (const route of batch) {
30
- await processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages);
31
- }
25
+ for (const route of routes) {
26
+ await processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages, buildDir)
32
27
  }
33
28
 
34
- logger.success(`✅ HTML generation complete for ${routes.length} routes`);
29
+ logger.success(`HTML generation complete for ${routes.length} routes`)
35
30
  }
36
31
 
37
- async function copyBertuiPackagesToProduction(root, outDir) {
38
- const nodeModulesDir = join(root, 'node_modules');
39
- const packages = { bertuiIcons: false, bertuiAnimate: false, elysiaEden: false };
40
-
41
- if (!existsSync(nodeModulesDir)) return packages;
42
-
43
- const bertuiIconsSource = join(nodeModulesDir, 'bertui-icons');
44
- if (existsSync(bertuiIconsSource)) {
45
- try {
46
- const bertuiIconsDest = join(outDir, 'node_modules', 'bertui-icons');
47
- mkdirSync(join(outDir, 'node_modules'), { recursive: true });
48
- cpSync(bertuiIconsSource, bertuiIconsDest, { recursive: true });
49
- logger.success('✅ Copied bertui-icons to dist/node_modules/');
50
- packages.bertuiIcons = true;
51
- } catch (error) {
52
- logger.error(`Failed to copy bertui-icons: ${error.message}`);
53
- }
54
- }
32
+ async function processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages, buildDir) {
33
+ try {
34
+ const sourceCode = await Bun.file(route.path).text()
35
+ const pageMeta = extractMetaFromSource(sourceCode)
36
+ const meta = { ...defaultMeta, ...pageMeta }
55
37
 
56
- const bertuiAnimateSource = join(nodeModulesDir, 'bertui-animate', 'dist');
57
- if (existsSync(bertuiAnimateSource)) {
58
- try {
59
- const bertuiAnimateDest = join(outDir, 'css');
60
- mkdirSync(bertuiAnimateDest, { recursive: true });
61
- const minCSSPath = join(bertuiAnimateSource, 'bertui-animate.min.css');
62
- if (existsSync(minCSSPath)) {
63
- cpSync(minCSSPath, join(bertuiAnimateDest, 'bertui-animate.min.css'));
64
- logger.success('✅ Copied bertui-animate.min.css to dist/css/');
65
- packages.bertuiAnimate = true;
66
- }
67
- } catch (error) {
68
- logger.error(`Failed to copy bertui-animate: ${error.message}`);
69
- }
70
- }
38
+ // Determine render mode from source
39
+ const renderMode = await getPageRenderMode(route.path)
71
40
 
72
- const elysiaEdenSource = join(nodeModulesDir, '@elysiajs', 'eden');
73
- if (existsSync(elysiaEdenSource)) {
74
- try {
75
- const elysiaEdenDest = join(outDir, 'node_modules', '@elysiajs', 'eden');
76
- mkdirSync(join(outDir, 'node_modules', '@elysiajs'), { recursive: true });
77
- cpSync(elysiaEdenSource, elysiaEdenDest, { recursive: true });
78
- logger.success('✅ Copied @elysiajs/eden to dist/node_modules/');
79
- packages.elysiaEden = true;
80
- } catch (error) {
81
- logger.error(`Failed to copy @elysiajs/eden: ${error.message}`);
82
- }
83
- }
41
+ let html
84
42
 
85
- return packages;
86
- }
43
+ if (renderMode === 'server' || renderMode === 'static') {
44
+ // Find the compiled version of this page in buildDir
45
+ const compiledPath = findCompiledPath(route, buildDir)
87
46
 
88
- async function processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages) {
89
- try {
90
- const sourceCode = await Bun.file(route.path).text();
91
- const pageMeta = extractMetaFromSource(sourceCode);
92
- const meta = { ...defaultMeta, ...pageMeta };
47
+ if (compiledPath && existsSync(compiledPath)) {
48
+ logger.info(` SSR rendering: ${route.route}`)
49
+ const ssrHTML = await renderPageToHTML(compiledPath, buildDir)
93
50
 
94
- const html = generateHTML(meta, bundlePath, bertuiPackages);
51
+ if (ssrHTML) {
52
+ if (renderMode === 'static') {
53
+ // Pure static — no JS at all
54
+ html = generateStaticHTML({ ssrHTML, meta, bertuiPackages })
55
+ } else {
56
+ // Server island — SSR HTML + JS bundle for hydration
57
+ html = generateServerIslandHTML({ ssrHTML, meta, bundlePath, bertuiPackages })
58
+ }
59
+ logger.success(` ✓ SSR: ${route.route} (${renderMode})`)
60
+ } else {
61
+ // SSR failed — fall back to client render with a warning
62
+ logger.warn(` SSR failed for ${route.route}, falling back to client render`)
63
+ html = generateClientHTML({ meta, bundlePath, bertuiPackages })
64
+ }
65
+ } else {
66
+ logger.warn(` Compiled path not found for ${route.route}, using client render`)
67
+ html = generateClientHTML({ meta, bundlePath, bertuiPackages })
68
+ }
69
+ } else {
70
+ // Default: client-only SPA
71
+ html = generateClientHTML({ meta, bundlePath, bertuiPackages })
72
+ }
95
73
 
96
- let htmlPath;
74
+ // Write to dist/
75
+ let htmlPath
97
76
  if (route.route === '/') {
98
- htmlPath = join(outDir, 'index.html');
77
+ htmlPath = join(outDir, 'index.html')
99
78
  } else {
100
- const routeDir = join(outDir, route.route.replace(/^\//, ''));
101
- mkdirSync(routeDir, { recursive: true });
102
- htmlPath = join(routeDir, 'index.html');
79
+ const routeDir = join(outDir, route.route.replace(/^\//, ''))
80
+ mkdirSync(routeDir, { recursive: true })
81
+ htmlPath = join(routeDir, 'index.html')
103
82
  }
104
83
 
105
- await Bun.write(htmlPath, html);
106
- logger.success(`✅ ${route.route}`);
84
+ await Bun.write(htmlPath, html)
85
+ logger.success(` ${route.route}`)
107
86
 
108
87
  } catch (error) {
109
- logger.error(`Failed HTML for ${route.route}: ${error.message}`);
110
- console.error(error);
88
+ logger.error(`Failed HTML for ${route.route}: ${error.message}`)
111
89
  }
112
90
  }
113
91
 
114
- function generateHTML(meta, bundlePath, bertuiPackages = {}) {
115
- const bertuiIconsImport = bertuiPackages.bertuiIcons
116
- ? ',\n "bertui-icons": "/node_modules/bertui-icons/generated/index.js"'
117
- : '';
92
+ /**
93
+ * Find the compiled .js path for a route's source file
94
+ */
95
+ function findCompiledPath(route, buildDir) {
96
+ const compiledFile = route.file.replace(/\.(jsx|tsx|ts)$/, '.js')
97
+ return join(buildDir, 'pages', compiledFile)
98
+ }
99
+ // ─── HTML generators ──────────────────────────────────────────────────────────
118
100
 
101
+ /**
102
+ * render = "static" → pure HTML, zero JS
103
+ */
104
+ function generateStaticHTML({ ssrHTML, meta, bertuiPackages }) {
119
105
  const bertuiAnimateCSS = bertuiPackages.bertuiAnimate
120
- ? ' <link rel="stylesheet" href="/css/bertui-animate.min.css">'
121
- : '';
106
+ ? '<link rel="stylesheet" href="/css/bertui-animate.min.css">'
107
+ : ''
122
108
 
123
- const elysiaEdenImport = bertuiPackages.elysiaEden
124
- ? ',\n "@elysiajs/eden": "/node_modules/@elysiajs/eden/dist/index.mjs"'
125
- : '';
109
+ return `<!DOCTYPE html>
110
+ <html lang="${meta.lang || 'en'}">
111
+ <head>
112
+ <meta charset="UTF-8">
113
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
114
+ <title>${meta.title || 'BertUI App'}</title>
115
+ <meta name="description" content="${meta.description || ''}">
116
+ ${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
117
+ ${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
118
+ ${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
119
+ <meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
120
+ <meta property="og:description" content="${meta.ogDescription || meta.description || ''}">
121
+ ${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
122
+ <link rel="stylesheet" href="/styles/bertui.min.css">
123
+ ${bertuiAnimateCSS}
124
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
125
+ </head>
126
+ <body>
127
+ ${ssrHTML}
128
+ </body>
129
+ </html>`
130
+ }
131
+
132
+ /**
133
+ * render = "server" → SSR HTML + JS bundle for hydration
134
+ */
135
+ function generateServerIslandHTML({ ssrHTML, meta, bundlePath, bertuiPackages }) {
136
+ const { importMapScript, bertuiAnimateCSS } = buildSharedAssets(meta, bertuiPackages)
126
137
 
127
138
  return `<!DOCTYPE html>
128
139
  <html lang="${meta.lang || 'en'}">
@@ -130,17 +141,70 @@ function generateHTML(meta, bundlePath, bertuiPackages = {}) {
130
141
  <meta charset="UTF-8">
131
142
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
132
143
  <title>${meta.title || 'BertUI App'}</title>
133
- <meta name="description" content="${meta.description || 'Built with BertUI'}">
134
- ${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
135
- ${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
136
- ${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
144
+ <meta name="description" content="${meta.description || ''}">
145
+ ${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
146
+ ${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
147
+ ${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
137
148
  <meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
138
- <meta property="og:description" content="${meta.ogDescription || meta.description || 'Built with BertUI'}">
149
+ <meta property="og:description" content="${meta.ogDescription || meta.description || ''}">
139
150
  ${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
140
151
  <link rel="stylesheet" href="/styles/bertui.min.css">
141
- ${bertuiAnimateCSS}
152
+ ${bertuiAnimateCSS}
142
153
  <link rel="icon" type="image/svg+xml" href="/favicon.svg">
143
- <script type="importmap">
154
+ ${importMapScript}
155
+ </head>
156
+ <body>
157
+ <div id="root">${ssrHTML}</div>
158
+ <script type="module" src="/${bundlePath}"></script>
159
+ </body>
160
+ </html>`
161
+ }
162
+
163
+ /**
164
+ * default → client-only SPA (existing behavior)
165
+ */
166
+ function generateClientHTML({ meta, bundlePath, bertuiPackages }) {
167
+ const { importMapScript, bertuiAnimateCSS } = buildSharedAssets(meta, bertuiPackages)
168
+
169
+ return `<!DOCTYPE html>
170
+ <html lang="${meta.lang || 'en'}">
171
+ <head>
172
+ <meta charset="UTF-8">
173
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
174
+ <title>${meta.title || 'BertUI App'}</title>
175
+ <meta name="description" content="${meta.description || ''}">
176
+ ${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
177
+ ${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
178
+ ${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
179
+ <meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
180
+ <meta property="og:description" content="${meta.ogDescription || meta.description || ''}">
181
+ ${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
182
+ <link rel="stylesheet" href="/styles/bertui.min.css">
183
+ ${bertuiAnimateCSS}
184
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
185
+ ${importMapScript}
186
+ </head>
187
+ <body>
188
+ <div id="root"></div>
189
+ <script type="module" src="/${bundlePath}"></script>
190
+ </body>
191
+ </html>`
192
+ }
193
+
194
+ // ─── Shared helpers ───────────────────────────────────────────────────────────
195
+
196
+ function buildSharedAssets(meta, bertuiPackages) {
197
+ const bertuiIconsImport = bertuiPackages.bertuiIcons
198
+ ? ',\n "bertui-icons": "/node_modules/bertui-icons/generated/index.js"'
199
+ : ''
200
+ const elysiaEdenImport = bertuiPackages.elysiaEden
201
+ ? ',\n "@elysiajs/eden": "/node_modules/@elysiajs/eden/dist/index.mjs"'
202
+ : ''
203
+ const bertuiAnimateCSS = bertuiPackages.bertuiAnimate
204
+ ? '<link rel="stylesheet" href="/css/bertui-animate.min.css">'
205
+ : ''
206
+
207
+ const importMapScript = `<script type="importmap">
144
208
  {
145
209
  "imports": {
146
210
  "react": "https://esm.sh/react@18.2.0",
@@ -148,16 +212,52 @@ ${bertuiAnimateCSS}
148
212
  "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
149
213
  "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
150
214
  "react/jsx-dev-runtime": "https://esm.sh/react@18.2.0/jsx-dev-runtime",
151
- "@bunnyx/api": "/bunnyx-api/api-client.js",
152
- "@elysiajs/eden": "/node_modules/@elysiajs/eden/dist/index.mjs"${bertuiIconsImport}${elysiaEdenImport}
215
+ "@bunnyx/api": "/bunnyx-api/api-client.js"${bertuiIconsImport}${elysiaEdenImport}
153
216
  }
154
217
  }
155
- </script>
156
- <script>window.__BERTUI_HYDRATE__ = false;</script>
157
- </head>
158
- <body>
159
- <div id="root"></div>
160
- <script type="module" src="/${bundlePath}"></script>
161
- </body>
162
- </html>`;
218
+ </script>`
219
+
220
+ return { importMapScript, bertuiAnimateCSS }
221
+ }
222
+
223
+ async function copyBertuiPackagesToProduction(root, outDir) {
224
+ const nodeModulesDir = join(root, 'node_modules')
225
+ const packages = { bertuiIcons: false, bertuiAnimate: false, elysiaEden: false }
226
+
227
+ if (!existsSync(nodeModulesDir)) return packages
228
+
229
+ const bertuiIconsSrc = join(nodeModulesDir, 'bertui-icons')
230
+ if (existsSync(bertuiIconsSrc)) {
231
+ try {
232
+ const dest = join(outDir, 'node_modules', 'bertui-icons')
233
+ mkdirSync(join(outDir, 'node_modules'), { recursive: true })
234
+ cpSync(bertuiIconsSrc, dest, { recursive: true })
235
+ packages.bertuiIcons = true
236
+ } catch {}
237
+ }
238
+
239
+ const bertuiAnimateSrc = join(nodeModulesDir, 'bertui-animate', 'dist')
240
+ if (existsSync(bertuiAnimateSrc)) {
241
+ try {
242
+ const dest = join(outDir, 'css')
243
+ mkdirSync(dest, { recursive: true })
244
+ const minCSS = join(bertuiAnimateSrc, 'bertui-animate.min.css')
245
+ if (existsSync(minCSS)) {
246
+ cpSync(minCSS, join(dest, 'bertui-animate.min.css'))
247
+ packages.bertuiAnimate = true
248
+ }
249
+ } catch {}
250
+ }
251
+
252
+ const elysiaEdenSrc = join(nodeModulesDir, '@elysiajs', 'eden')
253
+ if (existsSync(elysiaEdenSrc)) {
254
+ try {
255
+ const dest = join(outDir, 'node_modules', '@elysiajs', 'eden')
256
+ mkdirSync(join(outDir, 'node_modules', '@elysiajs'), { recursive: true })
257
+ cpSync(elysiaEdenSrc, dest, { recursive: true })
258
+ packages.elysiaEden = true
259
+ } catch {}
260
+ }
261
+
262
+ return packages
163
263
  }
@@ -1,175 +1,67 @@
1
1
  // bertui/src/build/server-island-validator.js
2
- // Fixed validation for Server Islands - no false positives!
2
+ import logger from '../logger/logger.js'
3
3
 
4
- import logger from '../logger/logger.js';
4
+ const BANNED_HOOKS = [
5
+ 'useState', 'useEffect', 'useReducer', 'useCallback', 'useMemo',
6
+ 'useRef', 'useContext', 'useLayoutEffect', 'useId',
7
+ 'useImperativeHandle', 'useDebugValue', 'useDeferredValue',
8
+ 'useTransition', 'useSyncExternalStore',
9
+ ]
10
+
11
+ const BANNED_EVENTS = [
12
+ 'onClick', 'onChange', 'onSubmit', 'onInput', 'onFocus',
13
+ 'onBlur', 'onMouseEnter', 'onMouseLeave', 'onKeyDown', 'onKeyUp',
14
+ ]
5
15
 
6
- /**
7
- * Validates that a Server Island component follows all rules
8
- * @param {string} sourceCode - The component source code
9
- * @param {string} filePath - Path to the file (for error messages)
10
- * @returns {{ valid: boolean, errors: string[] }}
11
- */
12
16
  export function validateServerIsland(sourceCode, filePath) {
13
- const errors = [];
14
-
15
- // SUPER AGGRESSIVE STRIPPING: Remove EVERYTHING that could be a false positive
16
-
17
- // First, remove all JSX prop values that contain code examples
18
- let cleanedCode = sourceCode
19
- // Remove the entire content of <Code> components (most common culprit)
20
- .replace(/<Code[^>]*>[\s\S]*?<\/Code>/g, '')
21
- // Remove the entire content of <InlineCode> components
22
- .replace(/<InlineCode[^>]*>[\s\S]*?<\/InlineCode>/g, '')
23
- // Remove any JSX expression that looks like it contains code
24
- .replace(/\{`[\s\S]*?`\}/g, '{}')
25
- .replace(/\{[\s\S]*?import[\s\S]*?\}/g, '{}')
26
- .replace(/\{[\s\S]*?useState[\s\S]*?\}/g, '{}')
27
- .replace(/\{[\s\S]*?useEffect[\s\S]*?\}/g, '{}')
28
- .replace(/\{[\s\S]*?fetch\([\s\S]*?\}/g, '{}');
29
-
30
- // Then strip all string literals
31
- cleanedCode = cleanedCode
32
- .replace(/`[\s\S]*?`/g, '""')
33
- .replace(/"(?:[^"\\]|\\.)*"/g, '""')
34
- .replace(/'(?:[^'\\]|\\.)*'/g, "''")
35
- // Remove comments
36
- .replace(/\/\/.*$/gm, '')
37
- .replace(/\/\*[\s\S]*?\*\//g, '');
38
-
39
- // Rule 1: No React hooks (check the cleaned code only)
40
- const hookPatterns = [
41
- 'useState', 'useEffect', 'useContext', 'useReducer',
42
- 'useCallback', 'useMemo', 'useRef', 'useImperativeHandle',
43
- 'useLayoutEffect', 'useDebugValue', 'useId', 'useDeferredValue',
44
- 'useTransition', 'useSyncExternalStore'
45
- ];
46
-
47
- for (const hook of hookPatterns) {
48
- // Look for the hook as a function call, but only in the cleaned code
49
- const regex = new RegExp(`\\b${hook}\\s*\\(`, 'g');
50
-
51
- // Also check that it's not preceded by "import" or part of a comment
52
- const matches = cleanedCode.match(regex);
53
- if (matches) {
54
- // Verify this isn't in an import statement by checking context
55
- const hookIndex = cleanedCode.indexOf(matches[0]);
56
- const contextBefore = cleanedCode.substring(Math.max(0, hookIndex - 50), hookIndex);
57
-
58
- if (!contextBefore.includes('import')) {
59
- errors.push(`❌ Uses React hook: ${hook}`);
60
- }
61
- }
62
- }
63
-
64
- // Rule 2: No bertui/router imports
65
- if (sourceCode.includes('from \'bertui/router\'') ||
66
- sourceCode.includes('from "bertui/router"')) {
67
- errors.push('❌ Imports from \'bertui/router\' (use <a> tags instead of Link)');
68
- }
69
-
70
- // Rule 3: No browser APIs (check cleaned code)
71
- const browserAPIs = [
72
- { pattern: '\\bwindow\\.(?!location)', name: 'window' },
73
- { pattern: '\\bdocument\\.', name: 'document' },
74
- { pattern: '\\blocalStorage\\.', name: 'localStorage' },
75
- { pattern: '\\bsessionStorage\\.', name: 'sessionStorage' },
76
- { pattern: '\\bnavigator\\.', name: 'navigator' },
77
- { pattern: '\\blocation\\.(?!href)', name: 'location' },
78
- { pattern: '\\bhistory\\.', name: 'history' },
79
- { pattern: '\\bfetch\\s*\\(', name: 'fetch' },
80
- { pattern: '\\.addEventListener\\s*\\(', name: 'addEventListener' },
81
- { pattern: '\\.removeEventListener\\s*\\(', name: 'removeEventListener' },
82
- { pattern: '\\bsetTimeout\\s*\\(', name: 'setTimeout' },
83
- { pattern: '\\bsetInterval\\s*\\(', name: 'setInterval' },
84
- { pattern: '\\brequestAnimationFrame\\s*\\(', name: 'requestAnimationFrame' },
85
- { pattern: '\\bconsole\\.', name: 'console' }
86
- ];
87
-
88
- for (const api of browserAPIs) {
89
- const regex = new RegExp(api.pattern, 'g');
90
- if (regex.test(cleanedCode)) {
91
- if (api.name === 'console') {
92
- logger.warn(`⚠️ ${filePath} uses console.log (will not work in static HTML)`);
93
- } else {
94
- errors.push(`❌ Uses browser API: ${api.name}`);
95
- }
17
+ const errors = []
18
+
19
+ for (const hook of BANNED_HOOKS) {
20
+ if (new RegExp(`\\b${hook}\\s*\\(`).test(sourceCode)) {
21
+ errors.push(`Cannot use React hook "${hook}" in a static/server page hooks need a browser runtime`)
96
22
  }
97
23
  }
98
-
99
- // Rule 4: No event handlers (check cleaned code)
100
- const eventHandlers = [
101
- 'onClick=', 'onChange=', 'onSubmit=', 'onInput=', 'onFocus=',
102
- 'onBlur=', 'onMouseEnter=', 'onMouseLeave=', 'onKeyDown=',
103
- 'onKeyUp=', 'onScroll='
104
- ];
105
-
106
- for (const handler of eventHandlers) {
107
- const escapedHandler = handler.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
108
- const regex = new RegExp(`\\b${escapedHandler}\\s*{`, 'g');
109
- if (regex.test(cleanedCode)) {
110
- errors.push(`❌ Uses event handler: ${handler.replace('=', '')} (Server Islands are static HTML)`);
24
+
25
+ for (const event of BANNED_EVENTS) {
26
+ if (sourceCode.includes(`${event}=`)) {
27
+ errors.push(`Cannot use event handler "${event}" in a static/server page — events need a browser runtime`)
111
28
  }
112
29
  }
113
-
114
- // Rule 5: Check for dynamic imports
115
- if (/import\s*\(/.test(cleanedCode)) {
116
- errors.push('❌ Uses dynamic import() (not supported in Server Islands)');
117
- }
118
-
119
- // Rule 6: Check for async/await
120
- if (/async\s+function|async\s*\(|async\s+\w+\s*\(/.test(cleanedCode)) {
121
- errors.push('❌ Uses async/await (Server Islands must be synchronous)');
122
- }
123
-
124
- const valid = errors.length === 0;
125
-
126
- return { valid, errors };
127
- }
128
30
 
31
+ if (/window\.|document\.|localStorage\.|sessionStorage\./.test(sourceCode)) {
32
+ errors.push('Cannot access browser APIs (window/document/localStorage) in a static/server page')
33
+ }
129
34
 
35
+ return { valid: errors.length === 0, errors }
36
+ }
130
37
 
131
- /**
132
- * Display validation errors in a clear format
133
- */
134
38
  export function displayValidationErrors(filePath, errors) {
135
- logger.error(`\n🏝️ Server Island validation failed: ${filePath}`);
136
- logger.error('\nViolations:');
137
- errors.forEach(error => logger.error(` ${error}`));
138
- logger.error('\n📖 Server Island Rules:');
139
- logger.error(' ✅ Pure static JSX only');
140
- logger.error(' ❌ No React hooks (useState, useEffect, etc.)');
141
- logger.error(' ❌ No Link component (use <a> tags)');
142
- logger.error(' ❌ No browser APIs (window, document, fetch)');
143
- logger.error(' ❌ No event handlers (onClick, onChange, etc.)');
144
- logger.error('\n💡 Tip: Remove the "export const render = \\"server\\"" line');
145
- logger.error(' if you need these features (page will be client-only).\n');
39
+ logger.error(`\n❌ Static/Server page validation failed: ${filePath}`)
40
+ for (const err of errors) {
41
+ logger.error(` · ${err}`)
42
+ }
43
+ logger.error(` → Remove the above or switch to default render mode\n`)
146
44
  }
147
45
 
148
- /**
149
- * Extract and validate all Server Islands in a project
150
- */
151
46
  export async function validateAllServerIslands(routes) {
152
- const serverIslands = [];
153
- const validationResults = [];
154
-
47
+ const serverIslands = []
48
+ const validationResults = []
49
+
155
50
  for (const route of routes) {
156
- const sourceCode = await Bun.file(route.path).text();
157
- const isServerIsland = sourceCode.includes('export const render = "server"');
158
-
159
- if (isServerIsland) {
160
- const validation = validateServerIsland(sourceCode, route.path);
161
-
162
- validationResults.push({
163
- route: route.route,
164
- path: route.path,
165
- ...validation
166
- });
167
-
168
- if (validation.valid) {
169
- serverIslands.push(route);
51
+ try {
52
+ const src = await Bun.file(route.path).text()
53
+ const isStatic = /export\s+const\s+render\s*=\s*["'](server|static)["']/.test(src)
54
+ if (!isStatic) continue
55
+
56
+ const result = validateServerIsland(src, route.path)
57
+ serverIslands.push(route)
58
+ validationResults.push({ ...result, route: route.route, path: route.path })
59
+
60
+ if (!result.valid) {
61
+ displayValidationErrors(route.path, result.errors)
170
62
  }
171
- }
63
+ } catch {}
172
64
  }
173
-
174
- return { serverIslands, validationResults };
65
+
66
+ return { serverIslands, validationResults }
175
67
  }
@@ -0,0 +1,64 @@
1
+ // bertui/src/build/ssr-renderer.js
2
+ import { join } from 'path'
3
+ import { existsSync, mkdirSync, rmSync } from 'fs'
4
+ import logger from '../logger/logger.js'
5
+
6
+ /**
7
+ * Render a static page to HTML string using renderToString.
8
+ * Forces both the page and renderToString to use the same React
9
+ * instance from the project's own node_modules.
10
+ */
11
+ export async function renderPageToHTML(compiledPagePath, buildDir) {
12
+ try {
13
+ // Extract project root from compiled path
14
+ // e.g. /project/.bertuibuild/pages/about.js → /project/
15
+ const projectRoot = compiledPagePath.split('.bertuibuild')[0]
16
+
17
+ // Explicitly resolve React from the PROJECT's node_modules
18
+ // This prevents the two-React-instances problem when bertui is bun linked
19
+ const reactPath = join(projectRoot, 'node_modules', 'react', 'index.js')
20
+ const reactDomServerPath = join(projectRoot, 'node_modules', 'react-dom', 'server.js')
21
+
22
+ if (!existsSync(reactPath)) {
23
+ logger.warn(`React not found in project node_modules: ${reactPath}`)
24
+ return null
25
+ }
26
+
27
+ if (!existsSync(reactDomServerPath)) {
28
+ logger.warn(`react-dom/server not found in project node_modules: ${reactDomServerPath}`)
29
+ return null
30
+ }
31
+
32
+ const React = await import(reactPath)
33
+ const { renderToString } = await import(reactDomServerPath)
34
+
35
+ // Import the compiled page
36
+ const mod = await import(`${compiledPagePath}?t=${Date.now()}`)
37
+ const Component = mod.default
38
+
39
+ if (typeof Component !== 'function') {
40
+ logger.warn(`No default export found in ${compiledPagePath}`)
41
+ return null
42
+ }
43
+
44
+ return renderToString(React.createElement(Component))
45
+
46
+ } catch (err) {
47
+ logger.warn(`renderToString failed for ${compiledPagePath}: ${err.message}`)
48
+ return null
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Check if a route's source file has render = "server" or render = "static"
54
+ */
55
+ export async function getPageRenderMode(sourcePath) {
56
+ try {
57
+ const src = await Bun.file(sourcePath).text()
58
+ if (/export\s+const\s+render\s*=\s*["']server["']/.test(src)) return 'server'
59
+ if (/export\s+const\s+render\s*=\s*["']static["']/.test(src)) return 'static'
60
+ return 'default'
61
+ } catch {
62
+ return 'default'
63
+ }
64
+ }
package/src/build.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // bertui/src/build.js
2
2
  import { join } from 'path';
3
- import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'fs';
3
+ import { existsSync, mkdirSync, rmSync, readdirSync } from 'fs';
4
4
  import logger from './logger/logger.js';
5
5
  import { loadEnvVariables } from './utils/env.js';
6
6
  import { globalCache } from './utils/cache.js';
@@ -13,9 +13,8 @@ import { generateSitemap } from './build/generators/sitemap-generator.js';
13
13
  import { generateRobots } from './build/generators/robots-generator.js';
14
14
  import { compileLayouts } from './layouts/index.js';
15
15
  import { compileLoadingComponents } from './loading/index.js';
16
- import { analyzeRoutes, logHydrationReport } from './hydration/index.js';
16
+ import { analyzeRoutes } from './hydration/index.js';
17
17
  import { analyzeBuild } from './analyzer/index.js';
18
- import { buildAliasMap } from './utils/importhow.js';
19
18
 
20
19
  const TOTAL_STEPS = 10;
21
20
 
@@ -44,11 +43,18 @@ export async function buildProduction(options = {}) {
44
43
  const importhow = config.importhow || {};
45
44
  logger.stepDone('Loading env', `${Object.keys(envVars).length} vars`);
46
45
 
46
+ // ── Step 2: Compile ──────────────────────────────────────────────────────
47
47
  // ── Step 2: Compile ──────────────────────────────────────────────────────
48
48
  logger.step(2, TOTAL_STEPS, 'Compiling');
49
- const { routes, serverIslands, clientRoutes } = await compileForBuild(root, buildDir, envVars, config);
50
- logger.stepDone('Compiling', `${routes.length} routes · ${serverIslands.length} islands`);
51
-
49
+ const { routes } = await compileForBuild(root, buildDir, envVars, config);
50
+ logger.stepDone('Compiling', `${routes.length} routes`);
51
+
52
+ // TEMP DEBUG - remove after
53
+ const aboutPath = join(buildDir, 'pages', 'about.js')
54
+ if (existsSync(aboutPath)) {
55
+ const src = await Bun.file(aboutPath).text()
56
+ console.log('\n--- about.js compiled output ---\n', src.slice(0, 500), '\n---\n')
57
+ }
52
58
  // ── Step 3: Layouts ──────────────────────────────────────────────────────
53
59
  logger.step(3, TOTAL_STEPS, 'Layouts');
54
60
  const layouts = await compileLayouts(root, buildDir);
@@ -78,14 +84,16 @@ export async function buildProduction(options = {}) {
78
84
  // ── Step 8: Bundle JS ────────────────────────────────────────────────────
79
85
  logger.step(8, TOTAL_STEPS, 'Bundling JS');
80
86
  const buildEntry = join(buildDir, 'main.js');
81
- if (!existsSync(buildEntry)) throw new Error('main.js not found in build dir');
82
- const result = await bundleJavaScript(buildEntry, outDir, envVars, buildDir, analyzedRoutes, importhow, root, config);
87
+ if (!existsSync(buildEntry)) {
88
+ throw new Error('main.js not found in build dir make sure src/main.jsx exists');
89
+ }
90
+ const result = await bundleJavaScript(buildEntry, outDir, envVars, buildDir, root, config);
83
91
  totalKB = (result.outputs.reduce((a, o) => a + (o.size || 0), 0) / 1024).toFixed(1);
84
92
  logger.stepDone('Bundling JS', `${totalKB} KB · tree-shaken`);
85
93
 
86
94
  // ── Step 9: HTML ─────────────────────────────────────────────────────────
87
95
  logger.step(9, TOTAL_STEPS, 'Generating HTML');
88
- await generateProductionHTML(root, outDir, buildDir, result, routes, serverIslands, config);
96
+ await generateProductionHTML(root, outDir, result, routes, config, buildDir)
89
97
  logger.stepDone('Generating HTML', `${routes.length} pages`);
90
98
 
91
99
  // ── Step 10: Sitemap + robots ────────────────────────────────────────────
@@ -94,32 +102,27 @@ export async function buildProduction(options = {}) {
94
102
  await generateRobots(config, outDir, routes);
95
103
  logger.stepDone('Sitemap & robots');
96
104
 
97
- // Delete build dir AFTER HTML generation
98
105
  if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true });
99
106
 
100
- // Generate bundle report
101
107
  try {
102
108
  await analyzeBuild(outDir, { outputFile: join(outDir, 'bundle-report.html') });
103
109
  } catch (reportErr) {
104
110
  logger.debug(`Bundle report generation skipped: ${reportErr.message}`);
105
111
  }
106
112
 
107
- // ── Summary ──────────────────────────────────────────────────────────────
108
113
  logger.printSummary({
109
- routes: routes.length,
110
- serverIslands: serverIslands.length,
111
- interactive: analyzedRoutes.interactive.length,
112
- staticRoutes: analyzedRoutes.static.length,
113
- jsSize: `${totalKB} KB`,
114
- outDir: 'dist/',
114
+ routes: routes.length,
115
+ interactive: analyzedRoutes.interactive.length,
116
+ staticRoutes: analyzedRoutes.static.length,
117
+ jsSize: `${totalKB} KB`,
118
+ outDir: 'dist/',
115
119
  });
116
120
 
117
121
  logger.cleanup();
118
-
119
122
  return { success: true };
120
123
 
121
124
  } catch (error) {
122
- logger.stepFail('Build', error.message);
125
+ logger.stepFail('Build', error?.message || String(error));
123
126
  if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true });
124
127
  throw error;
125
128
  }
@@ -161,6 +164,7 @@ async function generateProductionImportMap(root, config) {
161
164
  }
162
165
 
163
166
  async function copyNodeModulesToDist(root, outDir, importMap) {
167
+ const { mkdirSync } = await import('fs');
164
168
  const dest = join(outDir, 'assets', 'node_modules');
165
169
  mkdirSync(dest, { recursive: true });
166
170
  const src = join(root, 'node_modules');
@@ -179,23 +183,18 @@ async function copyNodeModulesToDist(root, outDir, importMap) {
179
183
  }
180
184
  }
181
185
 
182
- async function bundleJavaScript(buildEntry, outDir, envVars, buildDir, analyzedRoutes, importhow, root, config) {
186
+ async function bundleJavaScript(buildEntry, outDir, envVars, buildDir, root, config) {
183
187
  const originalCwd = process.cwd();
184
188
  process.chdir(buildDir);
185
189
 
186
190
  try {
187
- // Only main.js as entrypoint — router.js is imported by main.js already.
188
- // Adding router.js as a second entrypoint causes it to mount twice on
189
- // the same #root which triggers "Node.removeChild" DOM crashes in React.
190
- const entrypoints = [buildEntry];
191
-
192
191
  const importMap = await generateProductionImportMap(root, config);
193
192
  await Bun.write(join(outDir, 'import-map.json'), JSON.stringify({ imports: importMap }, null, 2));
194
193
  await copyNodeModulesToDist(root, outDir, importMap);
195
194
 
196
- // Copy @bunnyx/api client to dist so the importmap entry resolves
197
195
  const bunnyxSrc = join(root, 'bunnyx-api', 'api-client.js');
198
196
  if (existsSync(bunnyxSrc)) {
197
+ const { mkdirSync } = await import('fs');
199
198
  mkdirSync(join(outDir, 'bunnyx-api'), { recursive: true });
200
199
  await Bun.write(join(outDir, 'bunnyx-api', 'api-client.js'), Bun.file(bunnyxSrc));
201
200
  }
@@ -214,36 +213,42 @@ async function bundleJavaScript(buildEntry, outDir, envVars, buildDir, analyzedR
214
213
  },
215
214
  };
216
215
 
217
- const result = await Bun.build({
218
- entrypoints,
219
- outdir: join(outDir, 'assets'),
220
- target: 'browser',
221
- format: 'esm',
222
- plugins: [cssModulePlugin],
223
- minify: {
224
- whitespace: true,
225
- syntax: true,
226
- identifiers: true,
227
- },
228
- splitting: true,
229
- sourcemap: 'external',
230
- metafile: true,
231
- naming: {
232
- entry: 'js/[name]-[hash].js',
233
- chunk: 'js/chunks/[name]-[hash].js',
234
- asset: 'assets/[name]-[hash].[ext]',
235
- },
236
- external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@bunnyx/api'],
237
- define: {
238
- 'process.env.NODE_ENV': '"production"',
239
- ...Object.fromEntries(
240
- Object.entries(envVars).map(([k, v]) => [`process.env.${k}`, JSON.stringify(v)])
241
- ),
242
- },
243
- });
216
+ let result;
217
+ try {
218
+ result = await Bun.build({
219
+ entrypoints: [buildEntry],
220
+ outdir: join(outDir, 'assets'),
221
+ target: 'browser',
222
+ format: 'esm',
223
+ plugins: [cssModulePlugin],
224
+ minify: {
225
+ whitespace: true,
226
+ syntax: true,
227
+ identifiers: true,
228
+ },
229
+ splitting: true,
230
+ sourcemap: 'external',
231
+ metafile: true,
232
+ naming: {
233
+ entry: 'js/[name]-[hash].js',
234
+ chunk: 'js/chunks/[name]-[hash].js',
235
+ asset: 'assets/[name]-[hash].[ext]',
236
+ },
237
+ external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', '@bunnyx/api'],
238
+ define: {
239
+ 'process.env.NODE_ENV': '"production"',
240
+ ...Object.fromEntries(
241
+ Object.entries(envVars).map(([k, v]) => [`process.env.${k}`, JSON.stringify(v)])
242
+ ),
243
+ },
244
+ });
245
+ } catch (err) {
246
+ throw new Error(`Bun.build failed: ${err?.message || String(err)}`);
247
+ }
244
248
 
245
249
  if (!result.success) {
246
- throw new Error(`Bundle failed\n${result.logs?.map(l => l.message).join('\n') || 'Unknown error'}`);
250
+ const msgs = (result.logs || []).map(l => l?.message || l?.text || JSON.stringify(l)).join('\n');
251
+ throw new Error(`Bundle failed\n${msgs || 'Check your imports for .jsx extensions or unresolvable paths'}`);
247
252
  }
248
253
 
249
254
  if (result.metafile) {
@@ -262,7 +267,7 @@ export async function build(options = {}) {
262
267
  await buildProduction(options);
263
268
  process.exit(0);
264
269
  } catch (error) {
265
- console.error(error);
270
+ console.error('Build error:', error?.message || String(error));
266
271
  process.exit(1);
267
272
  }
268
273
  }