@zenithbuild/core 0.6.2 → 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.
@@ -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
  /**
@@ -66,18 +66,32 @@ export function wrapExpressionWithLoopContext(
66
66
  // or recognized as an existing h() call.
67
67
  const transformedCode = transformExpressionJSX(code)
68
68
 
69
+ // Properly escape the transformed code for use inside a string
70
+ const escapedTransformedCode = transformedCode
71
+ .replace(/\\/g, '\\\\')
72
+ .replace(/'/g, "\\'")
73
+ .replace(/\n/g, '\\n')
74
+ .replace(/\r/g, '\\r')
75
+
69
76
  return `
70
77
  // Expression with loop context: ${escapedCode}
71
78
  // Loop variables: ${loopContext.variables.join(', ')}
72
- const ${id} = (${argsStr}) => {
73
- try {
74
- ${contextObject}
75
- with (__ctx) {
76
- return ${transformedCode};
79
+ const ${id} = (function() {
80
+ // Create the evaluator function once (with 'with' support in sloppy mode)
81
+ var evalFn = new Function('__ctx',
82
+ 'with (__ctx) { return (' + '${escapedTransformedCode}' + '); }'
83
+ );
84
+
85
+ return function(${argsStr}) {
86
+ try {
87
+ // Merge window globals with context (for script-level variables)
88
+ var __baseCtx = Object.assign({}, window);
89
+ ${contextObject.replace('const __ctx', 'var __ctx').replace('= Object.assign({},', '= Object.assign(__baseCtx,')}
90
+ return evalFn(__ctx);
91
+ } catch (e) {
92
+ console.warn('[Zenith] Expression evaluation error for "${escapedCode}":', e);
93
+ return undefined;
77
94
  }
78
- } catch (e) {
79
- console.warn('[Zenith] Expression evaluation error for "${escapedCode}":', e);
80
- return undefined;
81
- }
82
- };`
95
+ };
96
+ })();`
83
97
  }
@@ -11,6 +11,18 @@
11
11
  * Hydrated pages reference the shared bundle.js and their page-specific JS.
12
12
  */
13
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
+
14
26
  import fs from "fs"
15
27
  import path from "path"
16
28
  import { compileZenSource } from "./index"
@@ -19,8 +31,16 @@ import { processLayout } from "./transform/layoutProcessor"
19
31
  import { discoverPages, generateRouteDefinition } from "@zenithbuild/router/manifest"
20
32
  import { analyzePageSource, getAnalysisSummary, getBuildOutputType, type PageAnalysis } from "./build-analyzer"
21
33
  import { generateBundleJS } from "../runtime/bundle-generator"
22
- import { loadContent } from "../cli/utils/content"
23
34
  import { compileCss, resolveGlobalsCss } from "./css"
35
+ import { loadZenithConfig } from "../core/config/loader"
36
+ import { PluginRegistry, createPluginContext, getPluginDataByNamespace } from "../core/plugins/registry"
37
+ import {
38
+ createBridgeAPI,
39
+ collectHookReturns,
40
+ buildRuntimeEnvelope,
41
+ clearHooks,
42
+ type HookContext
43
+ } from "../core/plugins/bridge"
24
44
 
25
45
  // ============================================
26
46
  // Types
