bertui 1.1.0 → 1.1.2

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,293 @@
1
+ // bertui/src/build/generators/html-generator.js - FIXED PRODUCTION IMPORT MAP
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
+
7
+ export async function generateProductionHTML(root, outDir, buildResult, routes, serverIslands, config) {
8
+ const mainBundle = buildResult.outputs.find(o =>
9
+ o.path.includes('main') && o.kind === 'entry-point'
10
+ );
11
+
12
+ if (!mainBundle) {
13
+ logger.error('❌ Could not find main bundle');
14
+ return;
15
+ }
16
+
17
+ const bundlePath = relative(outDir, mainBundle.path).replace(/\\/g, '/');
18
+ const defaultMeta = config.meta || {};
19
+
20
+ // ✅ FIX: Check if bertui-icons is installed and copy to dist/
21
+ const bertuiIconsInstalled = await copyBertuiIconsToProduction(root, outDir);
22
+
23
+ logger.info(`📄 Generating HTML for ${routes.length} routes...`);
24
+
25
+ // Process in batches to avoid Bun crashes
26
+ const BATCH_SIZE = 5;
27
+
28
+ for (let i = 0; i < routes.length; i += BATCH_SIZE) {
29
+ const batch = routes.slice(i, i + BATCH_SIZE);
30
+ logger.debug(`Processing batch ${Math.floor(i/BATCH_SIZE) + 1}/${Math.ceil(routes.length/BATCH_SIZE)}`);
31
+
32
+ // Process batch sequentially
33
+ for (const route of batch) {
34
+ await processSingleRoute(route, serverIslands, config, defaultMeta, bundlePath, outDir, bertuiIconsInstalled);
35
+ }
36
+ }
37
+
38
+ logger.success(`✅ HTML generation complete for ${routes.length} routes`);
39
+ }
40
+
41
+ // ✅ NEW: Copy bertui-icons to dist/ for production
42
+ async function copyBertuiIconsToProduction(root, outDir) {
43
+ const nodeModulesDir = join(root, 'node_modules');
44
+ const bertuiIconsSource = join(nodeModulesDir, 'bertui-icons');
45
+
46
+ if (!existsSync(bertuiIconsSource)) {
47
+ logger.debug('bertui-icons not installed, skipping...');
48
+ return false;
49
+ }
50
+
51
+ try {
52
+ const bertuiIconsDest = join(outDir, 'node_modules', 'bertui-icons');
53
+ mkdirSync(join(outDir, 'node_modules'), { recursive: true });
54
+
55
+ // Copy the entire bertui-icons package
56
+ cpSync(bertuiIconsSource, bertuiIconsDest, { recursive: true });
57
+
58
+ logger.success('✅ Copied bertui-icons to dist/node_modules/');
59
+ return true;
60
+ } catch (error) {
61
+ logger.error(`Failed to copy bertui-icons: ${error.message}`);
62
+ return false;
63
+ }
64
+ }
65
+
66
+ async function processSingleRoute(route, serverIslands, config, defaultMeta, bundlePath, outDir, bertuiIconsInstalled) {
67
+ try {
68
+ const sourceCode = await Bun.file(route.path).text();
69
+ const pageMeta = extractMetaFromSource(sourceCode);
70
+ const meta = { ...defaultMeta, ...pageMeta };
71
+
72
+ const isServerIsland = serverIslands.find(si => si.route === route.route);
73
+
74
+ let staticHTML = '';
75
+
76
+ if (isServerIsland) {
77
+ logger.info(`🏝️ Extracting static content: ${route.route}`);
78
+ staticHTML = await extractStaticHTMLFromComponent(sourceCode, route.path);
79
+
80
+ if (staticHTML) {
81
+ logger.success(`✅ Server Island rendered: ${route.route}`);
82
+ } else {
83
+ logger.warn(`⚠️ Could not extract HTML, falling back to client-only`);
84
+ }
85
+ }
86
+
87
+ const html = generateHTML(meta, route, bundlePath, staticHTML, isServerIsland, bertuiIconsInstalled);
88
+
89
+ let htmlPath;
90
+ if (route.route === '/') {
91
+ htmlPath = join(outDir, 'index.html');
92
+ } else {
93
+ const routeDir = join(outDir, route.route.replace(/^\//, ''));
94
+ mkdirSync(routeDir, { recursive: true });
95
+ htmlPath = join(routeDir, 'index.html');
96
+ }
97
+
98
+ await Bun.write(htmlPath, html);
99
+
100
+ if (isServerIsland) {
101
+ logger.success(`✅ Server Island: ${route.route} (instant content!)`);
102
+ } else {
103
+ logger.success(`✅ Client-only: ${route.route}`);
104
+ }
105
+
106
+ } catch (error) {
107
+ logger.error(`Failed HTML for ${route.route}: ${error.message}`);
108
+ }
109
+ }
110
+
111
+ async function extractStaticHTMLFromComponent(sourceCode, filePath) {
112
+ try {
113
+ const returnMatch = sourceCode.match(/return\s*\(/);
114
+ if (!returnMatch) {
115
+ logger.warn(`⚠️ Could not find return statement in ${filePath}`);
116
+ return null;
117
+ }
118
+
119
+ const codeBeforeReturn = sourceCode.substring(0, returnMatch.index);
120
+ const jsxContent = sourceCode.substring(returnMatch.index);
121
+
122
+ const hookPatterns = [
123
+ 'useState', 'useEffect', 'useContext', 'useReducer',
124
+ 'useCallback', 'useMemo', 'useRef', 'useImperativeHandle',
125
+ 'useLayoutEffect', 'useDebugValue'
126
+ ];
127
+
128
+ let hasHooks = false;
129
+ for (const hook of hookPatterns) {
130
+ const regex = new RegExp(`\\b${hook}\\s*\\(`, 'g');
131
+ if (regex.test(codeBeforeReturn)) {
132
+ logger.error(`❌ Server Island at ${filePath} contains React hooks!`);
133
+ logger.error(` Server Islands must be pure HTML - no ${hook}, etc.`);
134
+ hasHooks = true;
135
+ break;
136
+ }
137
+ }
138
+
139
+ if (hasHooks) return null;
140
+
141
+ const importLines = codeBeforeReturn.split('\n')
142
+ .filter(line => line.trim().startsWith('import'))
143
+ .join('\n');
144
+
145
+ const hasRouterImport = /from\s+['"]bertui\/router['"]/m.test(importLines);
146
+
147
+ if (hasRouterImport) {
148
+ logger.error(`❌ Server Island at ${filePath} imports from 'bertui/router'!`);
149
+ logger.error(` Server Islands cannot use Link - use <a> tags instead.`);
150
+ return null;
151
+ }
152
+
153
+ const eventHandlers = [
154
+ 'onClick=', 'onChange=', 'onSubmit=', 'onInput=', 'onFocus=',
155
+ 'onBlur=', 'onMouseEnter=', 'onMouseLeave=', 'onKeyDown=',
156
+ 'onKeyUp=', 'onScroll='
157
+ ];
158
+
159
+ for (const handler of eventHandlers) {
160
+ if (jsxContent.includes(handler)) {
161
+ logger.error(`❌ Server Island uses event handler: ${handler.replace('=', '')}`);
162
+ logger.error(` Server Islands are static HTML - no interactivity allowed`);
163
+ return null;
164
+ }
165
+ }
166
+
167
+ const fullReturnMatch = sourceCode.match(/return\s*\(([\s\S]*?)\);?\s*}/);
168
+ if (!fullReturnMatch) {
169
+ logger.warn(`⚠️ Could not extract JSX from ${filePath}`);
170
+ return null;
171
+ }
172
+
173
+ let html = fullReturnMatch[1].trim();
174
+
175
+ html = html.replace(/\{\/\*[\s\S]*?\*\/\}/g, '');
176
+ html = html.replace(/className=/g, 'class=');
177
+
178
+ html = html.replace(/style=\{\{([^}]+)\}\}/g, (match, styleObj) => {
179
+ const props = [];
180
+ let currentProp = '';
181
+ let depth = 0;
182
+
183
+ for (let i = 0; i < styleObj.length; i++) {
184
+ const char = styleObj[i];
185
+ if (char === '(') depth++;
186
+ if (char === ')') depth--;
187
+
188
+ if (char === ',' && depth === 0) {
189
+ props.push(currentProp.trim());
190
+ currentProp = '';
191
+ } else {
192
+ currentProp += char;
193
+ }
194
+ }
195
+ if (currentProp.trim()) props.push(currentProp.trim());
196
+
197
+ const cssString = props
198
+ .map(prop => {
199
+ const colonIndex = prop.indexOf(':');
200
+ if (colonIndex === -1) return '';
201
+
202
+ const key = prop.substring(0, colonIndex).trim();
203
+ const value = prop.substring(colonIndex + 1).trim();
204
+
205
+ if (!key || !value) return '';
206
+
207
+ const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase();
208
+ const cssValue = value.replace(/['"]/g, '');
209
+
210
+ return `${cssKey}: ${cssValue}`;
211
+ })
212
+ .filter(Boolean)
213
+ .join('; ');
214
+
215
+ return `style="${cssString}"`;
216
+ });
217
+
218
+ const voidElements = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img',
219
+ 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
220
+
221
+ html = html.replace(/<(\w+)([^>]*)\s*\/>/g, (match, tag, attrs) => {
222
+ if (voidElements.includes(tag.toLowerCase())) {
223
+ return match;
224
+ } else {
225
+ return `<${tag}${attrs}></${tag}>`;
226
+ }
227
+ });
228
+
229
+ html = html.replace(/\{`([^`]*)`\}/g, '$1');
230
+ html = html.replace(/\{(['"])(.*?)\1\}/g, '$2');
231
+ html = html.replace(/\{(\d+)\}/g, '$1');
232
+
233
+ logger.info(` Extracted ${html.length} chars of static HTML`);
234
+ return html;
235
+
236
+ } catch (error) {
237
+ logger.error(`Failed to extract HTML: ${error.message}`);
238
+ return null;
239
+ }
240
+ }
241
+
242
+ // ✅ FIXED: Add bertuiIconsInstalled parameter
243
+ function generateHTML(meta, route, bundlePath, staticHTML = '', isServerIsland = false, bertuiIconsInstalled = false) {
244
+ const rootContent = staticHTML
245
+ ? `<div id="root">${staticHTML}</div>`
246
+ : '<div id="root"></div>';
247
+
248
+ const comment = isServerIsland
249
+ ? '<!-- 🏝️ Server Island: Static content rendered at build time -->'
250
+ : '<!-- ⚡ Client-only: Content rendered by JavaScript -->';
251
+
252
+ // ✅ FIX: Add bertui-icons to production import map if installed
253
+ const bertuiIconsImport = bertuiIconsInstalled
254
+ ? ',\n "bertui-icons": "/node_modules/bertui-icons/generated/index.js"'
255
+ : '';
256
+
257
+ return `<!DOCTYPE html>
258
+ <html lang="${meta.lang || 'en'}">
259
+ <head>
260
+ <meta charset="UTF-8">
261
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
262
+ <title>${meta.title || 'BertUI App'}</title>
263
+
264
+ <meta name="description" content="${meta.description || 'Built with BertUI'}">
265
+ ${meta.keywords ? `<meta name="keywords" content="${meta.keywords}">` : ''}
266
+ ${meta.author ? `<meta name="author" content="${meta.author}">` : ''}
267
+ ${meta.themeColor ? `<meta name="theme-color" content="${meta.themeColor}">` : ''}
268
+
269
+ <meta property="og:title" content="${meta.ogTitle || meta.title || 'BertUI App'}">
270
+ <meta property="og:description" content="${meta.ogDescription || meta.description || 'Built with BertUI'}">
271
+ ${meta.ogImage ? `<meta property="og:image" content="${meta.ogImage}">` : ''}
272
+
273
+ <link rel="stylesheet" href="/styles/bertui.min.css">
274
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
275
+
276
+ <script type="importmap">
277
+ {
278
+ "imports": {
279
+ "react": "https://esm.sh/react@18.2.0",
280
+ "react-dom": "https://esm.sh/react-dom@18.2.0",
281
+ "react-dom/client": "https://esm.sh/react-dom@18.2.0/client",
282
+ "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime"${bertuiIconsImport}
283
+ }
284
+ }
285
+ </script>
286
+ </head>
287
+ <body>
288
+ ${comment}
289
+ ${rootContent}
290
+ <script type="module" src="/${bundlePath}"></script>
291
+ </body>
292
+ </html>`;
293
+ }
@@ -0,0 +1,58 @@
1
+ // bertui/src/build/robots-generator.js
2
+ import { join } from 'path';
3
+ import logger from '../../logger/logger.js';
4
+
5
+ /**
6
+ * Generate robots.txt from sitemap data
7
+ * @param {Object} config - BertUI config with baseUrl
8
+ * @param {string} outDir - Output directory (dist/)
9
+ * @param {Array} routes - Optional: routes to disallow (e.g., admin pages)
10
+ */
11
+ export async function generateRobots(config, outDir, routes = []) {
12
+ logger.info('🤖 Generating robots.txt...');
13
+
14
+ // ✅ FIX: Check if baseUrl exists, then remove trailing slash
15
+ if (!config?.baseUrl) {
16
+ logger.error('❌ baseUrl is required in bertui.config.js for robots.txt generation!');
17
+ logger.error(' Add: baseUrl: "https://your-domain.com" to your config');
18
+ throw new Error('Missing baseUrl in config - robots.txt generation failed');
19
+ }
20
+
21
+ const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
22
+ const sitemapUrl = `${baseUrl}/sitemap.xml`;
23
+
24
+ logger.info(` Sitemap URL: ${sitemapUrl}`);
25
+
26
+ // Default robots.txt - Allow all
27
+ let robotsTxt = `# BertUI Generated robots.txt
28
+ User-agent: *
29
+ Allow: /
30
+
31
+ # Sitemap
32
+ Sitemap: ${sitemapUrl}
33
+ `;
34
+
35
+ // Add custom disallow rules if specified in config
36
+ if (config?.robots?.disallow && Array.isArray(config.robots.disallow) && config.robots.disallow.length > 0) {
37
+ robotsTxt += '\n# Custom Disallow Rules\n';
38
+ config.robots.disallow.forEach(path => {
39
+ robotsTxt += `Disallow: ${path}\n`;
40
+ });
41
+ logger.info(` Blocked ${config.robots.disallow.length} path(s)`);
42
+ }
43
+
44
+ // Add crawl delay if specified
45
+ if (config?.robots?.crawlDelay && typeof config.robots.crawlDelay === 'number') {
46
+ robotsTxt += `\nCrawl-delay: ${config.robots.crawlDelay}\n`;
47
+ logger.info(` Crawl delay: ${config.robots.crawlDelay}s`);
48
+ }
49
+
50
+ // Write to dist/robots.txt
51
+ const robotsPath = join(outDir, 'robots.txt');
52
+ await Bun.write(robotsPath, robotsTxt);
53
+
54
+ logger.success('✅ robots.txt generated');
55
+ logger.info(` Location: ${robotsPath}`);
56
+
57
+ return { path: robotsPath, content: robotsTxt };
58
+ }
@@ -0,0 +1,63 @@
1
+ // bertui/src/build/sitemap-generator.js - SIMPLIFIED
2
+ import { join } from 'path';
3
+ import logger from '../../logger/logger';
4
+
5
+ function calculatePriority(route) {
6
+ if (route === '/') return 1.0;
7
+ if (route.includes(':')) return 0.6;
8
+ const depth = route.split('/').filter(Boolean).length;
9
+ if (depth === 1) return 0.8;
10
+ if (depth === 2) return 0.7;
11
+ return 0.6;
12
+ }
13
+
14
+ function generateSitemapXML(routes, baseUrl) {
15
+ const urls = routes.map(route => {
16
+ const url = `${baseUrl}${route.route}`;
17
+ return ` <url>
18
+ <loc>${url}</loc>
19
+ <lastmod>${route.lastmod}</lastmod>
20
+ <changefreq>weekly</changefreq>
21
+ <priority>${route.priority}</priority>
22
+ </url>`;
23
+ }).join('\n');
24
+
25
+ return `<?xml version="1.0" encoding="UTF-8"?>
26
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
27
+ ${urls}
28
+ </urlset>`;
29
+ }
30
+
31
+ export async function generateSitemap(routes, config, outDir) {
32
+ logger.info('🗺️ Generating sitemap.xml...');
33
+
34
+ if (!config?.baseUrl) {
35
+ logger.error('❌ baseUrl is required in bertui.config.js for sitemap generation!');
36
+ logger.error(' Add: baseUrl: "https://your-domain.com" to your config');
37
+ throw new Error('Missing baseUrl in config - sitemap generation failed');
38
+ }
39
+
40
+ const baseUrl = config.baseUrl.replace(/\/$/, '');
41
+ const currentDate = new Date().toISOString().split('T')[0];
42
+
43
+ logger.info(` Base URL: ${baseUrl}`);
44
+
45
+ const staticRoutes = routes.filter(r => r.type === 'static');
46
+ logger.info(` ${staticRoutes.length} static routes will be included in sitemap`);
47
+
48
+ // SIMPLE: No file reading, just route processing
49
+ const sitemapRoutes = staticRoutes.map(route => ({
50
+ route: route.route,
51
+ lastmod: currentDate,
52
+ priority: calculatePriority(route.route)
53
+ }));
54
+
55
+ const xml = generateSitemapXML(sitemapRoutes, baseUrl);
56
+ const sitemapPath = join(outDir, 'sitemap.xml');
57
+ await Bun.write(sitemapPath, xml);
58
+
59
+ logger.success(`✅ Sitemap generated: ${sitemapRoutes.length} URLs`);
60
+ logger.info(` Location: ${sitemapPath}`);
61
+
62
+ return { routes: sitemapRoutes, path: sitemapPath };
63
+ }
@@ -0,0 +1,19 @@
1
+ // bertui/src/build/processors/asset-processor.js
2
+ import { join } from 'path';
3
+ import { existsSync, mkdirSync } from 'fs'; // ✅ ADD THIS IMPORT
4
+ import { copyImages } from '../image-optimizer.js';
5
+
6
+ export async function copyAllStaticAssets(root, outDir) {
7
+ const publicDir = join(root, 'public');
8
+ const srcImagesDir = join(root, 'src', 'images');
9
+
10
+ if (existsSync(publicDir)) {
11
+ copyImages(publicDir, outDir);
12
+ }
13
+
14
+ if (existsSync(srcImagesDir)) {
15
+ const distImagesDir = join(outDir, 'images');
16
+ mkdirSync(distImagesDir, { recursive: true });
17
+ copyImages(srcImagesDir, distImagesDir);
18
+ }
19
+ }
@@ -0,0 +1,102 @@
1
+ // bertui/src/build/processors/css-builder.js - SAFE VERSION
2
+ import { join } from 'path';
3
+ import { existsSync, readdirSync, mkdirSync } from 'fs';
4
+ import logger from '../../logger/logger.js';
5
+
6
+ export async function buildAllCSS(root, outDir) {
7
+ const srcStylesDir = join(root, 'src', 'styles');
8
+ const stylesOutDir = join(outDir, 'styles');
9
+
10
+ mkdirSync(stylesOutDir, { recursive: true });
11
+
12
+ if (existsSync(srcStylesDir)) {
13
+ const cssFiles = readdirSync(srcStylesDir).filter(f => f.endsWith('.css'));
14
+
15
+ if (cssFiles.length === 0) {
16
+ await Bun.write(join(stylesOutDir, 'bertui.min.css'), '/* No CSS */');
17
+ return;
18
+ }
19
+
20
+ logger.info(`Processing ${cssFiles.length} CSS file(s)...`);
21
+
22
+ let combinedCSS = '';
23
+ for (const cssFile of cssFiles) {
24
+ const srcPath = join(srcStylesDir, cssFile);
25
+ const file = Bun.file(srcPath);
26
+ const cssContent = await file.text();
27
+ combinedCSS += `/* ${cssFile} */\n${cssContent}\n\n`;
28
+ }
29
+
30
+ const combinedPath = join(stylesOutDir, 'bertui.min.css');
31
+
32
+ // ✅ SAFE: Try Lightning CSS, fallback to simple minification
33
+ try {
34
+ const minified = await minifyCSSSafe(combinedCSS);
35
+ await Bun.write(combinedPath, minified);
36
+
37
+ const originalSize = Buffer.byteLength(combinedCSS);
38
+ const minifiedSize = Buffer.byteLength(minified);
39
+ const reduction = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
40
+
41
+ logger.success(`CSS minified: ${(originalSize/1024).toFixed(2)}KB → ${(minifiedSize/1024).toFixed(2)}KB (-${reduction}%)`);
42
+ } catch (error) {
43
+ logger.warn(`CSS minification failed: ${error.message}`);
44
+ logger.info('Falling back to unminified CSS...');
45
+ await Bun.write(combinedPath, combinedCSS);
46
+ }
47
+
48
+ logger.success(`✅ Combined ${cssFiles.length} CSS files`);
49
+ } else {
50
+ // No styles directory, create empty CSS
51
+ await Bun.write(join(stylesOutDir, 'bertui.min.css'), '/* No custom styles */');
52
+ logger.info('No styles directory found, created empty CSS');
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Safe CSS minification with fallback
58
+ */
59
+ async function minifyCSSSafe(css) {
60
+ // Try Lightning CSS first
61
+ try {
62
+ const { transform } = await import('lightningcss');
63
+
64
+ const { code } = transform({
65
+ filename: 'styles.css',
66
+ code: Buffer.from(css),
67
+ minify: true,
68
+ sourceMap: false,
69
+ targets: {
70
+ chrome: 90 << 16,
71
+ firefox: 88 << 16,
72
+ safari: 14 << 16,
73
+ edge: 90 << 16
74
+ }
75
+ });
76
+
77
+ return code.toString();
78
+
79
+ } catch (lightningError) {
80
+ logger.warn('Lightning CSS failed, using simple minification');
81
+
82
+ // Fallback: Simple manual minification
83
+ return simpleMinifyCSS(css);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Simple CSS minification without dependencies
89
+ */
90
+ function simpleMinifyCSS(css) {
91
+ return css
92
+ // Remove comments
93
+ .replace(/\/\*[\s\S]*?\*\//g, '')
94
+ // Remove extra whitespace
95
+ .replace(/\s+/g, ' ')
96
+ // Remove space around { } : ; ,
97
+ .replace(/\s*([{}:;,])\s*/g, '$1')
98
+ // Remove trailing semicolons before }
99
+ .replace(/;}/g, '}')
100
+ // Remove leading/trailing whitespace
101
+ .trim();
102
+ }