bertui 1.2.8 → 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/generators/html-generator.js +209 -97
- package/src/build/server-island-validator.js +59 -4
- package/src/build/ssr-renderer.js +64 -0
- package/src/build.js +8 -1
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,117 +1,170 @@
|
|
|
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, buildResult, routes, 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 || {}
|
|
19
|
-
const bertuiPackages = await copyBertuiPackagesToProduction(root, outDir)
|
|
18
|
+
const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/')
|
|
19
|
+
const defaultMeta = config.meta || {}
|
|
20
|
+
const bertuiPackages = await copyBertuiPackagesToProduction(root, outDir)
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
|
|
23
|
+
logger.info(`Generating HTML for ${routes.length} routes...`)
|
|
22
24
|
|
|
23
25
|
for (const route of routes) {
|
|
24
|
-
await processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages)
|
|
26
|
+
await processSingleRoute(route, config, defaultMeta, bundlePath, outDir, bertuiPackages, buildDir)
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
logger.success(`HTML generation complete for ${routes.length} routes`)
|
|
29
|
+
logger.success(`HTML generation complete for ${routes.length} routes`)
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
async function
|
|
31
|
-
|
|
32
|
-
|
|
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 }
|
|
33
37
|
|
|
34
|
-
|
|
38
|
+
// Determine render mode from source
|
|
39
|
+
const renderMode = await getPageRenderMode(route.path)
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
if (existsSync(bertuiIconsSource)) {
|
|
38
|
-
try {
|
|
39
|
-
const bertuiIconsDest = join(outDir, 'node_modules', 'bertui-icons');
|
|
40
|
-
mkdirSync(join(outDir, 'node_modules'), { recursive: true });
|
|
41
|
-
cpSync(bertuiIconsSource, bertuiIconsDest, { recursive: true });
|
|
42
|
-
packages.bertuiIcons = true;
|
|
43
|
-
} catch (error) {
|
|
44
|
-
logger.error(`Failed to copy bertui-icons: ${error.message}`);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
41
|
+
let html
|
|
47
42
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const bertuiAnimateDest = join(outDir, 'css');
|
|
52
|
-
mkdirSync(bertuiAnimateDest, { recursive: true });
|
|
53
|
-
const minCSSPath = join(bertuiAnimateSource, 'bertui-animate.min.css');
|
|
54
|
-
if (existsSync(minCSSPath)) {
|
|
55
|
-
cpSync(minCSSPath, join(bertuiAnimateDest, 'bertui-animate.min.css'));
|
|
56
|
-
packages.bertuiAnimate = true;
|
|
57
|
-
}
|
|
58
|
-
} catch (error) {
|
|
59
|
-
logger.error(`Failed to copy bertui-animate: ${error.message}`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
43
|
+
if (renderMode === 'server' || renderMode === 'static') {
|
|
44
|
+
// Find the compiled version of this page in buildDir
|
|
45
|
+
const compiledPath = findCompiledPath(route, buildDir)
|
|
62
46
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const elysiaEdenDest = join(outDir, 'node_modules', '@elysiajs', 'eden');
|
|
67
|
-
mkdirSync(join(outDir, 'node_modules', '@elysiajs'), { recursive: true });
|
|
68
|
-
cpSync(elysiaEdenSource, elysiaEdenDest, { recursive: true });
|
|
69
|
-
packages.elysiaEden = true;
|
|
70
|
-
} catch (error) {
|
|
71
|
-
logger.error(`Failed to copy @elysiajs/eden: ${error.message}`);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return packages;
|
|
76
|
-
}
|
|
47
|
+
if (compiledPath && existsSync(compiledPath)) {
|
|
48
|
+
logger.info(` SSR rendering: ${route.route}`)
|
|
49
|
+
const ssrHTML = await renderPageToHTML(compiledPath, buildDir)
|
|
77
50
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
73
|
|
|
86
|
-
|
|
74
|
+
// Write to dist/
|
|
75
|
+
let htmlPath
|
|
87
76
|
if (route.route === '/') {
|
|
88
|
-
htmlPath = join(outDir, 'index.html')
|
|
77
|
+
htmlPath = join(outDir, 'index.html')
|
|
89
78
|
} else {
|
|
90
|
-
const routeDir = join(outDir, route.route.replace(/^\//, ''))
|
|
91
|
-
mkdirSync(routeDir, { recursive: true })
|
|
92
|
-
htmlPath = join(routeDir, 'index.html')
|
|
79
|
+
const routeDir = join(outDir, route.route.replace(/^\//, ''))
|
|
80
|
+
mkdirSync(routeDir, { recursive: true })
|
|
81
|
+
htmlPath = join(routeDir, 'index.html')
|
|
93
82
|
}
|
|
94
83
|
|
|
95
|
-
await Bun.write(htmlPath, html)
|
|
96
|
-
logger.success(
|
|
84
|
+
await Bun.write(htmlPath, html)
|
|
85
|
+
logger.success(` ${route.route}`)
|
|
97
86
|
|
|
98
87
|
} catch (error) {
|
|
99
|
-
logger.error(`Failed HTML for ${route.route}: ${error.message}`)
|
|
88
|
+
logger.error(`Failed HTML for ${route.route}: ${error.message}`)
|
|
100
89
|
}
|
|
101
90
|
}
|
|
102
91
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 ──────────────────────────────────────────────────────────
|
|
107
100
|
|
|
101
|
+
/**
|
|
102
|
+
* render = "static" → pure HTML, zero JS
|
|
103
|
+
*/
|
|
104
|
+
function generateStaticHTML({ ssrHTML, meta, bertuiPackages }) {
|
|
108
105
|
const bertuiAnimateCSS = bertuiPackages.bertuiAnimate
|
|
109
|
-
? '
|
|
110
|
-
: ''
|
|
106
|
+
? '<link rel="stylesheet" href="/css/bertui-animate.min.css">'
|
|
107
|
+
: ''
|
|
111
108
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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)
|
|
137
|
+
|
|
138
|
+
return `<!DOCTYPE html>
|
|
139
|
+
<html lang="${meta.lang || 'en'}">
|
|
140
|
+
<head>
|
|
141
|
+
<meta charset="UTF-8">
|
|
142
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
143
|
+
<title>${meta.title || 'BertUI App'}</title>
|
|
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}">` : ''}
|
|
148
|
+
<meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
|
|
149
|
+
<meta property="og:description" content="${meta.ogDescription || meta.description || ''}">
|
|
150
|
+
${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
|
|
151
|
+
<link rel="stylesheet" href="/styles/bertui.min.css">
|
|
152
|
+
${bertuiAnimateCSS}
|
|
153
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
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)
|
|
115
168
|
|
|
116
169
|
return `<!DOCTYPE html>
|
|
117
170
|
<html lang="${meta.lang || 'en'}">
|
|
@@ -119,17 +172,39 @@ function generateHTML(meta, bundlePath, bertuiPackages = {}) {
|
|
|
119
172
|
<meta charset="UTF-8">
|
|
120
173
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
121
174
|
<title>${meta.title || 'BertUI App'}</title>
|
|
122
|
-
<meta name="description" content="${meta.description || '
|
|
123
|
-
${meta.keywords
|
|
124
|
-
${meta.author
|
|
125
|
-
${meta.themeColor
|
|
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}">` : ''}
|
|
126
179
|
<meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
|
|
127
|
-
<meta property="og:description" content="${meta.ogDescription || meta.description || '
|
|
180
|
+
<meta property="og:description" content="${meta.ogDescription || meta.description || ''}">
|
|
128
181
|
${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
|
|
129
182
|
<link rel="stylesheet" href="/styles/bertui.min.css">
|
|
130
|
-
${bertuiAnimateCSS}
|
|
183
|
+
${bertuiAnimateCSS}
|
|
131
184
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
132
|
-
|
|
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">
|
|
133
208
|
{
|
|
134
209
|
"imports": {
|
|
135
210
|
"react": "https://esm.sh/react@18.2.0",
|
|
@@ -137,15 +212,52 @@ ${bertuiAnimateCSS}
|
|
|
137
212
|
"react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
|
|
138
213
|
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
|
|
139
214
|
"react/jsx-dev-runtime": "https://esm.sh/react@18.2.0/jsx-dev-runtime",
|
|
140
|
-
"@bunnyx/api": "/bunnyx-api/api-client.js"
|
|
141
|
-
"@elysiajs/eden": "/node_modules/@elysiajs/eden/dist/index.mjs"${bertuiIconsImport}${elysiaEdenImport}
|
|
215
|
+
"@bunnyx/api": "/bunnyx-api/api-client.js"${bertuiIconsImport}${elysiaEdenImport}
|
|
142
216
|
}
|
|
143
217
|
}
|
|
144
|
-
</script
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
263
|
}
|
|
@@ -1,12 +1,67 @@
|
|
|
1
1
|
// bertui/src/build/server-island-validator.js
|
|
2
|
-
|
|
2
|
+
import logger from '../logger/logger.js'
|
|
3
|
+
|
|
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
|
+
]
|
|
3
15
|
|
|
4
16
|
export function validateServerIsland(sourceCode, filePath) {
|
|
5
|
-
|
|
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`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
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`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
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
|
+
}
|
|
34
|
+
|
|
35
|
+
return { valid: errors.length === 0, errors }
|
|
6
36
|
}
|
|
7
37
|
|
|
8
|
-
export function displayValidationErrors(filePath, errors) {
|
|
38
|
+
export function displayValidationErrors(filePath, errors) {
|
|
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`)
|
|
44
|
+
}
|
|
9
45
|
|
|
10
46
|
export async function validateAllServerIslands(routes) {
|
|
11
|
-
|
|
47
|
+
const serverIslands = []
|
|
48
|
+
const validationResults = []
|
|
49
|
+
|
|
50
|
+
for (const route of routes) {
|
|
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)
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { serverIslands, validationResults }
|
|
12
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
|
@@ -43,11 +43,18 @@ export async function buildProduction(options = {}) {
|
|
|
43
43
|
const importhow = config.importhow || {};
|
|
44
44
|
logger.stepDone('Loading env', `${Object.keys(envVars).length} vars`);
|
|
45
45
|
|
|
46
|
+
// ── Step 2: Compile ──────────────────────────────────────────────────────
|
|
46
47
|
// ── Step 2: Compile ──────────────────────────────────────────────────────
|
|
47
48
|
logger.step(2, TOTAL_STEPS, 'Compiling');
|
|
48
49
|
const { routes } = await compileForBuild(root, buildDir, envVars, config);
|
|
49
50
|
logger.stepDone('Compiling', `${routes.length} routes`);
|
|
50
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
|
+
}
|
|
51
58
|
// ── Step 3: Layouts ──────────────────────────────────────────────────────
|
|
52
59
|
logger.step(3, TOTAL_STEPS, 'Layouts');
|
|
53
60
|
const layouts = await compileLayouts(root, buildDir);
|
|
@@ -86,7 +93,7 @@ export async function buildProduction(options = {}) {
|
|
|
86
93
|
|
|
87
94
|
// ── Step 9: HTML ─────────────────────────────────────────────────────────
|
|
88
95
|
logger.step(9, TOTAL_STEPS, 'Generating HTML');
|
|
89
|
-
await generateProductionHTML(root, outDir, result, routes, config)
|
|
96
|
+
await generateProductionHTML(root, outDir, result, routes, config, buildDir)
|
|
90
97
|
logger.stepDone('Generating HTML', `${routes.length} pages`);
|
|
91
98
|
|
|
92
99
|
// ── Step 10: Sitemap + robots ────────────────────────────────────────────
|