@zenithbuild/core 0.6.3 → 1.1.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.
@@ -1,3 +1,23 @@
1
+ /**
2
+ * @zenith/cli - Dev Command
3
+ *
4
+ * Development server with HMR support.
5
+ *
6
+ * ═══════════════════════════════════════════════════════════════════════════════
7
+ * CLI HARDENING: BLIND ORCHESTRATOR PATTERN
8
+ * ═══════════════════════════════════════════════════════════════════════════════
9
+ *
10
+ * This file follows the CLI Hardening Plan:
11
+ * - NO plugin-specific branching (no `if (hasContentPlugin)`)
12
+ * - NO semantic helpers (no `getContentData()`)
13
+ * - NO plugin type imports or casts
14
+ * - ONLY opaque data forwarding via hooks
15
+ *
16
+ * The CLI dispatches lifecycle hooks and collects payloads.
17
+ * It never understands what the data means.
18
+ * ═══════════════════════════════════════════════════════════════════════════════
19
+ */
20
+
1
21
  import path from 'path'
2
22
  import fs from 'fs'
3
23
  import { serve, type ServerWebSocket } from 'bun'
@@ -7,13 +27,19 @@ import * as brand from '../utils/branding'
7
27
  import { compileZenSource } from '../../compiler/index'
8
28
  import { discoverLayouts } from '../../compiler/discovery/layouts'
9
29
  import { processLayout } from '../../compiler/transform/layoutProcessor'
10
- import { generateRouteDefinition } from '../../router/manifest'
30
+ import { generateRouteDefinition } from '@zenithbuild/router'
11
31
  import { generateBundleJS } from '../../runtime/bundle-generator'
12
- import { loadContent } from '../utils/content'
13
32
  import { loadZenithConfig } from '../../core/config/loader'
14
- import { PluginRegistry, createPluginContext } from '../../core/plugins/registry'
15
- import type { ContentItem } from '../../core/config/types'
33
+ import { PluginRegistry, createPluginContext, getPluginDataByNamespace } from '../../core/plugins/registry'
16
34
  import { compileCssAsync, resolveGlobalsCss } from '../../compiler/css'
35
+ import {
36
+ createBridgeAPI,
37
+ runPluginHooks,
38
+ collectHookReturns,
39
+ buildRuntimeEnvelope,
40
+ clearHooks,
41
+ type HookContext
42
+ } from '../../core/plugins/bridge'
17
43
 
