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 +69 -22
- package/package.json +1 -1
- package/src/build/compiler/index.js +4 -26
- package/src/build/compiler/router-generator.js +8 -20
- package/src/build/generators/html-generator.js +209 -109
- package/src/build/server-island-validator.js +48 -156
- package/src/build/ssr-renderer.js +64 -0
- package/src/build.js +61 -56
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
|
-
[](https://www.npmjs.com/package/bertui)
|
|
6
6
|
[](https://bun.sh)
|
|
7
7
|
[](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
|
|
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
|
-
|
|
96
|
-
|
|
130
|
+
Three render modes. One export at the top of your page.
|
|
97
131
|
```jsx
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
export const
|
|
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
|
|
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
|
-
|
|
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,4 +1,4 @@
|
|
|
1
|
-
// bertui/src/build/compiler/index.js
|
|
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
|
|
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
|
|
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,
|
|
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('
|
|
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(
|
|
23
|
+
logger.info(`Generating HTML for ${routes.length} routes...`)
|
|
23
24
|
|
|
24
|
-
const
|
|
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(
|
|
29
|
+
logger.success(`HTML generation complete for ${routes.length} routes`)
|
|
35
30
|
}
|
|
36
31
|
|
|
37
|
-
async function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
? '
|
|
121
|
-
: ''
|
|
106
|
+
? '<link rel="stylesheet" href="/css/bertui-animate.min.css">'
|
|
107
|
+
: ''
|
|
122
108
|
|
|
123
|
-
|
|
124
|
-
|
|
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 || '
|
|
134
|
-
${meta.keywords
|
|
135
|
-
${meta.author
|
|
136
|
-
${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 || '
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
2
|
+
import logger from '../logger/logger.js'
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
logger.error(
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
-
logger.stepDone('Compiling', `${routes.length} routes
|
|
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))
|
|
82
|
-
|
|
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,
|
|
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:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
'
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
}
|