bertui 1.1.9 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,310 @@
1
+ // bertui/src/scaffolder/index.js
2
+ // CLI component/page/layout scaffolder
3
+
4
+ import { join } from 'path';
5
+ import { existsSync, mkdirSync } from 'fs';
6
+ import logger from '../logger/logger.js';
7
+ // ─── TEMPLATES ─────────────────────────────────────────────────────────────
8
+
9
+ const TEMPLATES = {
10
+ component: (name) => `import React, { useState } from 'react';
11
+
12
+ interface ${name}Props {
13
+ className?: string;
14
+ children?: React.ReactNode;
15
+ }
16
+
17
+ export default function ${name}({ className = '', children }: ${name}Props) {
18
+ return (
19
+ <div className={className}>
20
+ {children ?? <p>${name} component</p>}
21
+ </div>
22
+ );
23
+ }
24
+ `,
25
+
26
+ page: (name, route) => `// Route: ${route}
27
+ import React from 'react';
28
+ import { Link } from 'bertui/router';
29
+
30
+ export const title = '${name}';
31
+ export const description = '${name} page';
32
+
33
+ export default function ${name}Page() {
34
+ return (
35
+ <main style={{ padding: '2rem', fontFamily: 'system-ui' }}>
36
+ <h1>${name}</h1>
37
+ <p>Welcome to ${name}</p>
38
+ <Link to="/">← Back home</Link>
39
+ </main>
40
+ );
41
+ }
42
+ `,
43
+
44
+ layout: (name) => `import React from 'react';
45
+
46
+ interface ${name}LayoutProps {
47
+ children: React.ReactNode;
48
+ }
49
+
50
+ // This layout wraps all pages${name === 'default' ? '' : ` under /${name.toLowerCase()}/`}
51
+ export default function ${name}Layout({ children }: ${name}LayoutProps) {
52
+ return (
53
+ <div style={{ minHeight: '100vh', fontFamily: 'system-ui' }}>
54
+ <header style={{
55
+ padding: '1rem 2rem',
56
+ borderBottom: '1px solid #e5e7eb',
57
+ display: 'flex',
58
+ alignItems: 'center',
59
+ justifyContent: 'space-between',
60
+ }}>
61
+ <a href="/" style={{ fontWeight: 700, textDecoration: 'none', color: 'inherit' }}>
62
+ My App
63
+ </a>
64
+ <nav style={{ display: 'flex', gap: '1.5rem' }}>
65
+ <a href="/" style={{ color: '#4b5563', textDecoration: 'none' }}>Home</a>
66
+ <a href="/about" style={{ color: '#4b5563', textDecoration: 'none' }}>About</a>
67
+ </nav>
68
+ </header>
69
+ <main style={{ padding: '2rem' }}>
70
+ {children}
71
+ </main>
72
+ <footer style={{
73
+ padding: '1rem 2rem',
74
+ borderTop: '1px solid #e5e7eb',
75
+ color: '#9ca3af',
76
+ fontSize: '14px',
77
+ textAlign: 'center',
78
+ }}>
79
+ Built with BertUI ⚡
80
+ </footer>
81
+ </div>
82
+ );
83
+ }
84
+ `,
85
+
86
+ loading: (name) => `import React from 'react';
87
+
88
+ // Loading state for ${name} route
89
+ // This shows while the page JavaScript loads
90
+ export default function ${name}Loading() {
91
+ return (
92
+ <div style={{
93
+ display: 'flex',
94
+ flexDirection: 'column',
95
+ alignItems: 'center',
96
+ justifyContent: 'center',
97
+ minHeight: '60vh',
98
+ gap: '1rem',
99
+ fontFamily: 'system-ui',
100
+ }}>
101
+ <div style={{
102
+ width: '40px',
103
+ height: '40px',
104
+ border: '3px solid #e5e7eb',
105
+ borderTopColor: '#10b981',
106
+ borderRadius: '50%',
107
+ animation: 'spin 0.7s linear infinite',
108
+ }} />
109
+ <style>{'@keyframes spin { to { transform: rotate(360deg); } }'}</style>
110
+ <p style={{ color: '#6b7280', fontSize: '14px' }}>Loading ${name}...</p>
111
+ </div>
112
+ );
113
+ }
114
+ `,
115
+
116
+ middleware: () => `import type { MiddlewareContext } from 'bertui/middleware';
117
+
118
+ // Runs before EVERY page request
119
+ // Use ctx.redirect(), ctx.respond(), or ctx.locals to pass data
120
+
121
+ export async function onRequest(ctx: MiddlewareContext) {
122
+ // Example: protect /dashboard
123
+ // if (ctx.pathname.startsWith('/dashboard')) {
124
+ // const token = ctx.headers['authorization'];
125
+ // if (!token) return ctx.redirect('/login');
126
+ // }
127
+
128
+ // Example: add custom headers
129
+ // ctx.setHeader('X-Powered-By', 'BertUI');
130
+
131
+ // Example: pass data to pages via locals
132
+ // ctx.locals.user = await getUser(ctx.headers.cookie);
133
+
134
+ console.log('[Middleware]', ctx.method, ctx.pathname);
135
+ }
136
+
137
+ // Optional: handle middleware errors
138
+ export async function onError(ctx: MiddlewareContext, error: Error) {
139
+ console.error('[Middleware Error]', error.message);
140
+ }
141
+ `,
142
+ };
143
+
144
+ // ─── SCAFFOLDER ─────────────────────────────────────────────────────────────
145
+
146
+ export async function scaffold(type, name, options = {}) {
147
+ const root = options.root || process.cwd();
148
+ const ts = options.ts !== false; // Default: TypeScript
149
+
150
+ const ext = ts ? '.tsx' : '.jsx';
151
+
152
+ switch (type) {
153
+ case 'component':
154
+ return createComponent(name, root, ext);
155
+ case 'page':
156
+ return createPage(name, root, ext);
157
+ case 'layout':
158
+ return createLayout(name, root, ext);
159
+ case 'loading':
160
+ return createLoading(name, root, ext);
161
+ case 'middleware':
162
+ return createMiddleware(root, ts);
163
+ default:
164
+ logger.error(`Unknown scaffold type: ${type}`);
165
+ logger.info('Available types: component, page, layout, loading, middleware');
166
+ return false;
167
+ }
168
+ }
169
+
170
+ async function createComponent(name, root, ext) {
171
+ const pascal = toPascalCase(name);
172
+ const dir = join(root, 'src', 'components');
173
+
174
+ fsMkdir(dir, { recursive: true });
175
+
176
+ const filePath = join(dir, `${pascal}${ext}`);
177
+
178
+ if (fsExists(filePath)) {
179
+ logger.warn(`Component already exists: src/components/${pascal}${ext}`);
180
+ return false;
181
+ }
182
+
183
+ await Bun.write(filePath, TEMPLATES.component(pascal));
184
+ logger.success(`✅ Created component: src/components/${pascal}${ext}`);
185
+ return filePath;
186
+ }
187
+
188
+ async function createPage(name, root, ext) {
189
+ const pascal = toPascalCase(name);
190
+ const route = `/${name.toLowerCase().replace(/\s+/g, '-')}`;
191
+
192
+ // Handle nested routes like "blog/[slug]"
193
+ const parts = name.split('/');
194
+ const pageName = toPascalCase(parts[parts.length - 1]);
195
+ const dir = join(root, 'src', 'pages', ...parts.slice(0, -1));
196
+
197
+ fsMkdir(dir, { recursive: true });
198
+
199
+ // Handle index pages
200
+ const fileName = pageName.toLowerCase() === 'index' ? 'index' : pageName.toLowerCase();
201
+ const filePath = join(dir, `${fileName}${ext}`);
202
+
203
+ if (fsExists(filePath)) {
204
+ logger.warn(`Page already exists: src/pages/${name}${ext}`);
205
+ return false;
206
+ }
207
+
208
+ await Bun.write(filePath, TEMPLATES.page(pascal, route));
209
+ logger.success(`✅ Created page: src/pages/${name}${ext}`);
210
+ logger.info(` Route: ${route}`);
211
+ return filePath;
212
+ }
213
+
214
+ async function createLayout(name, root, ext) {
215
+ const pascal = toPascalCase(name);
216
+ const dir = join(root, 'src', 'layouts');
217
+
218
+ fsMkdir(dir, { recursive: true });
219
+
220
+ const filePath = join(dir, `${name.toLowerCase()}${ext}`);
221
+
222
+ if (fsExists(filePath)) {
223
+ logger.warn(`Layout already exists: src/layouts/${name.toLowerCase()}${ext}`);
224
+ return false;
225
+ }
226
+
227
+ await Bun.write(filePath, TEMPLATES.layout(pascal));
228
+ logger.success(`✅ Created layout: src/layouts/${name.toLowerCase()}${ext}`);
229
+
230
+ if (name.toLowerCase() === 'default') {
231
+ logger.info(' → This layout will wrap ALL pages automatically');
232
+ } else {
233
+ logger.info(` → This layout will wrap pages under /${name.toLowerCase()}/`);
234
+ }
235
+
236
+ return filePath;
237
+ }
238
+
239
+ async function createLoading(name, root, ext) {
240
+ const pascal = toPascalCase(name);
241
+
242
+ // Place loading.tsx next to the relevant page/route
243
+ const isRoot = name.toLowerCase() === 'root' || name === '/';
244
+ const dir = isRoot
245
+ ? join(root, 'src', 'pages')
246
+ : join(root, 'src', 'pages', name.toLowerCase());
247
+
248
+ fsMkdir(dir, { recursive: true });
249
+
250
+ const filePath = join(dir, `loading${ext}`);
251
+
252
+ if (fsExists(filePath)) {
253
+ logger.warn(`Loading component already exists at ${dir}/loading${ext}`);
254
+ return false;
255
+ }
256
+
257
+ await Bun.write(filePath, TEMPLATES.loading(pascal));
258
+ logger.success(`✅ Created loading state: ${filePath.replace(root, '')}`);
259
+ logger.info(` → Shows while ${isRoot ? 'root' : `/${name.toLowerCase()}`} route loads`);
260
+ return filePath;
261
+ }
262
+
263
+ async function createMiddleware(root, ts) {
264
+ const ext = ts ? '.ts' : '.js';
265
+ const filePath = join(root, 'src', `middleware${ext}`);
266
+
267
+ if (fsExists(filePath)) {
268
+ logger.warn(`Middleware already exists: src/middleware${ext}`);
269
+ return false;
270
+ }
271
+
272
+ await Bun.write(filePath, TEMPLATES.middleware());
273
+ logger.success(`✅ Created middleware: src/middleware${ext}`);
274
+ logger.info(' → Runs before every page request');
275
+ return filePath;
276
+ }
277
+
278
+ // ─── HELPERS ────────────────────────────────────────────────────────────────
279
+
280
+ function toPascalCase(str) {
281
+ return str
282
+ .replace(/[-_\s]+(.)/g, (_, c) => c.toUpperCase())
283
+ .replace(/^(.)/, c => c.toUpperCase())
284
+ .replace(/[[\]]/g, ''); // Remove bracket from dynamic routes
285
+ }
286
+
287
+ /**
288
+ * Parse CLI args for the create command
289
+ * Usage: bertui create component Button
290
+ * bertui create page About
291
+ * bertui create layout default
292
+ * bertui create loading blog
293
+ * bertui create middleware
294
+ */
295
+ export function parseCreateArgs(args) {
296
+ const [type, name] = args;
297
+
298
+ if (!type) {
299
+ logger.error('Usage: bertui create <type> [name]');
300
+ logger.info('Types: component, page, layout, loading, middleware');
301
+ return null;
302
+ }
303
+
304
+ if (type !== 'middleware' && !name) {
305
+ logger.error(`Usage: bertui create ${type} <name>`);
306
+ return null;
307
+ }
308
+
309
+ return { type, name: name || type };
310
+ }
@@ -1,45 +1,28 @@
1
- // bertui/src/server/dev-handler.js - NEW FILE
2
- // Headless dev server handler for Bunny integration
3
-
1
+ // bertui/src/server/dev-handler.js - WITH MIDDLEWARE + LAYOUTS + LOADING
4
2
  import { join, extname, dirname } from 'path';