18
44
  export interface DevOptions {
19
45
  port?: number
@@ -83,39 +109,48 @@ export async function dev(options: DevOptions = {}): Promise<void> {
83
109
  const port = options.port || parseInt(process.env.PORT || '3000', 10)
84
110
  const pagesDir = project.pagesDir
85
111
  const rootDir = project.root
86
- const contentDir = path.join(rootDir, 'content')
87
112
 
88
113
  // Load zenith.config.ts if present
89
114
  const config = await loadZenithConfig(rootDir)
90
115
  const registry = new PluginRegistry()
116
+ const bridgeAPI = createBridgeAPI()
117
+
118
+ // Clear any previously registered hooks (important for restarts)
119
+ clearHooks()
91
120
 
92
121
  console.log('[Zenith] Config plugins:', config.plugins?.length ?? 0)
93
122
 
94
- // Register plugins from config
123
+ // ============================================
124
+ // Plugin Registration (Unconditional)
125
+ // ============================================
126
+ // CLI registers ALL plugins without checking which ones exist.
127
+ // Each plugin decides what hooks to register.
95
128
  for (const plugin of config.plugins || []) {
96
129
  console.log('[Zenith] Registering plugin:', plugin.name)
97
130
  registry.register(plugin)
131
+
132
+ // Let plugin register its CLI hooks (if it wants to)
133
+ // CLI does NOT check what the plugin is - it just offers the API
134
+ if (plugin.registerCLI) {
135
+ plugin.registerCLI(bridgeAPI)
136
+ }
98
137
  }
99
138
 
100
- // Initialize content data
101
- let contentData: Record<string, ContentItem[]> = {}
102
-
103
- // Initialize plugins with context
104
- const hasContentPlugin = registry.has('zenith-content')
105
- console.log('[Zenith] Has zenith-content plugin:', hasContentPlugin)
106
-
107
- if (hasContentPlugin) {
108
- await registry.initAll(createPluginContext(rootDir, (data) => {
109
- console.log('[Zenith] Content plugin set data, collections:', Object.keys(data))
110
- contentData = data
111
- }))
112
- } else {
113
- // Fallback to legacy content loading if no content plugin configured
114
- console.log('[Zenith] Using legacy content loading from:', contentDir)
115
- contentData = loadContent(contentDir)
139
+ // ============================================
140
+ // Plugin Initialization (Unconditional)
141
+ // ============================================
142
+ // Initialize ALL plugins unconditionally.
143
+ // If no plugins, this is a no-op. CLI doesn't branch on plugin presence.
144
+ await registry.initAll(createPluginContext(rootDir))
145
+
146
+ // Create hook context - CLI provides this but NEVER uses getPluginData itself
147
+ const hookCtx: HookContext = {
148
+ projectRoot: rootDir,
149
+ getPluginData: getPluginDataByNamespace
116
150
  }
117
151
 
118
- console.log('[Zenith] Content collections loaded:', Object.keys(contentData))
152
+ // Dispatch lifecycle hook - plugins decide if they care
153
+ await runPluginHooks('cli:dev:start', hookCtx)
119
154
 
120
155
  // ============================================
121
156
  // CSS Compilation (Compiler-Owned)
@@ -189,10 +224,50 @@ export async function dev(options: DevOptions = {}): Promise<void> {
189
224
  }
190
225
  }
191
226
 
192
- // Set up file watching for HMR
227
+ /**
228
+ * Generate dev HTML with plugin data envelope
229
+ *
230
+ * CLI collects payloads from plugins via 'cli:runtime:collect' hook.
231
+ * It serializes blindly - never inspecting what's inside.
232
+ */
233
+ async function generateDevHTML(page: CompiledPage): Promise<string> {
234
+ // Collect runtime payloads from ALL plugins
235
+ // CLI doesn't know which plugins will respond - it just collects
236
+ const payloads = await collectHookReturns('cli:runtime:collect', hookCtx)
237
+
238
+ // Build envelope - CLI doesn't know what's inside
239
+ const envelope = buildRuntimeEnvelope(payloads)
240
+
241
+ // Escape </script> sequences in JSON to prevent breaking the script tag
242
+ const envelopeJson = JSON.stringify(envelope).replace(/<\//g, '<\\/')
243
+
244
+ // Single neutral injection point - NOT plugin-specific
245
+ const runtimeTag = `<script src="/runtime.js"></script>`
246
+ const pluginDataTag = `<script>window.__ZENITH_PLUGIN_DATA__ = ${envelopeJson};</script>`
247
+ const scriptTag = `<script type="module">\n${page.script}\n</script>`
248
+ const allScripts = `${runtimeTag}\n${pluginDataTag}\n${scriptTag}`
249
+
250
+ return page.html.includes('</body>')
251
+ ? page.html.replace('</body>', `${allScripts}\n</body>`)
252
+ : `${page.html}\n${allScripts}`
253
+ }
254
+
255
+ // ============================================
256
+ // File Watcher (Plugin-Agnostic)
257
+ // ============================================
258
+ // CLI watches files but delegates decisions to plugins via hooks.
259
+ // No branching on file types that are "content" vs "not content".
193
260
  const watcher = fs.watch(path.join(pagesDir, '..'), { recursive: true }, async (event, filename) => {
194
261
  if (!filename) return
195
262
 
263
+ // Dispatch file change hook to ALL plugins
264
+ // Each plugin decides if it cares about this file
265
+ await runPluginHooks('cli:dev:file-change', {
266
+ ...hookCtx,
267
+ filename,
268
+ event
269
+ })
270
+
196
271
  if (filename.endsWith('.zen')) {
197
272
  logger.hmr('Page', filename)
198
273
 
@@ -223,16 +298,13 @@ export async function dev(options: DevOptions = {}): Promise<void> {
223
298
  for (const client of clients) {
224
299
  client.send(JSON.stringify({ type: 'style-update', url: '/assets/styles.css' }))
225
300
  }
226
- } else if (filename.startsWith('content') || filename.includes('zenith-docs')) {
227
- logger.hmr('Content', filename)
228
- // Reinitialize content plugin to reload data
229
- if (registry.has('zenith-content')) {
230
- registry.initAll(createPluginContext(rootDir, (data) => {
231
- contentData = data
232
- }))
233
- } else {
234
- contentData = loadContent(contentDir)
235
- }
301
+ } else {
302
+ // For all other file changes, re-initialize plugins unconditionally
303
+ // Plugins decide internally whether they need to reload data
304
+ // CLI does NOT branch on "is this a content file"
305
+ await registry.initAll(createPluginContext(rootDir))
306
+
307
+ // Broadcast reload for any non-code file changes
236
308
  for (const client of clients) {
237
309
  client.send(JSON.stringify({ type: 'reload' }))
238
310
  }
@@ -305,7 +377,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
305
377
 
306
378
  if (cached) {
307
379
  const renderStart = performance.now()
308
- const html = generateDevHTML(cached, contentData)
380
+ const html = await generateDevHTML(cached)
309
381
  const renderEnd = performance.now()
310
382
 
311
383
  const totalTime = Math.round(performance.now() - startTime)
@@ -373,16 +445,3 @@ function findPageForRoute(route: string, pagesDir: string): string | null {
373
445
 
374
446
  return null
375
447
  }
376
-
377
- function generateDevHTML(page: CompiledPage, contentData: any = {}): string {
378
- const runtimeTag = `<script src="/runtime.js"></script>`
379
- // Escape </script> sequences in JSON content to prevent breaking the script tag
380
- const contentJson = JSON.stringify(contentData).replace(/<\//g, '<\\/')
381
- const contentTag = `<script>window.__ZENITH_CONTENT__ = ${contentJson};</script>`
382
- // Use type="module" to support ES6 imports from npm packages
383
- const scriptTag = `<script type="module">\n${page.script}\n</script>`
384
- const allScripts = `${runtimeTag}\n${contentTag}\n${scriptTag}`
385
- return page.html.includes('</body>')
386
- ? page.html.replace('</body>', `${allScripts}\n</body>`)
387
- : `${page.html}\n${allScripts}`
388
- }
@@ -1,14 +1,33 @@
1
1
  /**
2
2
  * Component Discovery
3
3
  *
4
- * Discovers and catalogs components in a Zenith project
5
- * Similar to layout discovery but for reusable components
4
+ * Discovers and catalogs components in a Zenith project.
5
+ * Components are auto-imported based on filename:
6
+ *
7
+ * Auto-Import Rules:
8
+ * - Component name = filename (without .zen extension)
9
+ * - Subdirectories are for organization, not namespacing
10
+ * - Name collisions produce compile-time errors with clear messages
11
+ *
12
+ * Examples:
13
+ * components/Header.zen → <Header />
14
+ * components/sections/HeroSection.zen → <HeroSection />
15
+ * components/ui/buttons/Primary.zen → <Primary />
16
+ *
17
+ * If you have name collisions (same filename in different directories),
18
+ * you must rename one of the components.
19
+ *
20
+ * Requirements:
21
+ * - Auto-import resolution is deterministic and compile-time only
22
+ * - Name collisions produce compile-time errors with clear messages
23
+ * - No runtime component registration or global singleton registries
6
24
  */
7
25
 
8
26
  import * as fs from 'fs'
9
27
  import * as path from 'path'
10
28
  import { parseZenFile } from '../parse/parseZenFile'
11
- import type { TemplateNode } from '../ir/types'
29
+ import { CompilerError } from '../errors/compilerError'
30
+ import type { TemplateNode, ExpressionIR } from '../ir/types'
12
31
 
13
32
  export interface SlotDefinition {
14
33
  name: string | null // null = default slot, string = named slot
@@ -19,10 +38,12 @@ export interface SlotDefinition {
19
38
  }
20
39
 
21
40
  export interface ComponentMetadata {
22
- name: string // Component name (e.g., "Card", "Button")
41
+ name: string // Component name (e.g., "Card", "HeroSection")
23
42
  path: string // Absolute path to .zen file
43
+ relativePath: string // Relative path from components directory
24
44
  template: string // Raw template HTML
25
45
  nodes: TemplateNode[] // Parsed template nodes
46
+ expressions: ExpressionIR[] // Expressions referenced by nodes
26
47
  slots: SlotDefinition[]
27
48
  props: string[] // Declared props
28
49
  styles: string[] // Raw CSS from <style> blocks
@@ -33,12 +54,18 @@ export interface ComponentMetadata {
33
54
  }
34
55
 
35
56
  /**
36
- * Discover all components in a directory
57
+ * Discover all components in a directory with auto-import naming
58
+ *
59
+ * Components are named by their filename (without .zen extension).
60
+ * Subdirectories are for organization only and do not affect the component name.
61
+ *
37
62
  * @param baseDir - Base directory to search (e.g., src/components)
38
63
  * @returns Map of component name to metadata
64
+ * @throws CompilerError on name collisions
39
65
  */
40
66
  export function discoverComponents(baseDir: string): Map<string, ComponentMetadata> {
41
67
  const components = new Map<string, ComponentMetadata>()
68
+ const collisions = new Map<string, string[]>() // name → [relative paths]
42
69
 
43
70
  // Check if components directory exists
44
71
  if (!fs.existsSync(baseDir)) {
@@ -50,15 +77,43 @@ export function discoverComponents(baseDir: string): Map<string, ComponentMetada
50
77
 
51
78
  for (const filePath of zenFiles) {
52
79
  try {
53
- const metadata = parseComponentFile(filePath)
80
+ const metadata = parseComponentFile(filePath, baseDir)
54
81
  if (metadata) {
55
- components.set(metadata.name, metadata)
82
+ // Check for collision
83
+ if (components.has(metadata.name)) {
84
+ const existing = components.get(metadata.name)!
85
+ if (!collisions.has(metadata.name)) {
86
+ collisions.set(metadata.name, [existing.relativePath])
87
+ }
88
+ collisions.get(metadata.name)!.push(metadata.relativePath)
89
+ } else {
90
+ components.set(metadata.name, metadata)
91
+ }
56
92
  }
57
93
  } catch (error: any) {
58
94
  console.warn(`[Zenith] Failed to parse component ${filePath}: ${error.message}`)
59
95
  }
60
96
  }
61
97
 
98
+ // Report all collisions as a single error
99
+ if (collisions.size > 0) {
100
+ const collisionMessages = Array.from(collisions.entries())
101
+ .map(([name, paths]) => {
102
+ const pathList = paths.map(p => ` - ${p}`).join('\n')
103
+ return `Component name "${name}" is used by multiple files:\n${pathList}`
104
+ })
105
+ .join('\n\n')
106
+
107
+ throw new CompilerError(
108
+ `Component name collision detected!\n\n${collisionMessages}\n\n` +
109
+ `Each component must have a unique filename.\n` +
110
+ `To fix: Rename one of the conflicting components to have a unique name.`,
111
+ baseDir,
112
+ 0,
113
+ 0
114
+ )
115
+ }
116
+
62
117
  return components
63
118
  }
64
119
 
@@ -89,13 +144,20 @@ function findZenFiles(dir: string): string[] {
89
144
 
90
145
  /**
91
146
  * Parse a component file and extract metadata
147
+ *
148
+ * Component name is derived from the filename (without .zen extension).
149
+ *
150
+ * @param filePath - Absolute path to the component file
151
+ * @param baseDir - Base directory for component discovery (used for relative path)
92
152
  */
93
- function parseComponentFile(filePath: string): ComponentMetadata | null {
153
+ function parseComponentFile(filePath: string, baseDir: string): ComponentMetadata | null {
94
154
  const ir = parseZenFile(filePath)
95
155
 
96
- // Extract component name from filename
97
- const basename = path.basename(filePath, '.zen')
98
- const componentName = basename
156
+ // Component name is just the filename (without .zen extension)
157
+ const componentName = path.basename(filePath, '.zen')
158
+
159
+ // Relative path for error messages and debugging
160
+ const relativePath = path.relative(baseDir, filePath)
99
161
 
100
162
  // Extract slots from template
101
163
  const slots = extractSlots(ir.template.nodes)
@@ -109,8 +171,10 @@ function parseComponentFile(filePath: string): ComponentMetadata | null {
109
171
  return {
110
172
  name: componentName,
111
173
  path: filePath,
174
+ relativePath,
112
175
  template: ir.template.raw,
113
176
  nodes: ir.template.nodes,
177
+ expressions: ir.template.expressions, // Store expressions for later merging
114
178
  slots,
115
179
  props,
116
180
  styles,
@@ -2,8 +2,11 @@
2
2
  * Compiled Template Output Types
3
3
  *
4
4
  * Phase 2: Transform IR → Static HTML + Runtime Bindings
5
+ * Phase 8: Extended with fragment binding types (loop, conditional, optional)
5
6
  */
6
7
 
8
+ import type { TemplateNode } from '../ir/types'
9
+
7
10
  export type CompiledTemplate = {
8
11
  html: string
9
12
  bindings: Binding[]
@@ -13,7 +16,7 @@ export type CompiledTemplate = {
13
16
 
14
17
  export type Binding = {
15
18
  id: string
16
- type: 'text' | 'attribute'
19
+ type: 'text' | 'attribute' | 'loop' | 'conditional' | 'optional'
17
20
  target: string // e.g., "data-zen-text" or "class" for attribute bindings
18
21
  expression: string // The original expression code
19
22
  location?: {
@@ -21,6 +24,17 @@ export type Binding = {
21
24
  column: number
22
25
  }
23
26
  loopContext?: LoopContext // Phase 7: Loop context for expressions inside map iterations
27
+ loopMeta?: LoopMeta // Phase 8: Metadata for loop bindings
28
+ }
29
+
30
+ /**
31
+ * Loop binding metadata
32
+ * Phase 8: Contains loop variable names and body template for runtime instantiation
33
+ */
34
+ export type LoopMeta = {
35
+ itemVar: string
36
+ indexVar?: string
37
+ bodyTemplate: TemplateNode[]
24
38
  }
25
39
 
26
40
  /**
@@ -548,6 +548,31 @@ function parseNode(
548
548
  return null
549
549
  }
550
550
 
551
+ /**
552
+ * Convert self-closing component tags to properly closed tags
553
+ *
554
+ * HTML5/parse5 treats `<ComponentName />` as an opening tag (the `/` is ignored),
555
+ * which causes following siblings to be incorrectly nested as children.
556
+ *
557
+ * This function converts `<ComponentName />` to `<ComponentName></ComponentName>`
558
+ * for tags that start with uppercase (Zenith components).
559
+ *
560
+ * Example:
561
+ * Input: `<Header /><Hero /><Footer />`
562
+ * Output: `<Header></Header><Hero></Hero><Footer></Footer>`
563
+ */
564
+ function convertSelfClosingComponents(html: string): string {
565
+ // Match self-closing tags that start with uppercase (component tags)
566
+ // Pattern: <ComponentName ... />
567
+ // Captures: ComponentName and any attributes
568
+ const selfClosingPattern = /<([A-Z][a-zA-Z0-9._-]*)([^>]*?)\/>/g
569
+
570
+ return html.replace(selfClosingPattern, (match, tagName, attributes) => {
571
+ // Convert to properly closed tag
572
+ return `<${tagName}${attributes}></${tagName}>`
573
+ })
574
+ }
575
+
551
576
  /**
552
577
  * Parse template from HTML string
553
578
  */
@@ -555,6 +580,10 @@ export function parseTemplate(html: string, filePath: string): TemplateIR {
555
580
  // Strip script and style blocks
556
581
  let templateHtml = stripBlocks(html)
557
582
 
583
+ // Convert self-closing component tags to properly closed tags
584
+ // This fixes the component stacking bug where siblings become nested children
585
+ templateHtml = convertSelfClosingComponents(templateHtml)
586
+
558
587
  // Normalize all expressions so parse5 can parse them safely
559
588
  const { normalized, expressions: normalizedExprs } = normalizeAllExpressions(templateHtml)
560
589
  templateHtml = normalized
@@ -259,10 +259,10 @@ export function generateExplicitExpressionWrapper(
259
259
  contextParts.push('state')
260
260
  }
261
261
 
262
- // Create merged context for 'with' statement
262
+ // Create merged context code
263
263
  const contextCode = contextParts.length > 0
264
- ? `const __ctx = Object.assign({}, ${contextParts.join(', ')});\n with (__ctx) {`
265
- : 'with (state) {'
264
+ ? `var __ctx = Object.assign({}, ${contextParts.join(', ')});`
265
+ : 'var __ctx = state || {};'
266
266
 
267
267
  // Escape the code for use in a single-line comment (replace newlines with spaces)
268
268
  const commentCode = code.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').substring(0, 100)
@@ -273,6 +273,13 @@ export function generateExplicitExpressionWrapper(
273
273
  // Transform JSX
274
274
  const transformedCode = transformExpressionJSX(code)
275
275
 
276
+ // Properly escape the transformed code for use inside a string
277
+ const escapedTransformedCode = transformedCode
278
+ .replace(/\\/g, '\\\\')
279
+ .replace(/'/g, "\\'")
280
+ .replace(/\n/g, '\\n')
281
+ .replace(/\r/g, '\\r')
282
+
276
283
  return `
277
284
  // Expression: ${commentCode}${code.length > 100 ? '...' : ''}
278
285
  // Dependencies: ${JSON.stringify({
@@ -281,16 +288,24 @@ export function generateExplicitExpressionWrapper(
281
288
  stores: dependencies.usesStores,
282
289
  state: dependencies.usesState
283
290
  })}
284
- const ${id} = (${paramList}) => {
285
- try {
286
- ${contextCode}
287
- return ${transformedCode};
291
+ const ${id} = (function() {
292
+ // Create the evaluator function once (with 'with' support in sloppy mode)
293
+ var evalFn = new Function('__ctx',
294
+ 'with (__ctx) { return (' + '${escapedTransformedCode}' + '); }'
295
+ );
296
+
297
+ return function(${paramList}) {
298
+ try {
299
+ // Merge window globals with context (for script-level variables)
300
+ var __baseCtx = Object.assign({}, window);
301
+ ${contextCode.replace('var __ctx', 'var __ctx').replace('= Object.assign({},', '= Object.assign(__baseCtx,')}
302
+ return evalFn(__ctx);
303
+ } catch (e) {
304
+ console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
305
+ return undefined;
288
306
  }
289
- } catch (e) {
290
- console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
291
- return undefined;
292
- }
293
- };`
307
+ };
308
+ })();`
294
309
  }
295
310
 
296
311
  /**
@@ -119,7 +119,10 @@ ${indent}}\n`
119
119
  const conditionId = `cond_${varCounter.count++}`
120
120
 
121
121
  let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
122
- code += `${indent}const ${conditionId}_result = (function() { with (state) { return ${condNode.condition}; } })();\n`
122
+ // Evaluate condition with state context using new Function (sloppy mode allows 'with')
123
+ const escapedCondition = condNode.condition.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
124
+ code += `${indent}const ${conditionId}_evalFn = new Function('state', 'with (state) { return (' + '${escapedCondition}' + '); }');\n`
125
+ code += `${indent}const ${conditionId}_result = (function() { try { return ${conditionId}_evalFn(state); } catch(e) { return false; } })();\n`
123
126
 
124
127
  // Generate consequent branch
125
128
  code += `${indent}if (${conditionId}_result) {\n`
@@ -149,7 +152,10 @@ ${indent}}\n`
149
152
  const conditionId = `opt_${varCounter.count++}`
150
153
 
151
154
  let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
152
- code += `${indent}const ${conditionId}_result = (function() { with (state) { return ${optNode.condition}; } })();\n`
155
+ // Evaluate condition with state context using new Function (sloppy mode allows 'with')
156
+ const escapedCondition = optNode.condition.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
157
+ code += `${indent}const ${conditionId}_evalFn = new Function('state', 'with (state) { return (' + '${escapedCondition}' + '); }');\n`
158
+ code += `${indent}const ${conditionId}_result = (function() { try { return ${conditionId}_evalFn(state); } catch(e) { return false; } })();\n`
153
159
  code += `${indent}if (${conditionId}_result) {\n`
154
160
 
155
161
  for (const child of optNode.fragment) {
@@ -171,7 +177,10 @@ ${indent}}\n`
171
177
  const loopId = `loop_${varCounter.count++}`
172
178
 
173
179
  let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
174
- code += `${indent}const ${loopId}_items = (function() { with (state) { return ${loopNode.source}; } })() || [];\n`
180
+ // Evaluate loop source with state context using new Function (sloppy mode allows 'with')
181
+ const escapedSource = loopNode.source.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
182
+ code += `${indent}const ${loopId}_evalFn = new Function('state', 'with (state) { return (' + '${escapedSource}' + '); }');\n`
183
+ code += `${indent}const ${loopId}_items = (function() { try { return ${loopId}_evalFn(state); } catch(e) { return []; } })() || [];\n`
175
184
 
176
185
  // Loop parameters
177
186
  const itemVar = loopNode.itemVar
@@ -115,6 +115,9 @@ function generateRuntimeBundle(parts: {
115
115
  // Extract function declarations from script code to register on window
116
116
  const functionRegistrations = extractFunctionRegistrations(parts.scriptCode)
117
117
 
118
+ // Extract const/let declarations from script code to register on window
119
+ const variableRegistrations = extractVariableRegistrations(parts.scriptCode)
120
+
118
121
  // Generate npm imports header (hoisted, deduplicated, deterministic)
119
122
  const npmImportsHeader = parts.npmImports.length > 0
120
123
  ? `// NPM Imports (hoisted from component scripts)\n${emitImports(parts.npmImports)}\n\n`
@@ -139,6 +142,8 @@ ${parts.scriptCode ? parts.scriptCode : ''}
139
142
 
140
143
  ${functionRegistrations}
141
144
 
145
+ ${variableRegistrations}
146
+
142
147
  ${parts.stateInitCode ? `// State initialization
143
148
  ${parts.stateInitCode}` : ''}
144
149
 
@@ -260,6 +265,37 @@ function extractFunctionRegistrations(scriptCode: string): string {
260
265
  return `// Register functions on window for event handlers\n${registrations}`
261
266
  }
262
267
 
268
+ /**
269
+ * Extract const/let declarations and generate window registration code
270
+ * This allows expressions evaluated via new Function() to access script-level variables
271
+ */
272
+ function extractVariableRegistrations(scriptCode: string): string {
273
+ if (!scriptCode) return ''
274
+
275
+ // Match const/let declarations: const name = ... or let name = ...
276
+ // Also match destructured: const { a, b } = ... but we only care about simple assignments
277
+ const varPattern = /(?:const|let)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g
278
+ const varNames: string[] = []
279
+ let match
280
+
281
+ while ((match = varPattern.exec(scriptCode)) !== null) {
282
+ if (match[1]) {
283
+ varNames.push(match[1])
284
+ }
285
+ }
286
+
287
+ if (varNames.length === 0) {
288
+ return ''
289
+ }
290
+
291
+ // Generate window registration for each variable
292
+ const registrations = varNames.map(name =>
293
+ ` if (typeof ${name} !== 'undefined') window.${name} = ${name};`
294
+ ).join('\n')
295
+
296
+ return `// Register script variables on window for expression access\n${registrations}`
297
+ }
298
+
263
299
  /**
264
300
  * Generate hydrate function that mounts the DOM with reactivity
265
301
  */
@@ -44,22 +44,41 @@ export function wrapExpression(
44
44
  const commentCode = code.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').substring(0, 100)
45
45
  const jsonEscapedCode = JSON.stringify(code)
46
46
 
47
+ // Properly escape the transformed code for use inside a string
48
+ const escapedTransformedCode = transformedCode
49
+ .replace(/\\/g, '\\\\')
50
+ .replace(/'/g, "\\'")
51
+ .replace(/\n/g, '\\n')
52
+ .replace(/\r/g, '\\r')
53
+
54
+ // Note: We cannot use `with (state)` in ES modules (strict mode)
55
+ // Instead, we use new Function() which runs in non-strict sloppy mode by default
56
+ // and allows 'with' statements. This is a workaround for strict mode limitations.
47
57
  return `
48
58
  // Expression: ${commentCode}${code.length > 100 ? '...' : ''}
49
- const ${id} = (state) => {
50
- try {
51
- // Expose zenith helpers for JSX and content
52
- const __zenith = window.__zenith || {};
53
- const zenCollection = __zenith.zenCollection || ((name) => ({ get: () => [] }));
54
-
55
- with (state) {
56
- return ${transformedCode};
59
+ const ${id} = (function() {
60
+ // Create the evaluator function once (with 'with' support in sloppy mode)
61
+ var evalFn = new Function('__ctx',
62
+ 'with (__ctx) { return (' + '${escapedTransformedCode}' + '); }'
63
+ );
64
+
65
+ return function(state) {
66
+ try {
67
+ var __zenith = window.__zenith || {};
68
+ var zenCollection = __zenith.zenCollection || function(name) { return { get: function() { return []; } }; };
69
+ var createZenOrder = __zenith.createZenOrder || function(sections) { return { sections: [], getSectionBySlug: function() { return null; }, getDocBySlug: function() { return null; } }; };
70
+
71
+ // Merge window globals (script variables) with state
72
+ // State takes precedence over window globals
73
+ var __ctx = Object.assign({}, window, { zenCollection: zenCollection, createZenOrder: createZenOrder }, state || {});
74
+
75
+ return evalFn(__ctx);
76
+ } catch (e) {
77
+ console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
78
+ return undefined;
57
79
  }
58
- } catch (e) {
59
- console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
60
- return undefined;
61
- }
62
- };`
80
+ };
81
+ })();`
63
82
  }
64
83
 
65
84
  /**