juxscript 1.1.140 → 1.1.141

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,834 @@
1
+ import * as esbuild from 'esbuild';
2
+ import * as acorn from 'acorn';
3
+ import { walk } from 'astray';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ export class JuxCompiler {
12
+ constructor(config = {}) {
13
+ this.config = config;
14
+ this.srcDir = config.srcDir || './jux';
15
+ this.distDir = config.distDir || './.jux-dist';
16
+ this.publicDir = config.publicDir || './public'; // ✅ Configurable public path
17
+ this.defaults = config.defaults || {};
18
+ this.paths = config.paths || {};
19
+ this._juxscriptExports = null;
20
+ this._juxscriptPath = null;
21
+ this._renderFunctionCounter = 0; // ✅ Add counter for unique IDs
22
+ }
23
+
24
+ /**
25
+ * Locate juxscript package - simplified resolution
26
+ */
27
+ findJuxscriptPath() {
28
+ if (this._juxscriptPath) return this._juxscriptPath;
29
+
30
+ const projectRoot = process.cwd();
31
+
32
+ // Priority 1: User's node_modules (when used as dependency)
33
+ const userPath = path.resolve(projectRoot, 'node_modules/juxscript/index.js');
34
+ if (fs.existsSync(userPath)) {
35
+ this._juxscriptPath = userPath;
36
+ return userPath;
37
+ }
38
+
39
+ // Priority 2: Package root (when developing juxscript itself)
40
+ const packageRoot = path.resolve(__dirname, '..');
41
+ const devPath = path.resolve(packageRoot, 'index.js');
42
+ if (fs.existsSync(devPath)) {
43
+ this._juxscriptPath = devPath;
44
+ return devPath;
45
+ }
46
+
47
+ // Priority 3: Sibling in monorepo
48
+ const monoPath = path.resolve(projectRoot, '../jux/index.js');
49
+ if (fs.existsSync(monoPath)) {
50
+ this._juxscriptPath = monoPath;
51
+ return monoPath;
52
+ }
53
+
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * ✅ Recursively scan for .jux and .js files in srcDir and subdirectories
59
+ */
60
+ scanFiles() {
61
+ const views = [], dataModules = [], sharedModules = [];
62
+
63
+ const scanDirectory = (currentDir) => {
64
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
65
+
66
+ for (const entry of entries) {
67
+ const fullPath = path.join(currentDir, entry.name);
68
+
69
+ if (entry.isDirectory()) {
70
+ scanDirectory(fullPath);
71
+ } else if (entry.isFile()) {
72
+ const file = entry.name;
73
+
74
+ if ((file.endsWith('.jux') || file.endsWith('.js')) && !this.isAssetFile(file)) {
75
+ const content = fs.readFileSync(fullPath, 'utf8');
76
+ const relativePath = path.relative(this.srcDir, fullPath);
77
+ const name = relativePath.replace(/\.[^/.]+$/, '');
78
+
79
+ // Check if it has exports (module) or is executable code (view)
80
+ const hasExports = /export\s+(default|const|function|class|{)/.test(content);
81
+
82
+ if (file.includes('data')) {
83
+ dataModules.push({
84
+ name,
85
+ file: relativePath,
86
+ content,
87
+ originalContent: content
88
+ });
89
+ } else if (hasExports) {
90
+ // ✅ Any file with exports is a module (not just .js files)
91
+ sharedModules.push({
92
+ name,
93
+ file: relativePath,
94
+ content,
95
+ originalContent: content
96
+ });
97
+ } else {
98
+ // .jux files without exports are views - use AST to extract imports
99
+ let wrappedContent;
100
+ try {
101
+ const ast = acorn.parse(content, {
102
+ ecmaVersion: 'latest',
103
+ sourceType: 'module',
104
+ locations: true
105
+ });
106
+
107
+ const imports = [];
108
+ let lastImportEnd = 0;
109
+
110
+ // Collect imports and track where they end
111
+ for (const node of ast.body) {
112
+ if (node.type === 'ImportDeclaration') {
113
+ imports.push(content.substring(node.start, node.end));
114
+ lastImportEnd = node.end;
115
+ }
116
+ }
117
+
118
+ // Get the rest of the code (everything after imports)
119
+ const restOfCode = content.substring(lastImportEnd).trim();
120
+
121
+ // Build wrapped content
122
+ wrappedContent = [
123
+ ...imports,
124
+ '',
125
+ 'export default async function() {',
126
+ restOfCode,
127
+ '}'
128
+ ].join('\n');
129
+
130
+ } catch (parseError) {
131
+ // Fallback: if parsing fails, just wrap the whole thing
132
+ console.warn(`⚠️ Could not parse ${relativePath}, using basic wrapping`);
133
+ wrappedContent = `export default async function() {\n${content}\n}`;
134
+ }
135
+
136
+ views.push({
137
+ name,
138
+ file: relativePath,
139
+ content: wrappedContent,
140
+ originalContent: content
141
+ });
142
+ }
143
+ }
144
+ }
145
+ }
146
+ };
147
+
148
+ scanDirectory(this.srcDir);
149
+ return { views, dataModules, sharedModules };
150
+ }
151
+
152
+ isAssetFile(filename) {
153
+ const assetExtensions = ['.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.woff', '.woff2', '.ttf', '.eot'];
154
+ return assetExtensions.some(ext => filename.endsWith(ext));
155
+ }
156
+
157
+ removeImports(code) {
158
+ return code
159
+ .replace(/^\s*import\s+.*?from\s+['"][^'"]+['"][\s;]*$/gm, '')
160
+ .replace(/^\s*import\s*\{[\s\S]*?\}\s*from\s*['"][^'"]+['"][\s;]*/gm, '')
161
+ .replace(/^\s*import\s+\w+\s+from\s+['"][^'"]+['"][\s;]*$/gm, '')
162
+ .replace(/^\s*import\s*;?\s*$/gm, '');
163
+ }
164
+
165
+ sanitizeName(name) {
166
+ return name.replace(/[^a-zA-Z0-9]/g, '_');
167
+ }
168
+
169
+ async loadJuxscriptExports() {
170
+ if (this._juxscriptExports) return this._juxscriptExports;
171
+
172
+ try {
173
+ const juxscriptPath = this.findJuxscriptPath();
174
+ if (juxscriptPath) {
175
+ const indexContent = fs.readFileSync(juxscriptPath, 'utf8');
176
+ const exports = new Set();
177
+
178
+ for (const match of indexContent.matchAll(/export\s*\{\s*([^}]+)\s*\}/g)) {
179
+ match[1].split(',').forEach(exp => {
180
+ const name = exp.trim().split(/\s+as\s+/)[0].trim();
181
+ if (name) exports.add(name);
182
+ });
183
+ }
184
+
185
+ this._juxscriptExports = [...exports];
186
+ if (this._juxscriptExports.length > 0) {
187
+ console.log(`📦 juxscript exports: ${this._juxscriptExports.join(', ')}`);
188
+ }
189
+ }
190
+ } catch (err) {
191
+ this._juxscriptExports = [];
192
+ }
193
+
194
+ return this._juxscriptExports;
195
+ }
196
+
197
+ validateViewCode(viewName, code) {
198
+ const issues = [];
199
+
200
+ let ast;
201
+ try {
202
+ ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
203
+ } catch (parseError) {
204
+ issues.push({
205
+ type: 'error',
206
+ view: viewName,
207
+ line: parseError.loc?.line || 0,
208
+ message: `Syntax error: ${parseError.message}`,
209
+ code: ''
210
+ });
211
+ return issues;
212
+ }
213
+
214
+ const allImports = new Set();
215
+
216
+ walk(ast, {
217
+ ImportDeclaration(node) {
218
+ node.specifiers.forEach(spec => {
219
+ allImports.add(spec.local.name);
220
+ });
221
+ }
222
+ });
223
+
224
+ // Default known facades/components if load fails
225
+ const knownComponents = this._juxscriptExports || ['element', 'input', 'buttonGroup'];
226
+
227
+ walk(ast, {
228
+ Identifier(node, parent) {
229
+ if (parent?.type === 'CallExpression' && parent.callee === node) {
230
+ const name = node.name;
231
+ if (!allImports.has(name) && knownComponents.includes(name)) {
232
+ issues.push({
233
+ type: 'warning',
234
+ view: viewName,
235
+ line: node.loc?.start?.line || 0,
236
+ message: `"${name}" is used but not imported from juxscript`,
237
+ code: ''
238
+ });
239
+ }
240
+ }
241
+ }
242
+ });
243
+
244
+ return issues;
245
+ }
246
+
247
+ /**
248
+ * Generate entry point - inline EVERYTHING
249
+ */
250
+ generateEntryPoint(views, dataModules, sharedModules) {
251
+ let entry = `// Auto-generated JUX entry point\n\n`;
252
+ const sourceSnapshot = {};
253
+
254
+ // ✅ Collect ALL unique imports from ALL files (views + shared + data)
255
+ const allImports = new Map(); // Map<resolvedPath, Set<importedNames>>
256
+ const allFiles = [...views, ...sharedModules, ...dataModules];
257
+
258
+ allFiles.forEach(file => {
259
+ const codeBody = file.originalContent || file.content;
260
+ const filePath = path.join(this.srcDir, file.file);
261
+
262
+ try {
263
+ const ast = acorn.parse(codeBody, {
264
+ ecmaVersion: 'latest',
265
+ sourceType: 'module',
266
+ locations: true
267
+ });
268
+
269
+ // Extract import statements
270
+ ast.body.filter(node => node.type === 'ImportDeclaration').forEach(node => {
271
+ const importPath = node.source.value;
272
+
273
+ // ✅ Resolve the import path
274
+ let resolvedImportPath;
275
+ if (importPath.startsWith('.')) {
276
+ // Relative import
277
+ const absolutePath = path.resolve(path.dirname(filePath), importPath);
278
+ resolvedImportPath = path.relative(this.srcDir, absolutePath);
279
+ resolvedImportPath = resolvedImportPath.replace(/\\/g, '/');
280
+ } else {
281
+ // Module import like 'juxscript', 'axios'
282
+ resolvedImportPath = importPath;
283
+ }
284
+
285
+ // Initialize Set for this path
286
+ if (!allImports.has(resolvedImportPath)) {
287
+ allImports.set(resolvedImportPath, new Set());
288
+ }
289
+
290
+ // Add imported names
291
+ node.specifiers.forEach(spec => {
292
+ if (spec.type === 'ImportSpecifier') {
293
+ allImports.get(resolvedImportPath).add(spec.imported.name);
294
+ } else if (spec.type === 'ImportDefaultSpecifier') {
295
+ allImports.get(resolvedImportPath).add('default:' + spec.local.name);
296
+ }
297
+ });
298
+ });
299
+ } catch (parseError) {
300
+ console.warn(`⚠️ Could not parse ${file.file} for imports`);
301
+ }
302
+ });
303
+
304
+ // ✅ Filter out imports that point to our own files (these will be inlined)
305
+ const externalImports = new Map();
306
+ allImports.forEach((names, resolvedPath) => {
307
+ // Only include if it's NOT a file in our src directory
308
+ if (!resolvedPath.endsWith('.js') && !resolvedPath.endsWith('.jux')) {
309
+ externalImports.set(resolvedPath, names);
310
+ }
311
+ });
312
+
313
+ // ✅ Write external imports only (juxscript, axios, etc.)
314
+ externalImports.forEach((names, importPath) => {
315
+ const namedImports = Array.from(names).filter(n => !n.startsWith('default:'));
316
+ const defaultImports = Array.from(names).filter(n => n.startsWith('default:')).map(n => n.split(':')[1]);
317
+
318
+ if (defaultImports.length > 0) {
319
+ defaultImports.forEach(name => {
320
+ entry += `import ${name} from '${importPath}';\n`;
321
+ });
322
+ }
323
+
324
+ if (namedImports.length > 0) {
325
+ entry += `import { ${namedImports.join(', ')} } from '${importPath}';\n`;
326
+ }
327
+ });
328
+
329
+ entry += '\n';
330
+
331
+ // ✅ Inline shared modules as constants/functions
332
+ entry += `// --- SHARED MODULES (INLINED) ---\n`;
333
+ sharedModules.forEach(m => {
334
+ sourceSnapshot[m.file] = {
335
+ name: m.name,
336
+ file: m.file,
337
+ content: m.content,
338
+ lines: m.content.split('\n')
339
+ };
340
+
341
+ // Remove imports and exports, just keep the code
342
+ let code = m.originalContent || m.content;
343
+ code = this._stripImportsAndExports(code);
344
+
345
+ entry += `\n// From: ${m.file}\n${code}\n`;
346
+ });
347
+
348
+ // ✅ Inline data modules
349
+ entry += `\n// --- DATA MODULES (INLINED) ---\n`;
350
+ dataModules.forEach(m => {
351
+ sourceSnapshot[m.file] = {
352
+ name: m.name,
353
+ file: m.file,
354
+ content: m.content,
355
+ lines: m.content.split('\n')
356
+ };
357
+
358
+ let code = m.originalContent || m.content;
359
+ code = this._stripImportsAndExports(code);
360
+
361
+ entry += `\n// From: ${m.file}\n${code}\n`;
362
+ });
363
+
364
+ // ✅ Expose everything to window (since we removed exports)
365
+ //entry += `\n// Expose to window\n`;
366
+ //entry += `Object.assign(window, { jux, state, registry, stateHistory, Link, link, navLink, externalLink, layer, overlay, VStack, HStack, ZStack, vstack, hstack, zstack });\n`;
367
+
368
+ entry += `\n// --- VIEW FUNCTIONS ---\n`;
369
+
370
+ const routeToFunctionMap = new Map();
371
+
372
+ // ✅ Process views: strip imports, inline as functions
373
+ views.forEach((v, index) => {
374
+ const functionName = `renderJux${index}`;
375
+
376
+ sourceSnapshot[v.file] = {
377
+ name: v.name,
378
+ file: v.file,
379
+ content: v.originalContent || v.content,
380
+ lines: (v.originalContent || v.content).split('\n'),
381
+ functionName
382
+ };
383
+
384
+ let codeBody = v.originalContent || v.content;
385
+ codeBody = this._stripImportsAndExports(codeBody);
386
+
387
+ entry += `\nasync function ${functionName}() {\n${codeBody}\n}\n`;
388
+
389
+ // Generate route path
390
+ const routePath = v.name
391
+ .toLowerCase()
392
+ .replace(/\\/g, '/')
393
+ .replace(/\.jux$/i, '')
394
+ .replace(/\./g, '-')
395
+ .replace(/\s+/g, '-')
396
+ .replace(/[^a-z0-9\/_-]/g, '')
397
+ .replace(/-+/g, '-')
398
+ .replace(/^-|-$/g, '');
399
+
400
+ if (routePath === 'index' || routePath === '') {
401
+ routeToFunctionMap.set('/', functionName);
402
+ }
403
+ routeToFunctionMap.set(`/${routePath}`, functionName);
404
+ });
405
+
406
+ // ✅ Generate router
407
+ entry += this._generateRouter(routeToFunctionMap);
408
+
409
+ this._sourceSnapshot = sourceSnapshot;
410
+ this._validationIssues = [];
411
+
412
+ return entry;
413
+ }
414
+
415
+ /**
416
+ * Strip imports and exports from code, keeping the actual logic
417
+ */
418
+ _stripImportsAndExports(code) {
419
+ try {
420
+ const ast = acorn.parse(code, {
421
+ ecmaVersion: 'latest',
422
+ sourceType: 'module',
423
+ locations: true
424
+ });
425
+
426
+ // Remove imports and exports
427
+ const nodesToRemove = ast.body.filter(node =>
428
+ node.type === 'ImportDeclaration' ||
429
+ node.type === 'ExportNamedDeclaration' ||
430
+ node.type === 'ExportDefaultDeclaration' ||
431
+ node.type === 'ExportAllDeclaration'
432
+ );
433
+
434
+ if (nodesToRemove.length === 0) {
435
+ return code.trim();
436
+ }
437
+
438
+ // Remove nodes in reverse order
439
+ let result = code;
440
+ let offset = 0;
441
+
442
+ nodesToRemove.forEach(node => {
443
+ let start = node.start - offset;
444
+ let end = node.end - offset;
445
+
446
+ // If it's an export with a declaration, keep the declaration
447
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
448
+ // Keep the declaration part, remove just "export"
449
+ const declarationStart = node.declaration.start - offset;
450
+ result = result.substring(0, start) + result.substring(declarationStart);
451
+ offset += (declarationStart - start);
452
+ } else if (node.type === 'ExportDefaultDeclaration') {
453
+ // Remove "export default", keep the expression
454
+ const exportKeyword = result.substring(start, end).match(/export\s+default\s+/);
455
+ if (exportKeyword) {
456
+ result = result.substring(0, start) + result.substring(start + exportKeyword[0].length);
457
+ offset += exportKeyword[0].length;
458
+ }
459
+ } else {
460
+ // Remove the entire node
461
+ result = result.substring(0, start) + result.substring(end);
462
+ offset += (end - start);
463
+ }
464
+ });
465
+
466
+ return result.trim();
467
+ } catch (parseError) {
468
+ console.warn(`⚠️ Could not parse code for stripping, keeping as-is`);
469
+ return code.trim();
470
+ }
471
+ }
472
+
473
+ async build() {
474
+ console.log('🚀 JUX Build\n');
475
+
476
+ const juxscriptPath = this.findJuxscriptPath();
477
+ if (!juxscriptPath) {
478
+ console.error('❌ Could not locate juxscript package');
479
+ return { success: false, errors: [{ message: 'juxscript not found' }], warnings: [] };
480
+ }
481
+ console.log(`📦 Using: ${juxscriptPath}`);
482
+
483
+ await this.loadJuxscriptExports();
484
+
485
+ if (fs.existsSync(this.distDir)) {
486
+ fs.rmSync(this.distDir, { recursive: true, force: true });
487
+ }
488
+ fs.mkdirSync(this.distDir, { recursive: true });
489
+
490
+ // ✅ Copy public folder if exists
491
+ this.copyPublicFolder();
492
+
493
+ const { views, dataModules, sharedModules } = this.scanFiles();
494
+ console.log(`📁 Found ${views.length} views, ${sharedModules.length} shared, ${dataModules.length} data`);
495
+
496
+ // ❌ REMOVE: No need to copy files to dist - everything goes in bundle
497
+ // const juxDistDir = path.join(this.distDir, 'jux');
498
+ // fs.mkdirSync(juxDistDir, { recursive: true });
499
+ // ... copying logic removed ...
500
+
501
+ const entryContent = this.generateEntryPoint(views, dataModules, sharedModules);
502
+
503
+ const entryPath = path.join(this.distDir, 'entry.js');
504
+ fs.writeFileSync(entryPath, entryContent);
505
+
506
+ const snapshotPath = path.join(this.distDir, '__jux_sources.json');
507
+ fs.writeFileSync(snapshotPath, JSON.stringify(this._sourceSnapshot, null, 2));
508
+ console.log(`📸 Source snapshot written`);
509
+
510
+ const validation = this.reportValidationIssues();
511
+ if (!validation.isValid) {
512
+ console.log('🛑 BUILD FAILED\n');
513
+ return { success: false, errors: validation.errors, warnings: validation.warnings };
514
+ }
515
+
516
+ try {
517
+ console.log('🔧 Starting esbuild...\n');
518
+
519
+ await esbuild.build({
520
+ entryPoints: [entryPath],
521
+ bundle: true,
522
+ outfile: path.join(this.distDir, 'bundle.js'),
523
+ format: 'esm',
524
+ platform: 'browser',
525
+ target: 'esnext',
526
+ sourcemap: true,
527
+
528
+ loader: {
529
+ '.jux': 'js',
530
+ '.css': 'empty'
531
+ },
532
+
533
+ plugins: [{
534
+ name: 'juxscript-resolver',
535
+ setup: (build) => {
536
+ // Resolve juxscript
537
+ build.onResolve({ filter: /^juxscript$/ }, () => ({
538
+ path: juxscriptPath
539
+ }));
540
+
541
+ // ✅ Force axios to resolve from project's node_modules
542
+ build.onResolve({ filter: /^axios$/ }, () => {
543
+ const projectRoot = process.cwd();
544
+ const axiosPath = path.resolve(projectRoot, 'node_modules/axios/dist/esm/axios.js');
545
+
546
+ if (fs.existsSync(axiosPath)) {
547
+ console.log('✅ Found axios at:', axiosPath);
548
+ return { path: axiosPath };
549
+ }
550
+
551
+ console.error('❌ axios not found in project node_modules');
552
+ return null;
553
+ });
554
+
555
+ // ✅ Resolve .jux file imports - with detailed logging
556
+ build.onResolve({ filter: /\.jux$/ }, (args) => {
557
+ console.log(`🔍 Resolving: ${args.path} from ${args.importer}`);
558
+
559
+ // Skip if already resolved
560
+ if (path.isAbsolute(args.path)) {
561
+ return { path: args.path };
562
+ }
563
+
564
+ // Handle relative imports
565
+ if (args.path.startsWith('.')) {
566
+ const importer = args.importer || entryPath;
567
+ const importerDir = path.dirname(importer);
568
+ const resolvedPath = path.resolve(importerDir, args.path);
569
+
570
+ console.log(` Importer dir: ${importerDir}`);
571
+ console.log(` Resolved to: ${resolvedPath}`);
572
+ console.log(` Exists: ${fs.existsSync(resolvedPath)}`);
573
+
574
+ if (fs.existsSync(resolvedPath)) {
575
+ return { path: resolvedPath };
576
+ } else {
577
+ console.error(`❌ Could not resolve ${args.path} from ${importer}`);
578
+ console.error(` Tried: ${resolvedPath}`);
579
+ }
580
+ }
581
+
582
+ return null;
583
+ });
584
+ }
585
+ }],
586
+
587
+ mainFields: ['browser', 'module', 'main'],
588
+ conditions: ['browser', 'import', 'module', 'default'],
589
+
590
+ define: {
591
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
592
+ 'global': 'globalThis',
593
+ 'process.env': JSON.stringify({})
594
+ },
595
+
596
+ minify: false,
597
+ treeShaking: true,
598
+ metafile: true,
599
+ });
600
+
601
+ console.log('\n✅ Build complete');
602
+
603
+ const bundlePath = path.join(this.distDir, 'bundle.js');
604
+ const bundleStats = fs.statSync(bundlePath);
605
+ const bundleSizeKB = (bundleStats.size / 1024).toFixed(2);
606
+ console.log(`📦 Bundle size: ${bundleSizeKB} KB`);
607
+
608
+ } catch (err) {
609
+ console.error('❌ esbuild failed:', err);
610
+ return { success: false, errors: [{ message: err.message }], warnings: [] };
611
+ }
612
+
613
+ const html = `<!DOCTYPE html>
614
+ <html lang="en">
615
+ <head>
616
+ <meta charset="UTF-8">
617
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
618
+ <title>JUX Application</title>
619
+ <script type="module" src="/bundle.js"></script>
620
+ </head>
621
+ <body>
622
+ <div id="app"></div>
623
+ </body>
624
+ </html>`;
625
+
626
+ fs.writeFileSync(
627
+ path.join(this.config.distDir, 'index.html'),
628
+ html,
629
+ 'utf8'
630
+ );
631
+
632
+ console.log('✅ Generated index.html\n');
633
+ return { success: true, errors: [], warnings: validation.warnings };
634
+ }
635
+
636
+ /**
637
+ * Generate router code
638
+ */
639
+ _generateRouter(routeToFunctionMap) {
640
+ let router = `\n// --- ROUTER ---\n`;
641
+ router += `const routes = {\n`;
642
+
643
+ routeToFunctionMap.forEach((functionName, route) => {
644
+ router += ` '${route}': ${functionName},\n`;
645
+ });
646
+
647
+ router += `};\n\n`;
648
+
649
+ router += `function route(path) {
650
+ const renderFn = routes[path] || routes['/'];
651
+ if (renderFn) {
652
+ // Clear main content area
653
+ const appMain = document.getElementById('appmain-content');
654
+ if (appMain) appMain.innerHTML = '';
655
+
656
+ const app = document.getElementById('app');
657
+ if (app) app.innerHTML = '';
658
+
659
+ // Call render function
660
+ if (typeof renderFn === 'function') {
661
+ renderFn();
662
+ }
663
+ } else {
664
+ const app = document.getElementById('app');
665
+ if (app) app.innerHTML = '<h1>404 - Page Not Found</h1>';
666
+ }
667
+ }
668
+
669
+ // Initial route
670
+ window.addEventListener('DOMContentLoaded', () => {
671
+ route(window.location.pathname);
672
+ });
673
+
674
+ // Handle navigation
675
+ window.addEventListener('popstate', () => route(window.location.pathname));
676
+
677
+ // Intercept link clicks for SPA navigation
678
+ document.addEventListener('click', (e) => {
679
+ const link = e.target.closest('a[href]');
680
+ if (link) {
681
+ const href = link.getAttribute('href');
682
+ if (href.startsWith('/') && !href.startsWith('//')) {
683
+ e.preventDefault();
684
+ window.history.pushState({}, '', href);
685
+ route(href);
686
+ }
687
+ }
688
+ });
689
+
690
+ // Expose router for manual navigation
691
+ window.navigateTo = (path) => {
692
+ window.history.pushState({}, '', path);
693
+ route(path);
694
+ };
695
+ `;
696
+
697
+ return router;
698
+ }
699
+
700
+ /**
701
+ * Report validation issues
702
+ */
703
+ reportValidationIssues() {
704
+ const errors = [];
705
+ const warnings = [];
706
+
707
+ // Check if there are any validation issues collected
708
+ if (this._validationIssues && this._validationIssues.length > 0) {
709
+ this._validationIssues.forEach(issue => {
710
+ if (issue.type === 'error') {
711
+ errors.push(issue);
712
+ } else if (issue.type === 'warning') {
713
+ warnings.push(issue);
714
+ }
715
+ });
716
+ }
717
+
718
+ // Log warnings
719
+ if (warnings.length > 0) {
720
+ console.log('\n⚠️ Warnings:\n');
721
+ warnings.forEach(w => {
722
+ console.log(` ${w.view}:${w.line} - ${w.message}`);
723
+ });
724
+ }
725
+
726
+ // Log errors
727
+ if (errors.length > 0) {
728
+ console.log('\n❌ Errors:\n');
729
+ errors.forEach(e => {
730
+ console.log(` ${e.view}:${e.line} - ${e.message}`);
731
+ });
732
+ }
733
+
734
+ return {
735
+ isValid: errors.length === 0,
736
+ errors,
737
+ warnings
738
+ };
739
+ }
740
+
741
+ /**
742
+ * Copy public folder contents to dist
743
+ */
744
+ copyPublicFolder() {
745
+ // ✅ Use configured public path or resolve from paths object
746
+ const publicSrc = this.paths.public
747
+ ? this.paths.public
748
+ : path.resolve(process.cwd(), this.publicDir);
749
+
750
+ if (!fs.existsSync(publicSrc)) {
751
+ return; // No public folder, skip
752
+ }
753
+
754
+ console.log('📦 Copying public assets...');
755
+
756
+ try {
757
+ this._copyDirRecursive(publicSrc, this.distDir, 0);
758
+ console.log('✅ Public assets copied');
759
+ } catch (err) {
760
+ console.warn('⚠️ Error copying public folder:', err.message);
761
+ }
762
+ }
763
+
764
+ /**
765
+ * Recursively copy directory contents
766
+ */
767
+ _copyDirRecursive(src, dest, depth = 0) {
768
+ const entries = fs.readdirSync(src, { withFileTypes: true });
769
+
770
+ entries.forEach(entry => {
771
+ // Skip hidden files and directories
772
+ if (entry.name.startsWith('.')) return;
773
+
774
+ const srcPath = path.join(src, entry.name);
775
+ const destPath = path.join(dest, entry.name);
776
+
777
+ if (entry.isDirectory()) {
778
+ // Create directory and recurse
779
+ if (!fs.existsSync(destPath)) {
780
+ fs.mkdirSync(destPath, { recursive: true });
781
+ }
782
+ this._copyDirRecursive(srcPath, destPath, depth + 1);
783
+ } else {
784
+ // Copy file
785
+ fs.copyFileSync(srcPath, destPath);
786
+
787
+ // Log files at root level only
788
+ if (depth === 0) {
789
+ const ext = path.extname(entry.name);
790
+ const icon = this._getFileIcon(ext);
791
+ console.log(` ${icon} ${entry.name}`);
792
+ }
793
+ }
794
+ });
795
+ }
796
+
797
+ /**
798
+ * Get icon for file type
799
+ */
800
+ _getFileIcon(ext) {
801
+ const icons = {
802
+ '.html': '📄',
803
+ '.css': '🎨',
804
+ '.js': '📜',
805
+ '.json': '📋',
806
+ '.png': '🖼️',
807
+ '.jpg': '🖼️',
808
+ '.jpeg': '🖼️',
809
+ '.gif': '🖼️',
810
+ '.svg': '🎨',
811
+ '.ico': '🔖',
812
+ '.woff': '🔤',
813
+ '.woff2': '🔤',
814
+ '.ttf': '🔤',
815
+ '.eot': '🔤'
816
+ };
817
+ return icons[ext.toLowerCase()] || '📦';
818
+ }
819
+
820
+ /**
821
+ * ✅ Generate valid JavaScript identifier from file path
822
+ * Example: abc/aaa.jux -> abc_aaa
823
+ * Example: menus/main.jux -> menus_main
824
+ * Example: pages/blog/post.jux -> pages_blog_post
825
+ */
826
+ _generateNameFromPath(filepath) {
827
+ return filepath
828
+ .replace(/\.jux$/, '') // Remove .jux extension
829
+ .replace(/[\/\\]/g, '_') // Replace / and \ with _
830
+ .replace(/[^a-zA-Z0-9_]/g, '_') // Replace any other invalid chars with _
831
+ .replace(/_+/g, '_') // Collapse multiple consecutive underscores
832
+ .replace(/^_|_$/g, ''); // Remove leading/trailing underscores
833
+ }
834
+ }