@zenithbuild/cli 1.3.4 → 1.3.7

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,548 @@
1
+ /**
2
+ * Zenith SSG Build System
3
+ *
4
+ * SSG-first (Static Site Generation) build system that outputs:
5
+ * - Per-page HTML files: dist/{route}/index.html
6
+ * - Shared runtime: dist/assets/bundle.js
7
+ * - Global styles: dist/assets/styles.css
8
+ * - Page-specific JS only for pages needing hydration: dist/assets/page_{name}.js
9
+ *
10
+ * Static pages get pure HTML+CSS, no JavaScript.
11
+ * Hydrated pages reference the shared bundle.js and their page-specific JS.
12
+ */
13
+
14
+ /**
15
+ * ═══════════════════════════════════════════════════════════════════════════════
16
+ * CLI HARDENING: BLIND ORCHESTRATOR PATTERN
17
+ * ═══════════════════════════════════════════════════════════════════════════════
18
+ *
19
+ * This build system uses the plugin bridge pattern:
20
+ * - Plugins are initialized unconditionally
21
+ * - Data is collected via 'cli:runtime:collect' hook
22
+ * - CLI never inspects or branches on plugin data
23
+ * ═══════════════════════════════════════════════════════════════════════════════
24
+ */
25
+
26
+ import fs from "fs"
27
+ import { compile } from "@zenithbuild/compiler"
28
+ import { discoverComponents } from "./discovery/componentDiscovery"
29
+ import { discoverPages, generateRouteDefinition } from "@zenithbuild/router/manifest"
30
+ import { analyzePageSource, getAnalysisSummary, getBuildOutputType, type PageAnalysis } from "@zenithbuild/bundler"
31
+ import { generateBundleJS } from "@zenithbuild/bundler"
32
+ import { compileCss, resolveGlobalsCss } from "@zenithbuild/bundler"
33
+ import { loadZenithConfig } from "@zenithbuild/compiler/config"
34
+ import { PluginRegistry, createPluginContext, getPluginDataByNamespace } from "@zenithbuild/compiler/registry"
35
+ import {
36
+ createBridgeAPI,
37
+ collectHookReturns,
38
+ buildRuntimeEnvelope,
39
+ clearHooks,
40
+ type HookContext
41
+ } from "@zenithbuild/compiler/plugins"
42
+ import { bundlePageScript } from "@zenithbuild/bundler"
43
+ import type { BundlePlan } from "@zenithbuild/compiler"
44
+
45
+ // ============================================
46
+ // Types
47
+ // ============================================
48
+
49
+ interface CompiledPage {
50
+ /** Route path like "/" or "/about" or "/blog/:id" */
51
+ routePath: string
52
+ /** Original file path */
53
+ filePath: string
54
+ /** Compiled HTML content */
55
+ html: string
56
+ /** Page-specific JavaScript (empty if static) */
57
+ pageScript: string
58
+ /** Hoisted imports for the page script */
59
+ pageImports: string
60
+ /** Page styles */
61
+ styles: string
62
+ /** Route score for matching priority */
63
+ score: number
64
+ /** Dynamic route parameter names */
65
+ paramNames: string[]
66
+ /** Build analysis result */
67
+ analysis: PageAnalysis
68
+ /** Output directory relative to dist/ */
69
+ outputDir: string
70
+ /** Compiler-emitted bundling plan (if bundling required) */
71
+ bundlePlan?: BundlePlan
72
+ }
73
+
74
+ export interface SSGBuildOptions {
75
+ /** Pages directory (e.g., app/pages) */
76
+ pagesDir: string
77
+ /** Output directory (e.g., app/dist) */
78
+ outDir: string
79
+ /** Base directory for components/layouts (e.g., app/) */
80
+ baseDir?: string
81
+ /** Include source maps */
82
+ sourceMaps?: boolean
83
+ }
84
+
85
+ // ============================================
86
+ // Page Compilation
87
+ // ============================================
88
+
89
+ /**
90
+ * Compile a single page file for SSG output
91
+ */
92
+ async function compilePage(
93
+ pagePath: string,
94
+ pagesDir: string,
95
+ baseDir: string = process.cwd()
96
+ ): Promise<CompiledPage> {
97
+ const source = fs.readFileSync(pagePath, 'utf-8')
98
+
99
+ // Analyze page requirements
100
+ const analysis = analyzePageSource(source)
101
+
102
+ // Determine source directory relative to pages (e.g., 'src' or 'app' or root)
103
+ const srcDir = path.dirname(pagesDir)
104
+
105
+ // Layout discovery removed in Phase A1
106
+ // const layouts = discoverLayouts(layoutsDir)
107
+
108
+ // Discover components & layouts
109
+ const componentsDir = path.join(srcDir, 'components')
110
+ const layoutsDir = path.join(srcDir, 'layouts')
111
+ const components = new Map<string, any>()
112
+
113
+ if (fs.existsSync(componentsDir)) {
114
+ const comps = discoverComponents(componentsDir)
115
+ for (const [k, v] of comps) components.set(k, v)
116
+ }
117
+
118
+ if (fs.existsSync(layoutsDir)) {
119
+ const layoutComps = discoverComponents(layoutsDir)
120
+ for (const [k, v] of layoutComps) {
121
+ // Start with uppercase = component
122
+ if (k[0] === k[0]?.toUpperCase()) {
123
+ components.set(k, v)
124
+ }
125
+ }
126
+ }
127
+
128
+ // Compile with unified pipeline
129
+ // const layoutToUse = layouts.get('DefaultLayout')
130
+ const result = await compile(source, pagePath, {
131
+ components,
132
+ // layout: layoutToUse
133
+ })
134
+
135
+ if (!result.finalized) {
136
+ throw new Error(`Compilation failed for ${pagePath}: No finalized output`)
137
+ }
138
+
139
+ // Extract compiled output
140
+ const html = result.finalized.html
141
+ const js = result.finalized.js || ''
142
+ const imports = result.finalized.npmImports || ''
143
+ const styles = result.finalized.styles || ''
144
+
145
+ // Generate route definition
146
+ const routeDef = generateRouteDefinition(pagePath, pagesDir)
147
+
148
+ // Determine output directory from route path
149
+ // "/" -> "index", "/about" -> "about", "/blog/post" -> "blog/post"
150
+ let outputDir = routeDef.path === '/' ? 'index' : routeDef.path.replace(/^\//, '')
151
+
152
+ // Handle dynamic routes - they'll be placeholders for now
153
+ // [id] segments become _id_ for folder names
154
+ outputDir = outputDir.replace(/\[([^\]]+)\]/g, '_$1_')
155
+
156
+ // Force hydration if we have compiled JS or if top-level analysis detected it
157
+ const needsHydration = analysis.needsHydration || js.trim().length > 0
158
+
159
+ return {
160
+ routePath: routeDef.path,
161
+ filePath: pagePath,
162
+ html,
163
+ pageScript: needsHydration ? js : '',
164
+ pageImports: needsHydration ? imports : '',
165
+ styles,
166
+ score: routeDef.score,
167
+ paramNames: routeDef.paramNames,
168
+ analysis: {
169
+ ...analysis,
170
+ needsHydration,
171
+ isStatic: !needsHydration && !analysis.needsSSR
172
+ },
173
+ outputDir,
174
+ bundlePlan: result.finalized.bundlePlan
175
+ }
176
+ }
177
+
178
+ // ============================================
179
+ // HTML Generation
180
+ // ============================================
181
+
182
+ /**
183
+ * Generate the final HTML for a page
184
+ * Static pages: no JS references
185
+ * Hydrated pages: bundle.js + page-specific JS
186
+ *
187
+ * Uses the neutral __ZENITH_PLUGIN_DATA__ envelope - CLI never inspects contents.
188
+ */
189
+ function generatePageHTML(page: CompiledPage, globalStyles: string, pluginEnvelope: Record<string, unknown>): string {
190
+ const { html, styles, analysis, routePath, pageScript } = page
191
+
192
+ // Combine styles
193
+ const pageStyles = styles
194
+ const allStyles = globalStyles + '\n' + pageStyles
195
+
196
+ // Build script tags only if needed
197
+ let scriptTags = ''
198
+ if (analysis.needsHydration) {
199
+ scriptTags = `
200
+ <script src="/assets/bundle.js"></script>`
201
+
202
+ if (pageScript) {
203
+ // Generate a safe filename from route path
204
+ const pageJsName = routePath === '/'
205
+ ? 'page_index.js'
206
+ : `page_${routePath.replace(/^\//, '').replace(/\//g, '_')}.js`
207
+ scriptTags += `
208
+ <script type="module" src="/assets/${pageJsName}"></script>`
209
+ }
210
+ }
211
+
212
+ // Check if HTML already has full document structure
213
+ const hasHtmlTag = /<html[^>]*>/i.test(html)
214
+
215
+ if (hasHtmlTag) {
216
+ // HTML already has structure from layout - inject styles and scripts
217
+ let finalHtml = html
218
+
219
+ // Inject styles into <head> if not already there
220
+ if (!/<style[^>]*>/.test(finalHtml)) {
221
+ finalHtml = finalHtml.replace(
222
+ '</head>',
223
+ ` <style>\n${allStyles}\n </style>\n</head>`
224
+ )
225
+ }
226
+
227
+ // Inject scripts before </body>
228
+ if (scriptTags) {
229
+ finalHtml = finalHtml.replace(
230
+ '</body>',
231
+ `${scriptTags}\n</body>`
232
+ )
233
+ }
234
+
235
+ return finalHtml
236
+ }
237
+
238
+ // Generate full HTML document for pages without layout
239
+ return `<!DOCTYPE html>
240
+ <html lang="en">
241
+ <head>
242
+ <meta charset="UTF-8">
243
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
244
+ <title>Zenith App</title>
245
+ <style>
246
+ ${allStyles}
247
+ </style>
248
+ </head>
249
+ <body>
250
+ ${html}${scriptTags}
251
+ </body>
252
+ </html>`
253
+ }
254
+
255
+ // ============================================
256
+ // Asset Generation
257
+ // ============================================
258
+
259
+ /**
260
+ * Generate page-specific JavaScript
261
+ */
262
+ function generatePageJS(page: CompiledPage): string {
263
+ if (!page.pageScript) return ''
264
+
265
+ // Module imports must be top-level
266
+ return `// Zenith Page: ${page.routePath}
267
+ // Phase 5: ES Module Mode
268
+
269
+ ${page.pageScript}
270
+
271
+ // Trigger hydration after DOM is ready
272
+ (function() {
273
+ function trigger() {
274
+ if (window.__zenith && window.__zenith.triggerMount) {
275
+ window.__zenith.triggerMount();
276
+ }
277
+ }
278
+
279
+ if (document.readyState === 'loading') {
280
+ document.addEventListener('DOMContentLoaded', trigger);
281
+ } else {
282
+ trigger();
283
+ }
284
+ })();
285
+ `
286
+ }
287
+
288
+ // ============================================
289
+ // Main Build Function
290
+ // ============================================
291
+
292
+ /**
293
+ * Build all pages using SSG approach
294
+ *
295
+ * Follows the blind orchestrator pattern:
296
+ * - Plugins are initialized unconditionally
297
+ * - Data is collected via hooks
298
+ * - CLI never inspects plugin data
299
+ */
300
+ export async function buildSSG(options: SSGBuildOptions): Promise<void> {
301
+ const { pagesDir, outDir, baseDir = path.dirname(pagesDir) } = options
302
+
303
+ console.log('🔨 Zenith SSG Build')
304
+ console.log(` Pages: ${pagesDir}`)
305
+ console.log(` Output: ${outDir}`)
306
+ console.log('')
307
+
308
+ // ============================================
309
+ // Plugin Initialization (Unconditional)
310
+ // ============================================
311
+ // Load config and initialize all plugins without checking which ones exist.
312
+ const config = await loadZenithConfig(baseDir)
313
+ const registry = new PluginRegistry()
314
+ const bridgeAPI = createBridgeAPI()
315
+
316
+ // Clear any previously registered hooks
317
+ clearHooks()
318
+
319
+ // Register ALL plugins unconditionally
320
+ for (const plugin of config.plugins || []) {
321
+ console.log(` Plugin: ${plugin.name}`)
322
+ registry.register(plugin)
323
+
324
+ // Let plugin register its CLI hooks
325
+ if (plugin.registerCLI) {
326
+ plugin.registerCLI(bridgeAPI)
327
+ }
328
+ }
329
+
330
+ // Initialize all plugins
331
+ await registry.initAll(createPluginContext(baseDir))
332
+
333
+ // Create hook context - CLI provides this but NEVER uses getPluginData itself
334
+ const hookCtx: HookContext = {
335
+ projectRoot: baseDir,
336
+ getPluginData: getPluginDataByNamespace
337
+ }
338
+
339
+ // Collect runtime payloads from ALL plugins
340
+ const payloads = await collectHookReturns('cli:runtime:collect', hookCtx)
341
+ const pluginEnvelope = buildRuntimeEnvelope(payloads)
342
+
343
+ console.log('')
344
+
345
+ // Clean and create output directory
346
+ if (fs.existsSync(outDir)) {
347
+ fs.rmSync(outDir, { recursive: true, force: true })
348
+ }
349
+ fs.mkdirSync(outDir, { recursive: true })
350
+ fs.mkdirSync(path.join(outDir, 'assets'), { recursive: true })
351
+
352
+ // Discover pages
353
+ const pageFiles = discoverPages(pagesDir)
354
+
355
+ if (pageFiles.length === 0) {
356
+ console.warn('⚠️ No pages found in', pagesDir)
357
+ return
358
+ }
359
+
360
+ console.log(`📄 Found ${pageFiles.length} page(s)`)
361
+
362
+ // Compile all pages
363
+ const compiledPages: CompiledPage[] = []
364
+ let hasHydratedPages = false
365
+
366
+ for (const pageFile of pageFiles) {
367
+ const relativePath = path.relative(pagesDir, pageFile)
368
+ console.log(` Compiling: ${relativePath}`)
369
+
370
+ try {
371
+ const compiled = await compilePage(pageFile, pagesDir, baseDir)
372
+ compiledPages.push(compiled)
373
+
374
+ if (compiled.analysis.needsHydration) {
375
+ hasHydratedPages = true
376
+ }
377
+
378
+ const outputType = getBuildOutputType(compiled.analysis)
379
+ const summary = getAnalysisSummary(compiled.analysis)
380
+
381
+ // Check if it's "forced" hydration (analysis missed it, but compiler found JS)
382
+ const logType = outputType.toUpperCase()
383
+ console.log(` → ${logType} [${summary}]`)
384
+ } catch (error: any) {
385
+ console.error(` ❌ Error: ${error.message}`)
386
+ throw error
387
+ }
388
+ }
389
+
390
+ console.log('')
391
+
392
+ // Compile global styles (Tailwind CSS)
393
+ let globalStyles = ''
394
+ const globalsCssPath = resolveGlobalsCss(baseDir)
395
+ if (globalsCssPath) {
396
+ console.log('📦 Compiling CSS:', path.relative(baseDir, globalsCssPath))
397
+ const cssOutputPath = path.join(outDir, 'assets', 'styles.css')
398
+ const result = compileCss({
399
+ input: globalsCssPath,
400
+ output: cssOutputPath,
401
+ minify: true
402
+ })
403
+ if (result.success) {
404
+ globalStyles = result.css
405
+ console.log(`📦 Generated assets/styles.css (${result.duration}ms)`)
406
+ } else {
407
+ console.error('❌ CSS compilation failed:', result.error)
408
+ }
409
+ }
410
+
411
+ // Write bundle.js if any pages need hydration
412
+ if (hasHydratedPages) {
413
+ const bundleJS = generateBundleJS(pluginEnvelope)
414
+ fs.writeFileSync(path.join(outDir, 'assets', 'bundle.js'), bundleJS)
415
+ console.log('📦 Generated assets/bundle.js (with plugin data)')
416
+ }
417
+
418
+ // Write each page
419
+ for (const page of compiledPages) {
420
+ // Create output directory
421
+ const pageOutDir = path.join(outDir, page.outputDir)
422
+ fs.mkdirSync(pageOutDir, { recursive: true })
423
+
424
+ // Generate and write HTML
425
+ const html = generatePageHTML(page, globalStyles, pluginEnvelope)
426
+ fs.writeFileSync(path.join(pageOutDir, 'index.html'), html)
427
+
428
+ // Write page-specific JS if needed
429
+ if (page.pageScript) {
430
+ const pageJsName = page.routePath === '/'
431
+ ? 'page_index.js'
432
+ : `page_${page.routePath.replace(/^\//, '').replace(/\//g, '_')}.js`
433
+ const pageJS = generatePageJS(page)
434
+
435
+ if (page.routePath === '/' && pageJS.includes('</a>')) {
436
+ console.log('🚨 LEAKED JSX DETECTED IN INDEX.ZEN:')
437
+ // print relevant lines
438
+ const lines = pageJS.split('\n');
439
+ lines.forEach((line, i) => {
440
+ if (line.includes('</a>')) {
441
+ console.log(`${i + 1}: ${line.trim()}`)
442
+ }
443
+ })
444
+ }
445
+
446
+ // Bundle ONLY if compiler emitted a BundlePlan (no inference)
447
+ let bundledJS = pageJS
448
+ if (page.bundlePlan) {
449
+ const plan: BundlePlan = {
450
+ ...page.bundlePlan,
451
+ entry: pageJS,
452
+ resolveRoots: [path.join(baseDir, 'node_modules'), 'node_modules']
453
+ }
454
+ bundledJS = await bundlePageScript(plan)
455
+ }
456
+ fs.writeFileSync(path.join(outDir, 'assets', pageJsName), bundledJS)
457
+ }
458
+
459
+ console.log(`✅ ${page.outputDir}/index.html`)
460
+ }
461
+
462
+ // Copy favicon if exists
463
+ const faviconPath = path.join(baseDir, 'favicon.ico')
464
+ if (fs.existsSync(faviconPath)) {
465
+ fs.copyFileSync(faviconPath, path.join(outDir, 'favicon.ico'))
466
+ console.log('📦 Copied favicon.ico')
467
+ }
468
+
469
+ // Generate 404 page
470
+ const custom404Candidates = ['404.zen', '+404.zen', 'not-found.zen']
471
+ let has404 = false
472
+
473
+ for (const candidate of custom404Candidates) {
474
+ const custom404Path = path.join(pagesDir, candidate)
475
+ if (fs.existsSync(custom404Path)) {
476
+ try {
477
+ const compiled = await compilePage(custom404Path, pagesDir, baseDir)
478
+ const html = generatePageHTML(compiled, globalStyles, pluginEnvelope)
479
+ fs.writeFileSync(path.join(outDir, '404.html'), html)
480
+ console.log('📦 Generated 404.html (custom)')
481
+ has404 = true
482
+ if (compiled.pageScript) {
483
+ const pageJS = generatePageJS(compiled)
484
+ // Bundle ONLY if compiler emitted a BundlePlan (no inference)
485
+ let bundledJS = pageJS
486
+ if (compiled.bundlePlan) {
487
+ const plan: BundlePlan = {
488
+ ...compiled.bundlePlan,
489
+ entry: pageJS,
490
+ resolveRoots: [path.join(baseDir, 'node_modules'), 'node_modules']
491
+ }
492
+ bundledJS = await bundlePageScript(plan)
493
+ }
494
+ fs.writeFileSync(path.join(outDir, 'assets', 'page_404.js'), bundledJS)
495
+ }
496
+ } catch (error: any) {
497
+ console.warn(` ⚠️ Could not compile ${candidate}: ${error.message}`)
498
+ }
499
+ break
500
+ }
501
+ }
502
+
503
+ if (!has404) {
504
+ const default404HTML = `<!DOCTYPE html>
505
+ <html lang="en">
506
+ <head>
507
+ <meta charset="UTF-8">
508
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
509
+ <title>Page Not Found | Zenith</title>
510
+ <style>
511
+ * { box-sizing: border-box; margin: 0; padding: 0; }
512
+ body { font-family: system-ui, sans-serif; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); color: #f1f5f9; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
513
+ .container { text-align: center; padding: 2rem; }
514
+ .error-code { font-size: 8rem; font-weight: 800; background: linear-gradient(135deg, #3b82f6, #06b6d4); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; line-height: 1; margin-bottom: 1rem; }
515
+ h1 { font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem; color: #e2e8f0; }
516
+ .message { color: #94a3b8; margin-bottom: 2rem; }
517
+ a { display: inline-block; background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; text-decoration: none; padding: 0.75rem 1.5rem; border-radius: 8px; font-weight: 500; }
518
+ </style>
519
+ </head>
520
+ <body>
521
+ <div class="container">
522
+ <div class="error-code">404</div>
523
+ <h1>Page Not Found</h1>
524
+ <p class="message">The page you're looking for doesn't exist.</p>
525
+ <a href="/">← Go Home</a>
526
+ </div>
527
+ </body>
528
+ </html>`
529
+ fs.writeFileSync(path.join(outDir, '404.html'), default404HTML)
530
+ console.log('📦 Generated 404.html (default)')
531
+ }
532
+
533
+ // Summary
534
+ console.log('')
535
+ console.log('✨ Build complete!')
536
+ console.log(` Static pages: ${compiledPages.filter(p => p.analysis.isStatic).length}`)
537
+ console.log(` Hydrated pages: ${compiledPages.filter(p => p.analysis.needsHydration).length}`)
538
+ console.log(` SSR pages: ${compiledPages.filter(p => p.analysis.needsSSR).length}`)
539
+ console.log('')
540
+
541
+ // Route manifest
542
+ console.log('📍 Routes:')
543
+ for (const page of compiledPages.sort((a, b) => b.score - a.score)) {
544
+ const type = getBuildOutputType(page.analysis)
545
+ console.log(` ${page.routePath.padEnd(20)} → ${page.outputDir}/index.html (${type})`)
546
+ }
547
+ }
548
+