@zenithbuild/cli 0.4.11 → 1.3.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zenithbuild/cli",
3
- "version": "0.4.11",
3
+ "version": "1.3.6",
4
4
  "description": "CLI for Zenith framework - dev server, build tools, and plugin management",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,14 +50,17 @@
50
50
  },
51
51
  "private": false,
52
52
  "peerDependencies": {
53
- "@zenithbuild/core": "^1.2.15"
53
+ "@zenithbuild/core": "^1.3.0",
54
+ "@zenithbuild/compiler": "^1.3.0"
54
55
  },
55
56
  "devDependencies": {
56
- "@types/bun": "latest"
57
+ "@types/bun": "latest",
58
+ "@zenithbuild/compiler": "workspace:*"
57
59
  },
58
60
  "dependencies": {
59
- "@zenithbuild/compiler": "^1.0.16",
60
- "@zenithbuild/router": "^1.0.8",
61
+ "@zenithbuild/bundler": "workspace:*",
62
+ "@zenithbuild/router": "workspace:*",
63
+ "glob": "^13.0.0",
61
64
  "picocolors": "^1.0.0"
62
65
  }
63
- }
66
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { requireProject } from '../utils/project'
8
8
  import * as logger from '../utils/logger'
9
- import { buildSSG } from '@zenithbuild/compiler'
9
+ import { buildSSG } from '../ssg-build.ts'
10
10
 
11
11
  export interface BuildOptions {
12
12
  outDir?: string
@@ -25,23 +25,32 @@ import { requireProject } from '../utils/project'
25
25
  import * as logger from '../utils/logger'
26
26
  import * as brand from '../utils/branding'
27
27
  import {
28
- compileZenSource,
29
- discoverLayouts,
30
- processLayout,
28
+ generateRuntime,
31
29
  generateBundleJS,
32
- loadZenithConfig,
33
- PluginRegistry,
34
- createPluginContext,
35
- getPluginDataByNamespace,
36
30
  compileCssAsync,
37
31
  resolveGlobalsCss,
32
+ bundlePageScript
33
+ } from '@zenithbuild/bundler'
34
+ import type { ZenManifest } from '@zenithbuild/bundler'
35
+ import { generateRouteDefinition } from '@zenithbuild/router/manifest'
36
+ import { compile } from '@zenithbuild/compiler'
37
+ import { discoverLayouts } from '../discovery/layouts.ts'
38
+ import { discoverComponents } from '../discovery/componentDiscovery.ts'
39
+ import { processLayout } from '@zenithbuild/compiler/transform'
40
+ import { loadZenithConfig } from '@zenithbuild/compiler/config'
41
+ import {
42
+ PluginRegistry,
43
+ createPluginContext,
44
+ getPluginDataByNamespace
45
+ } from '@zenithbuild/compiler/registry'
46
+ import {
38
47
  createBridgeAPI,
39
48
  runPluginHooks,
40
49
  collectHookReturns,
41
50
  buildRuntimeEnvelope,
42
- clearHooks,
43
- type HookContext
44
- } from '@zenithbuild/compiler'
51
+ clearHooks
52
+ } from '@zenithbuild/compiler/plugins'
53
+ import type { HookContext } from '@zenithbuild/compiler/plugins'
45
54
 