@@ -131,8 +151,10 @@ async function compilePage(
131
151
  * Generate the final HTML for a page
132
152
  * Static pages: no JS references
133
153
  * Hydrated pages: bundle.js + page-specific JS
154
+ *
155
+ * Uses the neutral __ZENITH_PLUGIN_DATA__ envelope - CLI never inspects contents.
134
156
  */
135
- function generatePageHTML(page: CompiledPage, globalStyles: string, contentData: any): string {
157
+ function generatePageHTML(page: CompiledPage, globalStyles: string, pluginEnvelope: Record<string, unknown>): string {
136
158
  const { html, styles, analysis, routePath, pageScript } = page
137
159
 
138
160
  // Combine styles
@@ -142,8 +164,10 @@ function generatePageHTML(page: CompiledPage, globalStyles: string, contentData:
142
164
  // Build script tags only if needed
143
165
  let scriptTags = ''
144
166
  if (analysis.needsHydration) {
167
+ // Escape </script> sequences in JSON to prevent breaking the script tag
168
+ const envelopeJson = JSON.stringify(pluginEnvelope).replace(/<\//g, '<\\/')
145
169
  scriptTags = `
146
- <script>window.__ZENITH_CONTENT__ = ${JSON.stringify(contentData)};</script>
170
+ <script>window.__ZENITH_PLUGIN_DATA__ = ${envelopeJson};</script>
147
171
  <script src="/assets/bundle.js"></script>`
148
172
 
149
173
  if (pageScript) {
@@ -238,17 +262,57 @@ ${page.pageScript}
238
262
 
239
263
  /**
240
264
  * Build all pages using SSG approach
265
+ *
266
+ * Follows the blind orchestrator pattern:
267
+ * - Plugins are initialized unconditionally
268
+ * - Data is collected via hooks
269
+ * - CLI never inspects plugin data
241
270
  */
242
271
  export async function buildSSG(options: SSGBuildOptions): Promise<void> {
243
272
  const { pagesDir, outDir, baseDir = path.dirname(pagesDir) } = options
244
- const contentDir = path.join(baseDir, 'content')
245
- const contentData = loadContent(contentDir)
246
273
 
247
274
  console.log('🔨 Zenith SSG Build')
248
275
  console.log(` Pages: ${pagesDir}`)
249
276
  console.log(` Output: ${outDir}`)
250
277
  console.log('')
251
278
 
279
+ // ============================================
280
+ // Plugin Initialization (Unconditional)
281
+ // ============================================
282
+ // Load config and initialize all plugins without checking which ones exist.
283
+ const config = await loadZenithConfig(baseDir)
284
+ const registry = new PluginRegistry()
285
+ const bridgeAPI = createBridgeAPI()
286
+
287
+ // Clear any previously registered hooks
288
+ clearHooks()
289
+
290
+ // Register ALL plugins unconditionally
291
+ for (const plugin of config.plugins || []) {
292
+ console.log(` Plugin: ${plugin.name}`)
293
+ registry.register(plugin)
294
+
295
+ // Let plugin register its CLI hooks
296
+ if (plugin.registerCLI) {
297
+ plugin.registerCLI(bridgeAPI)
298
+ }
299
+ }
300
+
301
+ // Initialize all plugins
302
+ await registry.initAll(createPluginContext(baseDir))
303
+
304
+ // Create hook context - CLI provides this but NEVER uses getPluginData itself
305
+ const hookCtx: HookContext = {
306
+ projectRoot: baseDir,
307
+ getPluginData: getPluginDataByNamespace
308
+ }
309
+
310
+ // Collect runtime payloads from ALL plugins
311
+ const payloads = await collectHookReturns('cli:runtime:collect', hookCtx)
312
+ const pluginEnvelope = buildRuntimeEnvelope(payloads)
313
+
314
+ console.log('')
315
+
252
316
  // Clean and create output directory
253
317
  if (fs.existsSync(outDir)) {
254
318
  fs.rmSync(outDir, { recursive: true, force: true })
@@ -326,7 +390,7 @@ export async function buildSSG(options: SSGBuildOptions): Promise<void> {
326
390
  fs.mkdirSync(pageOutDir, { recursive: true })
327
391
 
328
392
  // Generate and write HTML
329
- const html = generatePageHTML(page, globalStyles, contentData)
393
+ const html = generatePageHTML(page, globalStyles, pluginEnvelope)
330
394
  fs.writeFileSync(path.join(pageOutDir, 'index.html'), html)
331
395
 
332
396
  // Write page-specific JS if needed
@@ -357,7 +421,7 @@ export async function buildSSG(options: SSGBuildOptions): Promise<void> {
357
421
  if (fs.existsSync(custom404Path)) {
358
422
  try {
359
423
  const compiled = await compilePage(custom404Path, pagesDir, baseDir)
360
- const html = generatePageHTML(compiled, globalStyles, contentData)
424
+ const html = generatePageHTML(compiled, globalStyles, pluginEnvelope)
361
425
  fs.writeFileSync(path.join(outDir, '404.html'), html)
362
426
  console.log('📦 Generated 404.html (custom)')
363
427
  has404 = true
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Component Stacking Test
3
+ *
4
+ * Tests that multiple sibling components and multiple instances
5
+ * of the same component render correctly.
6
+ */
7
+
8
+ import { compileZenSource } from '../index'
9
+ import { discoverComponents, type ComponentMetadata } from '../discovery/componentDiscovery'
10
+ import { resolveComponentsInIR } from '../transform/componentResolver'
11
+ import { parseTemplate } from '../parse/parseTemplate'
12
+ import * as fs from 'fs'
13
+ import * as path from 'path'
14
+
15
+ // Test 1: Multiple sibling components
16
+ async function testMultipleSiblingComponents() {
17
+ console.log('\n=== Test 1: Multiple Sibling Components ===\n')
18
+
19
+ // Create mock components
20
+ const mockComponents = new Map<string, ComponentMetadata>()
21
+
22
+ mockComponents.set('Header', {
23
+ name: 'Header',
24
+ path: '/mock/Header.zen',
25
+ template: '<header class="test-header">Header Content</header>',
26
+ nodes: [{
27
+ type: 'element',
28
+ tag: 'header',
29
+ attributes: [{ name: 'class', value: 'test-header', location: { line: 1, column: 1 } }],
30
+ children: [{ type: 'text', value: 'Header Content', location: { line: 1, column: 1 } }],
31
+ location: { line: 1, column: 1 }
32
+ }],
33
+ slots: [],
34
+ props: [],
35
+ styles: [],
36
+ script: null,
37
+ scriptAttributes: null,
38
+ hasScript: false,
39
+ hasStyles: false
40
+ })
41
+
42
+ mockComponents.set('Hero', {
43
+ name: 'Hero',
44
+ path: '/mock/Hero.zen',
45
+ template: '<section class="test-hero">Hero Content</section>',
46
+ nodes: [{
47
+ type: 'element',
48
+ tag: 'section',
49
+ attributes: [{ name: 'class', value: 'test-hero', location: { line: 1, column: 1 } }],
50
+ children: [{ type: 'text', value: 'Hero Content', location: { line: 1, column: 1 } }],
51
+ location: { line: 1, column: 1 }
52
+ }],
53
+ slots: [],
54
+ props: [],
55
+ styles: [],
56
+ script: null,
57
+ scriptAttributes: null,
58
+ hasScript: false,
59
+ hasStyles: false
60
+ })
61
+
62
+ mockComponents.set('Footer', {
63
+ name: 'Footer',
64
+ path: '/mock/Footer.zen',
65
+ template: '<footer class="test-footer">Footer Content</footer>',
66
+ nodes: [{
67
+ type: 'element',
68
+ tag: 'footer',
69
+ attributes: [{ name: 'class', value: 'test-footer', location: { line: 1, column: 1 } }],
70
+ children: [{ type: 'text', value: 'Footer Content', location: { line: 1, column: 1 } }],
71
+ location: { line: 1, column: 1 }
72
+ }],
73
+ slots: [],
74
+ props: [],
75
+ styles: [],
76
+ script: null,
77
+ scriptAttributes: null,
78
+ hasScript: false,
79
+ hasStyles: false
80
+ })
81
+
82
+ // Test source with multiple sibling components
83
+ const testSource = `
84
+ <script setup="ts">
85
+ </script>
86
+ <div class="page">
87
+ <Header />
88
+ <Hero />
89
+ <Footer />
90
+ </div>
91
+ `
92
+
93
+ // Parse the template
94
+ const template = parseTemplate(testSource, 'test.zen')
95
+
96
+ console.log('Parsed nodes:', JSON.stringify(template.nodes, null, 2))
97
+ console.log('\nComponent nodes found:')
98
+
99
+ // Find all component nodes
100
+ function findComponents(nodes: any[], depth = 0): void {
101
+ for (const node of nodes) {
102
+ if (node.type === 'component') {
103
+ console.log(`${' '.repeat(depth)}Component: ${node.name}`)
104
+ } else if (node.type === 'element') {
105
+ console.log(`${' '.repeat(depth)}Element: <${node.tag}>`)
106
+ if (node.children) {
107
+ findComponents(node.children, depth + 1)
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ findComponents(template.nodes)
114
+
115
+ // Create IR
116
+ const ir = {
117
+ filePath: 'test.zen',
118
+ template,
119
+ script: { raw: '', attributes: {} },
120
+ styles: []
121
+ }
122
+
123
+ // Resolve components
124
+ const resolvedIR = resolveComponentsInIR(ir, mockComponents)
125
+
126
+ console.log('\nResolved nodes:', JSON.stringify(resolvedIR.template.nodes, null, 2))
127
+
128
+ // Count how many test-* classes appear in the resolved HTML
129
+ const jsonStr = JSON.stringify(resolvedIR.template.nodes)
130
+ const countHeaders = (jsonStr.match(/"value":"test-header"/g) || []).length
131
+ const countHeros = (jsonStr.match(/"value":"test-hero"/g) || []).length
132
+ const countFooters = (jsonStr.match(/"value":"test-footer"/g) || []).length
133
+
134
+ console.log(`\nResults:`)
135
+ console.log(` Headers: ${countHeaders} (expected: 1)`)
136
+ console.log(` Heros: ${countHeros} (expected: 1)`)
137
+ console.log(` Footers: ${countFooters} (expected: 1)`)
138
+
139
+ const passed = countHeaders === 1 && countHeros === 1 && countFooters === 1
140
+ console.log(`\n${passed ? '✅ PASSED' : '❌ FAILED'}: Multiple sibling components`)
141
+
142
+ return passed
143
+ }
144
+
145
+ // Test 2: Multiple instances of the same component
146
+ async function testMultipleInstances() {
147
+ console.log('\n=== Test 2: Multiple Instances of Same Component ===\n')
148
+
149
+ const mockComponents = new Map<string, ComponentMetadata>()
150
+
151
+ mockComponents.set('Card', {
152
+ name: 'Card',
153
+ path: '/mock/Card.zen',
154
+ template: '<div class="card">Card Content</div>',
155
+ nodes: [{
156
+ type: 'element',
157
+ tag: 'div',
158
+ attributes: [{ name: 'class', value: 'card', location: { line: 1, column: 1 } }],
159
+ children: [{ type: 'text', value: 'Card Content', location: { line: 1, column: 1 } }],
160
+ location: { line: 1, column: 1 }
161
+ }],
162
+ slots: [],
163
+ props: [],
164
+ styles: [],
165
+ script: null,
166
+ scriptAttributes: null,
167
+ hasScript: false,
168
+ hasStyles: false
169
+ })
170
+
171
+ const testSource = `
172
+ <script setup="ts">
173
+ </script>
174
+ <div class="grid">
175
+ <Card />
176
+ <Card />
177
+ <Card />
178
+ </div>
179
+ `
180
+
181
+ const template = parseTemplate(testSource, 'test.zen')
182
+
183
+ console.log('Component nodes found:')
184
+
185
+ function countComponents(nodes: any[]): number {
186
+ let count = 0
187
+ for (const node of nodes) {
188
+ if (node.type === 'component') {
189
+ console.log(` Found component: ${node.name}`)
190
+ count++
191
+ } else if (node.type === 'element' && node.children) {
192
+ count += countComponents(node.children)
193
+ }
194
+ }
195
+ return count
196
+ }
197
+
198
+ const componentCount = countComponents(template.nodes)
199
+ console.log(`Total component nodes before resolution: ${componentCount}`)
200
+
201
+ const ir = {
202
+ filePath: 'test.zen',
203
+ template,
204
+ script: { raw: '', attributes: {} },
205
+ styles: []
206
+ }
207
+
208
+ const resolvedIR = resolveComponentsInIR(ir, mockComponents)
209
+
210
+ // Count card divs in resolved output - search for the attribute value
211
+ const jsonStr = JSON.stringify(resolvedIR.template.nodes)
212
+ const cardCount = (jsonStr.match(/"value":"card"/g) || []).length
213
+
214
+ console.log(`\nResolved JSON (first 500 chars): ${jsonStr.substring(0, 500)}...`)
215
+ console.log(`\nCard divs in resolved output: ${cardCount} (expected: 3)`)
216
+
217
+ const passed = cardCount === 3
218
+ console.log(`\n${passed ? '✅ PASSED' : '❌ FAILED'}: Multiple instances of same component`)
219
+
220
+ return passed
221
+ }
222
+
223
+ // Test 3: Nested components
224
+ async function testNestedComponents() {
225
+ console.log('\n=== Test 3: Nested Components ===\n')
226
+
227
+ const mockComponents = new Map<string, ComponentMetadata>()
228
+
229
+ mockComponents.set('Outer', {
230
+ name: 'Outer',
231
+ path: '/mock/Outer.zen',
232
+ template: '<div class="outer"><slot /></div>',
233
+ nodes: [{
234
+ type: 'element',
235
+ tag: 'div',
236
+ attributes: [{ name: 'class', value: 'outer', location: { line: 1, column: 1 } }],
237
+ children: [{
238
+ type: 'element',
239
+ tag: 'slot',
240
+ attributes: [],
241
+ children: [],
242
+ location: { line: 1, column: 1 }
243
+ }],
244
+ location: { line: 1, column: 1 }
245
+ }],
246
+ slots: [{ name: null, location: { line: 1, column: 1 } }],
247
+ props: [],
248
+ styles: [],
249
+ script: null,
250
+ scriptAttributes: null,
251
+ hasScript: false,
252
+ hasStyles: false
253
+ })
254
+
255
+ mockComponents.set('Inner', {
256
+ name: 'Inner',
257
+ path: '/mock/Inner.zen',
258
+ template: '<span class="inner">Inner Content</span>',
259
+ nodes: [{
260
+ type: 'element',
261
+ tag: 'span',
262
+ attributes: [{ name: 'class', value: 'inner', location: { line: 1, column: 1 } }],
263
+ children: [{ type: 'text', value: 'Inner Content', location: { line: 1, column: 1 } }],
264
+ location: { line: 1, column: 1 }
265
+ }],
266
+ slots: [],
267
+ props: [],
268
+ styles: [],
269
+ script: null,
270
+ scriptAttributes: null,
271
+ hasScript: false,
272
+ hasStyles: false
273
+ })
274
+
275
+ const testSource = `
276
+ <script setup="ts">
277
+ </script>
278
+ <Outer>
279
+ <Inner />
280
+ </Outer>
281
+ `
282
+
283
+ const template = parseTemplate(testSource, 'test.zen')
284
+
285
+ const ir = {
286
+ filePath: 'test.zen',
287
+ template,
288
+ script: { raw: '', attributes: {} },
289
+ styles: []
290
+ }
291
+
292
+ const resolvedIR = resolveComponentsInIR(ir, mockComponents)
293
+
294
+ const jsonStr = JSON.stringify(resolvedIR.template.nodes)
295
+ const hasOuter = jsonStr.includes('"value":"outer"')
296
+ const hasInner = jsonStr.includes('"value":"inner"')
297
+
298
+ console.log(`Resolved JSON (first 500 chars): ${jsonStr.substring(0, 500)}...`)
299
+ console.log(`Outer div present: ${hasOuter}`)
300
+ console.log(`Inner span present: ${hasInner}`)
301
+
302
+ const passed = hasOuter && hasInner
303
+ console.log(`\n${passed ? '✅ PASSED' : '❌ FAILED'}: Nested components`)
304
+
305
+ return passed
306
+ }
307
+
308
+ // Test 4: Auto-import naming (filename-based)
309
+ async function testAutoImportNaming() {
310
+ console.log('\n=== Test 4: Auto-Import Naming ===\n')
311
+
312
+ // Test the naming algorithm directly
313
+ // With the new convention, component name = filename (subdirectories are ignored)
314
+ const testCases = [
315
+ { input: 'components/Header.zen', expected: 'Header' },
316
+ { input: 'components/globals/Header.zen', expected: 'Header' }, // Filename only!
317
+ { input: 'components/ui/buttons/Primary.zen', expected: 'Primary' },
318
+ { input: 'components/sections/HeroSection.zen', expected: 'HeroSection' },
319
+ { input: 'components/ui-kit/Button.zen', expected: 'Button' },
320
+ ]
321
+
322
+ let passed = true
323
+
324
+ for (const tc of testCases) {
325
+ // Component name is just the filename without .zen extension
326
+ const result = path.basename(tc.input, '.zen')
327
+
328
+ const match = result === tc.expected
329
+ console.log(` ${match ? '✓' : '✗'} "${tc.input}" → "${result}" (expected: "${tc.expected}")`)
330
+
331
+ if (!match) passed = false
332
+ }
333
+
334
+ console.log(`\n${passed ? '✅ PASSED' : '❌ FAILED'}: Auto-import naming`)
335
+
336
+ return passed
337
+ }
338
+
339
+ // Run all tests
340
+ async function runTests() {
341
+ console.log('╔════════════════════════════════════════════╗')
342
+ console.log('║ Component Stacking Tests ║')
343
+ console.log('╚════════════════════════════════════════════╝')
344
+
345
+ const results = []
346
+
347
+ results.push(await testMultipleSiblingComponents())
348
+ results.push(await testMultipleInstances())
349
+ results.push(await testNestedComponents())
350
+ results.push(await testAutoImportNaming())
351
+
352
+ console.log('\n════════════════════════════════════════════')
353
+ console.log('Summary:')
354
+ console.log(` Total: ${results.length}`)
355
+ console.log(` Passed: ${results.filter(r => r).length}`)
356
+ console.log(` Failed: ${results.filter(r => !r).length}`)
357
+ console.log('════════════════════════════════════════════\n')
358
+
359
+ process.exit(results.every(r => r) ? 0 : 1)
360
+ }
361
+
362
+ runTests().catch(err => {
363
+ console.error('Test error:', err)
364
+ process.exit(1)
365
+ })
@@ -5,19 +5,19 @@
5
5
  * Uses compound component pattern for named slots (Card.Header, Card.Footer).
6
6
  */
7
7
 
8
- import type { TemplateNode, ComponentNode, ElementNode, ZenIR, LoopContext, ComponentScriptIR } from '../ir/types'
8
+ import type { TemplateNode, ComponentNode, ElementNode, ZenIR, LoopContext, ComponentScriptIR, ExpressionIR } from '../ir/types'
9
9
  import type { ComponentMetadata } from '../discovery/componentDiscovery'
10
10
  import { extractSlotsFromChildren, resolveSlots } from './slotResolver'
11
11
  import { throwOrphanCompoundError, throwUnresolvedComponentError } from '../validate/invariants'
12
12
 
13
- // Track which components have been used (for style and script collection)
13
+ // Track which components have been used (for style, script, and expression collection)
14
14
  const usedComponents = new Set<string>()
15
15
 
16
16
  /**
17
17
  * Resolve all component nodes in a template IR
18
18
  *
19
19
  * Recursively replaces ComponentNode instances with their resolved templates
20
- * Also collects styles AND scripts from used components and adds them to the IR
20
+ * Also collects styles, scripts, AND expressions from used components and adds them to the IR
21
21
  */
22
22
  export function resolveComponentsInIR(
23
23
  ir: ZenIR,
@@ -46,11 +46,21 @@ export function resolveComponentsInIR(
46
46
  scriptAttributes: meta.scriptAttributes || {}
47
47
  }))
48
48
 
49
+ // Collect expressions from all used components (critical for rendering)
50
+ // Component templates may contain expression nodes that reference expression IDs
51
+ // These IDs must be present in the IR's expressions array for transformation to work
52
+ const componentExpressions: ExpressionIR[] = Array.from(usedComponents)
53
+ .map(name => components.get(name))
54
+ .filter((meta): meta is ComponentMetadata => meta !== undefined && meta.expressions?.length > 0)
55
+ .flatMap(meta => meta.expressions)
56
+
49
57
  return {
50
58
  ...ir,
51
59
  template: {
52
60
  ...ir.template,
53
- nodes: resolvedNodes
61
+ nodes: resolvedNodes,
62
+ // Merge component expressions with existing page expressions
63
+ expressions: [...ir.template.expressions, ...componentExpressions]
54
64
  },
55
65
  // Merge component styles with existing page styles
56
66
  styles: [...ir.styles, ...componentStyles],
@@ -108,6 +118,34 @@ function resolveComponentNode(
108
118
  }
109
119
  }
110
120
 
121
+ // Handle loop-fragment nodes - recursively resolve body
122
+ if (node.type === 'loop-fragment') {
123
+ const loopNode = node as import('../ir/types').LoopFragmentNode
124
+ return {
125
+ ...loopNode,
126
+ body: resolveComponentsInNodes(loopNode.body, components, depth + 1)
127
+ }
128
+ }
129
+
130
+ // Handle conditional-fragment nodes - recursively resolve both branches
131
+ if (node.type === 'conditional-fragment') {
132
+ const condNode = node as import('../ir/types').ConditionalFragmentNode
133
+ return {
134
+ ...condNode,
135
+ consequent: resolveComponentsInNodes(condNode.consequent, components, depth + 1),
136
+ alternate: resolveComponentsInNodes(condNode.alternate, components, depth + 1)
137
+ }
138
+ }
139
+
140
+ // Handle optional-fragment nodes - recursively resolve fragment
141
+ if (node.type === 'optional-fragment') {
142
+ const optNode = node as import('../ir/types').OptionalFragmentNode
143
+ return {
144
+ ...optNode,
145
+ fragment: resolveComponentsInNodes(optNode.fragment, components, depth + 1)
146
+ }
147
+ }
148
+
111
149
  // Text and expression nodes pass through unchanged
112
150
  return node
113
151
  }