5
- import { existsSync, readdirSync } from 'fs';
3
+ import { existsSync } from 'fs';
6
4
  import logger from '../logger/logger.js';
7
5
  import { compileProject } from '../client/compiler.js';
8
6
  import { loadConfig } from '../config/loadConfig.js';
9
7
  import { getContentType, getImageContentType, serveHTML, setupFileWatcher } from './dev-server-utils.js';
10
8
 
11
- /**
12
- * Create a headless dev server handler for integration with Elysia/Bunny
13
- * @param {Object} options
14
- * @param {string} options.root - Project root directory
15
- * @param {number} options.port - Port number (for HMR WebSocket URL)
16
- * @param {Object} options.elysiaApp - Optional Elysia app instance to mount on
17
- * @returns {Promise<Object>} Handler object with request handler and utilities
18
- */
19
9
  export async function createDevHandler(options = {}) {
20
10
  const root = options.root || process.cwd();
21
11
  const port = parseInt(options.port) || 3000;
22
- const elysiaApp = options.elysiaApp || null;
23
-
24
- // Initialize BertUI state
12
+ const middlewareManager = options.middleware || null;
13
+ const layouts = options.layouts || {};
14
+ const loadingComponents = options.loadingComponents || {};
15
+
25
16
  const compiledDir = join(root, '.bertui', 'compiled');
26
17
  const stylesDir = join(root, '.bertui', 'styles');
27
18
  const srcDir = join(root, 'src');
28
19
  const publicDir = join(root, 'public');
29
-
20
+
30
21
  const config = await loadConfig(root);
31
-
32
- let hasRouter = false;
33
- const routerPath = join(compiledDir, 'router.js');
34
- if (existsSync(routerPath)) {
35
- hasRouter = true;
36
- logger.info('File-based routing enabled');
37
- }
38
-
39
- // Clients set for HMR
22
+
23
+ let hasRouter = existsSync(join(compiledDir, 'router.js'));
40
24
  const clients = new Set();
41
-
42
- // WebSocket handler for HMR
25
+
43
26
  const websocketHandler = {
44
27
  open(ws) {
45
28
  clients.add(ws);
@@ -47,208 +30,155 @@ export async function createDevHandler(options = {}) {
47
30
  },
48
31
  close(ws) {
49
32
  clients.delete(ws);
50
- logger.debug(`HMR client disconnected (${clients.size} remaining)`);
51
- }
33
+ },
52
34
  };
53
-
54
- // Notify all HMR clients
35
+
55
36
  function notifyClients(message) {
56
37
  for (const client of clients) {
57
- try {
58
- client.send(JSON.stringify(message));
59
- } catch (e) {
60
- clients.delete(client);
61
- }
38
+ try { client.send(JSON.stringify(message)); }
39
+ catch (e) { clients.delete(client); }
62
40
  }
63
41
  }
64
-
65
- // Setup file watcher if we have a root
42
+
66
43
  let watcherCleanup = null;
67
44
  if (root) {
68
45
  watcherCleanup = setupFileWatcher(root, compiledDir, clients, async () => {
69
46
  hasRouter = existsSync(join(compiledDir, 'router.js'));
70
47
  });
71
48
  }
72
-
73
- // MAIN REQUEST HANDLER
49
+
74
50
  async function handleRequest(request) {
75
51
  const url = new URL(request.url);
76
-
77
- // Handle WebSocket upgrade for HMR
52
+
53
+ // WebSocket upgrade for HMR
78
54
  if (url.pathname === '/__hmr' && request.headers.get('upgrade') === 'websocket') {
79
- // This will be handled by Elysia/Bun.serve upgrade mechanism
80
55
  return { type: 'websocket', handler: websocketHandler };
81
56
  }
82
-
83
- // Serve HTML for routes
57
+
58
+ // Run middleware BEFORE every page request
59
+ if (middlewareManager && isPageRequest(url.pathname)) {
60
+ const middlewareResponse = await middlewareManager.run(request, {
61
+ route: url.pathname,
62
+ });
63
+ if (middlewareResponse) {
64
+ logger.debug(`🛡️ Middleware handled: ${url.pathname}`);
65
+ return middlewareResponse;
66
+ }
67
+ }
68
+
69
+ // Serve page HTML
84
70
  if (url.pathname === '/' || (!url.pathname.includes('.') && !url.pathname.startsWith('/compiled'))) {
85
71
  const html = await serveHTML(root, hasRouter, config, port);
86
72
  return new Response(html, {
87
- headers: { 'Content-Type': 'text/html' }
73
+ headers: { 'Content-Type': 'text/html' },
88
74
  });
89
75
  }
90
-
91
- // Serve compiled JavaScript
76
+
77
+ // Compiled JS (includes layouts and loading components)
92
78
  if (url.pathname.startsWith('/compiled/')) {
93
79
  const filepath = join(compiledDir, url.pathname.replace('/compiled/', ''));
94
80
  const file = Bun.file(filepath);
95
-
96
- if (await file.exists()) {
97
- const ext = extname(filepath).toLowerCase();
98
- const contentType = ext === '.js' ? 'application/javascript; charset=utf-8' : 'text/plain';
99
-
100
- return new Response(file, {
101
- headers: {
102
- 'Content-Type': contentType,
103
- 'Cache-Control': 'no-store, no-cache, must-revalidate'
104
- }
105
- });
106
- }
107
- }
108
-
109
- // Serve bertui-animate CSS
110
- if (url.pathname === '/bertui-animate.css') {
111
- const bertuiAnimatePath = join(root, 'node_modules/bertui-animate/dist/bertui-animate.min.css');
112
- const file = Bun.file(bertuiAnimatePath);
113
-
114
81
  if (await file.exists()) {
115
82
  return new Response(file, {
116
- headers: {
117
- 'Content-Type': 'text/css',
118
- 'Cache-Control': 'no-store'
119
- }
83
+ headers: {
84
+ 'Content-Type': 'application/javascript; charset=utf-8',
85
+ 'Cache-Control': 'no-store',
86
+ },
120
87
  });
121
88
  }
122
89
  }
123
-
124
- // Serve CSS
90
+
91
+ // CSS
125
92
  if (url.pathname.startsWith('/styles/')) {
126
93
  const filepath = join(stylesDir, url.pathname.replace('/styles/', ''));
127
94
  const file = Bun.file(filepath);
128
-
129
95
  if (await file.exists()) {
130
96
  return new Response(file, {
131
- headers: {
132
- 'Content-Type': 'text/css',
133
- 'Cache-Control': 'no-store'
134
- }
97
+ headers: { 'Content-Type': 'text/css', 'Cache-Control': 'no-store' },
135
98
  });
136
99
  }
137
100
  }
138
-
139
- // Serve images from src/images/
101
+
102
+ // bertui-animate CSS
103
+ if (url.pathname === '/bertui-animate.css') {
104
+ const animPath = join(root, 'node_modules/bertui-animate/dist/bertui-animate.min.css');
105
+ const file = Bun.file(animPath);
106
+ if (await file.exists()) {
107
+ return new Response(file, { headers: { 'Content-Type': 'text/css' } });
108
+ }
109
+ }
110
+
111
+ // Images
140
112
  if (url.pathname.startsWith('/images/')) {
141
113
  const filepath = join(srcDir, 'images', url.pathname.replace('/images/', ''));
142
114
  const file = Bun.file(filepath);
143
-
144
115
  if (await file.exists()) {
145
116
  const ext = extname(filepath).toLowerCase();
146
- const contentType = getImageContentType(ext);
147
-
148
117
  return new Response(file, {
149
- headers: {
150
- 'Content-Type': contentType,
151
- 'Cache-Control': 'no-cache'
152
- }
118
+ headers: { 'Content-Type': getImageContentType(ext), 'Cache-Control': 'no-cache' },
153
119
  });
154
120
  }
155
121
  }
156
-
157
- // Serve from public/
158
- if (url.pathname.startsWith('/public/')) {
122
+
123
+ // Public directory
124
+ if (url.pathname.startsWith('/public/') || existsSync(join(publicDir, url.pathname.slice(1)))) {
159
125
  const filepath = join(publicDir, url.pathname.replace('/public/', ''));
160
126
  const file = Bun.file(filepath);
161
-
162
127
  if (await file.exists()) {
163
- return new Response(file, {
164
- headers: { 'Cache-Control': 'no-cache' }
165
- });
128
+ return new Response(file, { headers: { 'Cache-Control': 'no-cache' } });
166
129
  }
167
130
  }
168
-
169
- // Serve node_modules
131
+
132
+ // node_modules
170
133
  if (url.pathname.startsWith('/node_modules/')) {
171
134
  const filepath = join(root, 'node_modules', url.pathname.replace('/node_modules/', ''));
172
135
  const file = Bun.file(filepath);
173
-
174
136
  if (await file.exists()) {
175
137
  const ext = extname(filepath).toLowerCase();
176
- let contentType;
177
-
178
- if (ext === '.css') {
179
- contentType = 'text/css';
180
- } else if (ext === '.js' || ext === '.mjs') {
181
- contentType = 'application/javascript; charset=utf-8';
182
- } else {
183
- contentType = getContentType(ext);
184
- }
185
-
138
+ const contentType = ext === '.css' ? 'text/css' :
139
+ ['.js', '.mjs'].includes(ext) ? 'application/javascript; charset=utf-8' :
140
+ getContentType(ext);
186
141
  return new Response(file, {
187
- headers: {
188
- 'Content-Type': contentType,
189
- 'Cache-Control': 'no-cache'
190
- }
142
+ headers: { 'Content-Type': contentType, 'Cache-Control': 'no-cache' },
191
143
  });
192
144
  }
193
145
  }
194
-
195
- // Not a BertUI route
146
+
196
147
  return null;
197
148
  }
198
-
199
- // Standalone server starter (for backward compatibility)
149
+
200
150
  async function start() {
201
151
  const server = Bun.serve({
202
152
  port,
203
153
  async fetch(req, server) {
204
154
  const url = new URL(req.url);
205
-
206
- // Handle WebSocket upgrade
207
155
  if (url.pathname === '/__hmr') {
208
156
  const success = server.upgrade(req);
209
157
  if (success) return undefined;
210
158
  return new Response('WebSocket upgrade failed', { status: 500 });
211
159
  }
212
-
213
- // Handle normal requests
214
160
  const response = await handleRequest(req);
215
161
  if (response) return response;
216
-
217
162
  return new Response('Not found', { status: 404 });
218
163
  },
219
- websocket: websocketHandler
164
+ websocket: websocketHandler,
220
165
  });
221
-
222
- logger.success(`🚀 BertUI standalone server running at http://localhost:${port}`);
166
+
167
+ logger.success(`🚀 BertUI running at http://localhost:${port}`);
223
168
  return server;
224
169
  }
225
-
226
- // Recompile project
227
- async function recompile() {
228
- return await compileProject(root);
229
- }
230
-
231
- // Cleanup
170
+
232
171
  function dispose() {
233
- if (watcherCleanup && typeof watcherCleanup === 'function') {
234
- watcherCleanup();
235
- }
172
+ if (watcherCleanup) watcherCleanup();
236
173
  clients.clear();
174
+ if (middlewareManager) middlewareManager.dispose();
237
175
  }
238
-
239
- return {
240
- handleRequest,
241
- start,
242
- recompile,
243
- dispose,
244
- notifyClients,
245
- config,
246
- hasRouter,
247
- // For Elysia integration
248
- getElysiaApp: () => elysiaApp,
249
- websocketHandler
250
- };
176
+
177
+ return { handleRequest, start, notifyClients, dispose, config, hasRouter, websocketHandler };
251
178
  }
252
179
 
253
- // Re-export for convenience
254
- export { handleRequest as standaloneHandler } from './request-handler.js';
180
+ function isPageRequest(pathname) {
181
+ // Skip asset requests
182
+ return !pathname.includes('.') ||
183
+ pathname.endsWith('.html');
184
+ }
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { validateServerIsland } from '../build/server-island-validator.js';
5
5
 
6
- export function extractStaticHTML(componentCode) {
6
+ function extractStaticHTML(componentCode) {
7
7
  // For now, use existing regex-based extractor
8
8
  // TODO: Replace with proper AST parser
9
9
  return extractJSXFromReturn(componentCode);