46
55
  export interface DevOptions {
47
56
  port?: number
@@ -61,7 +70,7 @@ const pageCache = new Map<string, CompiledPage>()
61
70
  * Bundle page script using Rolldown to resolve npm imports at compile time.
62
71
  * Only called when compiler emits a BundlePlan - bundler performs no inference.
63
72
  */
64
- import { bundlePageScript, type BundlePlan, generateRouteDefinition } from '@zenithbuild/compiler'
73
+ import type { BundlePlan } from '@zenithbuild/compiler'
65
74
 
66
75
  export async function dev(options: DevOptions = {}): Promise<void> {
67
76
  const project = requireProject()
@@ -153,88 +162,39 @@ export async function dev(options: DevOptions = {}): Promise<void> {
153
162
  const layoutsDir = path.join(pagesDir, '../layouts')
154
163
  const componentsDir = path.join(pagesDir, '../components')
155
164
  const layouts = discoverLayouts(layoutsDir)
156
- const source = fs.readFileSync(pagePath, 'utf-8')
157
-
158
- let processedSource = source
159
- let layoutToUse = layouts.get('DefaultLayout')
160
-
161
- if (layoutToUse) processedSource = processLayout(source, layoutToUse)
162
-
163
- const result = await compileZenSource(processedSource, pagePath, {
164
- componentsDir: fs.existsSync(componentsDir) ? componentsDir : undefined
165
- })
166
- if (!result.finalized) throw new Error('Compilation failed')
167
-
168
- const routeDef = generateRouteDefinition(pagePath, pagesDir)
169
-
170
-
171
165
 
172
- // Safely strip imports from the top of the script
173
- // This relies on the fact that duplicate imports (from Rust codegen)
174
- // appear at the beginning of result.finalized.js
175
- let jsLines = result.finalized.js.split('\n')
176
-
177
- // Remove lines from top that are imports, whitespace, or comments
178
- while (jsLines.length > 0 && jsLines[0] !== undefined) {
179
- const line = jsLines[0].trim()
180
- if (
181
- line.startsWith('import ') ||
182
- line === '' ||
183
- line.startsWith('//') ||
184
- line.startsWith('/*') ||
185
- line.startsWith('*')
186
- ) {
187
- jsLines.shift()
188
- } else {
189
- break
166
+ // Manual component discovery since compiler is pure
167
+ const components = new Map<string, any>([...layouts])
168
+ if (fs.existsSync(componentsDir)) {
169
+ const discovered = discoverComponents(componentsDir)
170
+ for (const [k, v] of discovered) {
171
+ components.set(k, v)
190
172
  }
191
173
  }
192
174
 
193
- let jsWithoutImports = jsLines.join('\n')
194
-
195
- // PATCH: Fix unquoted keys with dashes (Rust codegen bug in jsx_lowerer)
196
- // e.g. stroke-width: "1.5" -> "stroke-width": "1.5"
197
- // We only apply this to the JS portions (Script and Expressions)
198
- // to avoid corrupting the Styles section.
199
- const stylesMarker = '// 6. Styles injection'
200
- const parts = jsWithoutImports.split(stylesMarker)
201
-
202
- if (parts.length > 1) {
203
- // Apply patch only to the JS part
204
- parts[0] = parts[0]!.replace(
205
- /(^|[{,])\s*([a-zA-Z][a-zA-Z0-9-]*-[a-zA-Z0-9-]*)\s*:/gm,
206
- '$1"$2":'
207
- )
208
- jsWithoutImports = parts.join(stylesMarker)
209
- } else {
210
- // Fallback if marker not found
211
- jsWithoutImports = jsWithoutImports.replace(
212
- /(^|[{,])\s*([a-zA-Z][a-zA-Z0-9-]*-[a-zA-Z0-9-]*)\s*:/gm,
213
- '$1"$2":'
214
- )
215
- }
175
+ const source = fs.readFileSync(pagePath, 'utf-8')
216
176
 
217
- // Combine: structured imports first, then cleaned script body
218
- const fullScript = (result.finalized.npmImports || '') + '\n\n' + jsWithoutImports
177
+ const result = await compile(source, pagePath, {
178
+ components: components
179
+ })
180
+ if (!result.finalized || !result.finalized.manifest) throw new Error('Compilation failed')
219
181
 
220
182
 
183
+ const routeDef = generateRouteDefinition(pagePath, pagesDir)
221
184
 
222
- // Bundle ONLY if compiler emitted a BundlePlan (no inference)
223
- let bundledScript = fullScript
224
- if (result.finalized.bundlePlan) {
225
- // Compiler decided bundling is needed - pass plan with proper resolve roots
226
- const plan: BundlePlan = {
227
- ...result.finalized.bundlePlan,
228
- entry: fullScript,
229
- resolveRoots: [path.join(rootDir, 'node_modules'), 'node_modules']
230
- }
231
- bundledScript = await bundlePageScript(plan)
185
+ // Use the new bundler to generate the runtime + author script
186
+ // This replaces all the manual regex patching and string concatenation
187
+ const manifest: ZenManifest = {
188
+ routes: [routeDef],
189
+ layouts: {}, // Dev server handles layouts dynamically
190
+ components: {}
232
191
  }
192
+ const { code } = generateRuntime(manifest, true)
233
193
 
234
194
  return {
235
195
  html: result.finalized.html,
236
- script: bundledScript,
237
- styles: result.finalized.styles,
196
+ script: code,
197
+ styles: [], // Styles are now injected via the script
238
198
  route: routeDef.path,
239
199
  lastModified: Date.now()
240
200
  }
@@ -245,22 +205,15 @@ export async function dev(options: DevOptions = {}): Promise<void> {
245
205
  }
246
206
 
247
207
  /**
248
- * Generate dev HTML with plugin data envelope
249
- *
250
- * CLI collects payloads from plugins via 'cli:runtime:collect' hook.
251
- * It serializes blindly - never inspecting what's inside.
208
+ * Generate dev HTML
252
209
  */
253
210
  async function generateDevHTML(page: CompiledPage): Promise<string> {
254
- // Single neutral injection point
255
- const runtimeTag = `<script src="/runtime.js"></script>`
256
211
  const scriptTag = `<script type="module">\n${page.script}\n</script>`
257
- const allScripts = `${runtimeTag}\n${scriptTag}`
258
212
 
259
213
  let html = page.html.includes('</body>')
260
- ? page.html.replace('</body>', `${allScripts}\n</body>`)
261
- : `${page.html}\n${allScripts}`
214
+ ? page.html.replace('</body>', `${scriptTag}\n</body>`)
215
+ : `${page.html}\n${scriptTag}`
262
216
 
263
- // Ensure DOCTYPE is present to prevent Quirks Mode (critical for SVG namespace)
264
217
  if (!html.trimStart().toLowerCase().startsWith('<!doctype')) {
265
218
  html = `<!DOCTYPE html>\n${html}`
266
219
  }
@@ -10,6 +10,7 @@ import { build, type BuildOptions } from './build'
10
10
  import { add, type AddOptions } from './add'
11
11
  import { remove } from './remove'
12
12
  import { create } from './create'
13
+ import { lint, type LintOptions } from './lint'
13
14
  import * as logger from '../utils/logger'
14
15
 
15
16
  export interface Command {
@@ -84,6 +85,17 @@ export const commands: Command[] = [
84
85
  }
85
86
  await remove(pluginName)
86
87
  }
88
+ },
89
+ {
90
+ name: 'lint',
91
+ description: 'Audit project for Zenith Contract compliance',
92
+ usage: 'zenith lint [--fix]',
93
+ async run(args, options) {
94
+ const opts: LintOptions = {}
95
+ if (options.fix) opts.fix = true
96
+ if (options.incremental) opts.incremental = true
97
+ await lint(args, opts)
98
+ }
87
99
  }
88
100
  ]
89
101
 
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @zenithbuild/cli - Lint Command
3
+ *
4
+ * Scans for .zen files and enforces the Zenith Contract.
5
+ */
6
+
7
+ import { glob } from 'glob'
8
+ import { join } from 'path'
9
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs'
10
+ import { compile } from '../../../zenith-compiler/dist/index.js'
11
+ import * as logger from '../utils/logger'
12
+ import picocolors from 'picocolors'
13
+ import { createHash } from 'crypto'
14
+ import { dirname } from 'path'
15
+
16
+ export interface LintOptions {
17
+ fix?: boolean
18
+ incremental?: boolean
19
+ }
20
+
21
+ interface LintCache {
22
+ files: Record<string, string> // path -> hash
23
+ }
24
+
25
+ function findProjectRoot(startDir: string): string {
26
+ let current = startDir
27
+ while (current !== dirname(current)) {
28
+ if (existsSync(join(current, 'package.json')) || existsSync(join(current, '.zenith'))) {
29
+ return current
30
+ }
31
+ current = dirname(current)
32
+ }
33
+ return startDir
34
+ }
35
+
36
+ export async function lint(fileArgs: string[] = [], options: LintOptions = {}): Promise<void> {
37
+ logger.header('Zenith Safety Audit')
38
+
39
+ const isIncremental = options.incremental || false
40
+
41
+ // Find files
42
+ // Filter out flags that might have been passed in args
43
+ const filesToScan = fileArgs.filter(a => !a.startsWith('--'))
44
+ let files: string[] = []
45
+
46
+ if (filesToScan.length > 0) {
47
+ files = filesToScan
48
+ } else {
49
+ files = await glob('**/*.zen', {
50
+ ignore: ['**/node_modules/**', '**/dist/**', '.git/**', '**/test/**']
51
+ })
52
+ }
53
+
54
+ if (files.length === 0) {
55
+ logger.info('No .zen files found to audit.')
56
+ return
57
+ }
58
+
59
+ // Load lint cache - Optimized for monorepos
60
+ const root = findProjectRoot(process.cwd())
61
+ const cacheDir = join(root, '.zenith/cache')
62
+ const cachePath = join(cacheDir, 'lint-cache.json')
63
+ let lintCache: LintCache = { files: {} }
64
+
65
+ if (isIncremental && existsSync(cachePath)) {
66
+ try {
67
+ lintCache = JSON.parse(readFileSync(cachePath, 'utf-8'))
68
+ } catch (e) {
69
+ logger.warn('Failed to load lint cache, starting fresh.')
70
+ }
71
+ }
72
+
73
+ logger.info(`Auditing ${files.length} components${isIncremental ? ' (incremental)' : ''}...\n`)
74
+
75
+ let errorCount = 0
76
+ let fileCount = 0
77
+ let skippedCount = 0
78
+ const newCache: LintCache = { files: { ...lintCache.files } }
79
+
80
+ for (const file of files) {
81
+ fileCount++
82
+ const source = readFileSync(file, 'utf-8')
83
+ const hash = createHash('sha256').update(source).digest('hex')
84
+
85
+ if (isIncremental && lintCache.files[file] === hash) {
86
+ skippedCount++
87
+ continue
88
+ }
89
+
90
+ try {
91
+ // Run compilation with cache enabled for components
92
+ process.env.ZENITH_CACHE = '1'
93
+ await compile(source, file)
94
+ newCache.files[file] = hash
95
+ } catch (error: any) {
96
+ errorCount++
97
+ // Clear cache for this file on error
98
+ delete newCache.files[file]
99
+
100
+ if (error.name === 'InvariantError' || error.name === 'CompilerError') {
101
+ console.log(picocolors.red(picocolors.bold(`\n✖ ${file}:${error.line}:${error.column}`)))
102
+ console.log(picocolors.red(` [${error.code || 'ERROR'}] ${error.message}`))
103
+
104
+ if (error.guarantee) {
105
+ console.log(picocolors.yellow(` Guarantee: ${error.guarantee}`))
106
+ }
107
+
108
+ if (error.context) {
109
+ console.log(picocolors.dim(` Context: ${error.context}`))
110
+ }
111
+
112
+ if (error.hints && error.hints.length > 0) {
113
+ console.log(picocolors.cyan(' Hints:'))
114
+ for (const hint of error.hints) {
115
+ console.log(picocolors.cyan(` - ${hint}`))
116
+ }
117
+ }
118
+ } else {
119
+ console.log(picocolors.red(`\n✖ ${file}: Unexpected error`))
120
+ console.log(picocolors.red(` ${error.message}`))
121
+ }
122
+ }
123
+ }
124
+
125
+ // Save updated cache
126
+ if (isIncremental) {
127
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true })
128
+ writeFileSync(cachePath, JSON.stringify(newCache, null, 2))
129
+ }
130
+
131
+ console.log('')
132
+
133
+ if (errorCount > 0) {
134
+ logger.error(`Audit failed. Found ${errorCount} contract violations in ${fileCount} files.`)
135
+ process.exit(1)
136
+ } else {
137
+ const skipMsg = skippedCount > 0 ? ` (${skippedCount} unchanged files skipped)` : ''
138
+ logger.success(`Audit passed. ${fileCount} files checked${skipMsg}, 0 violations found.`)
139
+ }
140
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Component Discovery
3
+ *
4
+ * Discovers and catalogs components in a Zenith project using standard
5
+ * file system walking and the unified native "syscall" for metadata.
6
+ */
7
+
8
+ import * as fs from 'fs'
9
+ import * as path from 'path'
10
+ import { parseZenFile, type TemplateNode, type ExpressionIR } from '@zenithbuild/compiler'
11
+
12
+ export interface SlotDefinition {
13
+ name: string | null // null = default slot, string = named slot
14
+ location: {
15
+ line: number
16
+ column: number
17
+ }
18
+ }
19
+
20
+ export interface ComponentMetadata {
21
+ name: string // Component name (e.g., "Card", "Button")
22
+ path: string // Absolute path to .zen file
23
+ template: string // Raw template HTML
24
+ nodes: TemplateNode[] // Parsed template nodes
25
+ expressions: ExpressionIR[] // Component-level expressions
26
+ slots: SlotDefinition[]
27
+ props: string[] // Declared props
28
+ states: Record<string, string> // Declared state (name -> initializer)
29
+ styles: string[] // Raw CSS from <style> blocks
30
+ script: string | null // Raw script content for bundling
31
+ scriptAttributes: Record<string, string> | null // Script attributes (setup, lang)
32
+ hasScript: boolean
33
+ hasStyles: boolean
34
+ }
35
+
36
+ /**
37
+ * Discover all components in a directory recursively
38
+ */
39
+ export function discoverComponents(baseDir: string): Map<string, ComponentMetadata> {
40
+ const components = new Map<string, ComponentMetadata>()
41
+
42
+ if (!fs.existsSync(baseDir)) return components;
43
+
44
+ const walk = (dir: string) => {
45
+ const files = fs.readdirSync(dir);
46
+ for (const file of files) {
47
+ const fullPath = path.join(dir, file);
48
+ if (fs.statSync(fullPath).isDirectory()) {
49
+ walk(fullPath);
50
+ } else if (file.endsWith('.zen')) {
51
+ const name = path.basename(file, '.zen');
52
+ try {
53
+ // Call the "One True Bridge" in metadata mode
54
+ const ir = parseZenFile(fullPath, undefined, { mode: 'metadata' });
55
+
56
+ // Map IR to ComponentMetadata format
57
+ components.set(name, {
58
+ name,
59
+ path: fullPath,
60
+ template: ir.template.raw,
61
+ nodes: ir.template.nodes,
62
+ expressions: ir.template.expressions,
63
+ slots: [], // Native bridge needs to return slot info in IR if used
64
+ props: ir.props || [],
65
+ states: ir.script?.states || {},
66
+ styles: ir.styles?.map((s: any) => s.raw) || [],
67
+ script: ir.script?.raw || null,
68
+ scriptAttributes: ir.script?.attributes || null,
69
+ hasScript: !!ir.script,
70
+ hasStyles: ir.styles?.length > 0
71
+ });
72
+ } catch (e) {
73
+ console.error(`[Zenith Discovery] Failed to parse component ${file}:`, e);
74
+ }
75
+ }
76
+ }
77
+ };
78
+
79
+ walk(baseDir);
80
+ return components;
81
+ }
82
+
83
+ /**
84
+ * Universal Zenith Component Tag Rule: PascalCase
85
+ */
86
+ export function isComponentTag(tagName: string): boolean {
87
+ return tagName.length > 0 && tagName[0] === tagName[0]?.toUpperCase()
88
+ }
89
+
90
+ /**
91
+ * Get component metadata by name
92
+ */
93
+ export function getComponent(
94
+ components: Map<string, ComponentMetadata>,
95
+ name: string
96
+ ): ComponentMetadata | undefined {
97
+ return components.get(name)
98
+ }
@@ -0,0 +1,50 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import { parseZenFile } from '@zenithbuild/compiler'
4
+
5
+ export interface LayoutMetadata {
6
+ name: string
7
+ filePath: string
8
+ props: string[]
9
+ states: Map<string, any>
10
+ html: string
11
+ scripts: string[]
12
+ styles: string[]
13
+ }
14
+
15
+ /**
16
+ * Discover layouts in a directory using standard file system walking
17
+ * and the unified native bridge for metadata.
18
+ */
19
+ export function discoverLayouts(layoutsDir: string): Map<string, LayoutMetadata> {
20
+ const layouts = new Map<string, LayoutMetadata>()
21
+
22
+ if (!fs.existsSync(layoutsDir)) return layouts
23
+
24
+ const files = fs.readdirSync(layoutsDir)
25
+ for (const file of files) {
26
+ if (file.endsWith('.zen')) {
27
+ const fullPath = path.join(layoutsDir, file)
28
+ const name = path.basename(file, '.zen')
29
+
30
+ try {
31
+ // Call the "One True Bridge" in metadata mode
32
+ const ir = parseZenFile(fullPath, undefined, { mode: 'metadata' })
33
+
34
+ layouts.set(name, {
35
+ name,
36
+ filePath: fullPath,
37
+ props: ir.props || [],
38
+ states: new Map(),
39
+ html: ir.template.raw,
40
+ scripts: ir.script ? [ir.script.content] : [],
41
+ styles: ir.styles?.map((s: any) => s.raw) || []
42
+ })
43
+ } catch (e) {
44
+ console.error(`[Zenith Layout Discovery] Failed to parse layout ${file}:`, e)
45
+ }
46
+ }
47
+ }
48
+
49
+ return layouts
50
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Zenith Development Server
3
+ *
4
+ * SPA-compatible server that:
5
+ * - Serves static assets directly (js, css, ico, images)
6
+ * - Serves index.html for all other routes (SPA fallback)
7
+ *
8
+ * This enables client-side routing to work on:
9
+ * - Direct URL entry
10
+ * - Hard refresh
11
+ * - Back/forward navigation
12
+ */
13
+
14
+ import { serve } from "bun"
15
+ import path from "path"
16
+
17
+ const distDir = path.resolve(process.cwd(), "dist")
18
+
19
+ // File extensions that should be served as static assets
20
+ const STATIC_EXTENSIONS = new Set([
21
+ ".js",
22
+ ".css",
23
+ ".ico",
24
+ ".png",
25
+ ".jpg",
26
+ ".jpeg",
27
+ ".gif",
28
+ ".svg",
29
+ ".webp",
30
+ ".woff",
31
+ ".woff2",
32
+ ".ttf",
33
+ ".eot",
34
+ ".json",
35
+ ".map"
36
+ ])
37
+
38
+ serve({
39
+ port: 3000,
40
+
41
+ async fetch(req) {
42
+ const url = new URL(req.url)
43
+ const pathname = url.pathname
44
+
45
+ // Get file extension
46
+ const ext = path.extname(pathname).toLowerCase()
47
+
48
+ // Check if this is a static asset request
49
+ if (STATIC_EXTENSIONS.has(ext)) {
50
+ const filePath = path.join(distDir, pathname)
51
+ const file = Bun.file(filePath)
52
+
53
+ // Check if file exists
54
+ if (await file.exists()) {
55
+ return new Response(file)
56
+ }
57
+
58
+ // Static file not found
59
+ return new Response("Not found", { status: 404 })
60
+ }
61
+
62
+ // For all other routes, serve index.html (SPA fallback)
63
+ const indexPath = path.join(distDir, "index.html")
64
+ const indexFile = Bun.file(indexPath)
65
+
66
+ if (await indexFile.exists()) {
67
+ return new Response(indexFile, {
68
+ headers: {
69
+ "Content-Type": "text/html; charset=utf-8"
70
+ }
71
+ })
72
+ }
73
+
74
+ // No index.html found - likely need to run build first
75
+ return new Response(
76
+ `<html>
77
+ <head><title>Zenith - Build Required</title></head>
78
+ <body style="font-family: system-ui; padding: 2rem; text-align: center;">
79
+ <h1>Build Required</h1>
80
+ <p>Run <code>zenith build</code> first to compile the pages.</p>
81
+ </body>
82
+ </html>`,
83
+ {
84
+ status: 500,
85
+ headers: { "Content-Type": "text/html; charset=utf-8" }
86
+ }
87
+ )
88
+ }
89
+ })
90
+
91
+ console.log("🚀 Zenith dev server running at http://localhost:3000")
92
+ console.log(" SPA mode: All routes serve index.html")