@zenithbuild/core 0.6.3 → 1.2.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.
- package/cli/commands/dev.ts +107 -48
- package/compiler/discovery/componentDiscovery.ts +75 -11
- package/compiler/output/types.ts +15 -1
- package/compiler/parse/parseTemplate.ts +29 -0
- package/compiler/runtime/dataExposure.ts +27 -12
- package/compiler/runtime/generateDOM.ts +12 -3
- package/compiler/runtime/transformIR.ts +36 -0
- package/compiler/runtime/wrapExpression.ts +32 -13
- package/compiler/runtime/wrapExpressionWithLoop.ts +24 -10
- package/compiler/ssg-build.ts +71 -7
- package/compiler/test/component-stacking.test.ts +365 -0
- package/compiler/transform/componentResolver.ts +42 -4
- package/compiler/transform/fragmentLowering.ts +153 -1
- package/compiler/transform/generateBindings.ts +31 -10
- package/compiler/transform/transformNode.ts +114 -1
- package/core/config/index.ts +5 -3
- package/core/config/types.ts +67 -37
- package/core/plugins/bridge.ts +193 -0
- package/core/plugins/registry.ts +51 -6
- package/dist/cli.js +8 -0
- package/dist/zen-build.js +482 -1802
- package/dist/zen-dev.js +482 -1802
- package/dist/zen-preview.js +482 -1802
- package/dist/zenith.js +482 -1802
- package/package.json +11 -3
- package/runtime/bundle-generator.ts +10 -1
- package/runtime/client-runtime.ts +462 -120
- package/cli/utils/content.ts +0 -112
- package/router/manifest.ts +0 -314
- package/router/navigation/ZenLink.zen +0 -231
- package/router/navigation/index.ts +0 -78
- package/router/navigation/zen-link.ts +0 -584
- package/router/runtime.ts +0 -458
- package/router/types.ts +0 -168
|
@@ -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} = (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
with (__ctx) {
|
|
76
|
-
|
|
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
|
-
}
|
|
79
|
-
|
|
80
|
-
return undefined;
|
|
81
|
-
}
|
|
82
|
-
};`
|
|
95
|
+
};
|
|
96
|
+
})();`
|
|
83
97
|
}
|
package/compiler/ssg-build.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
}
|