@zenithbuild/core 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { parseTemplate } from '../parse/parseTemplate'
|
|
2
|
+
import { transformTemplate } from '../transform/transformTemplate'
|
|
3
|
+
import { lowerFragments } from '../transform/fragmentLowering'
|
|
4
|
+
import { transformNode } from '../transform/transformNode'
|
|
5
|
+
import type { ZenIR, TemplateNode, ExpressionNode, LoopFragmentNode } from '../ir/types'
|
|
6
|
+
|
|
7
|
+
async function testMapLowering() {
|
|
8
|
+
console.log('--- Testing JSX .map() Lowering ---')
|
|
9
|
+
|
|
10
|
+
const source = `
|
|
11
|
+
<div>
|
|
12
|
+
{items.map((item, index) => (
|
|
13
|
+
<li key={index} class={item.active ? 'active' : ''}>
|
|
14
|
+
{item.text}
|
|
15
|
+
</li>
|
|
16
|
+
))}
|
|
17
|
+
</div>
|
|
18
|
+
`
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const template = parseTemplate(source, 'test.zen')
|
|
22
|
+
|
|
23
|
+
// Recursive search for LoopFragmentNode
|
|
24
|
+
function findNode(nodes: TemplateNode[], type: string): any {
|
|
25
|
+
for (const node of nodes) {
|
|
26
|
+
if (node.type === type) return node
|
|
27
|
+
if ('children' in node) {
|
|
28
|
+
const found = findNode(node.children, type)
|
|
29
|
+
if (found) return found
|
|
30
|
+
}
|
|
31
|
+
if (node.type === 'loop-fragment') {
|
|
32
|
+
const found = findNode(node.body, type)
|
|
33
|
+
if (found) return found
|
|
34
|
+
}
|
|
35
|
+
if (node.type === 'conditional-fragment') {
|
|
36
|
+
const foundC = findNode(node.consequent, type)
|
|
37
|
+
if (foundC) return foundC
|
|
38
|
+
const foundA = findNode(node.alternate, type)
|
|
39
|
+
if (foundA) return foundA
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const loopNode = findNode(template.nodes, 'loop-fragment') as LoopFragmentNode
|
|
46
|
+
|
|
47
|
+
if (!loopNode) {
|
|
48
|
+
console.log('Template structure:', JSON.stringify(template.nodes, null, 2))
|
|
49
|
+
throw new Error('LoopFragmentNode not found in template')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.assert(loopNode.source.startsWith('expr_'), 'Loop source should be an expression ID')
|
|
53
|
+
const sourceExpr = template.expressions.find(e => e.id === loopNode.source)
|
|
54
|
+
console.assert(sourceExpr?.code === 'items', `Expected source code "items", got "${sourceExpr?.code}"`)
|
|
55
|
+
|
|
56
|
+
// Check if inner expressions are registered
|
|
57
|
+
const li = loopNode.body.find(n => n.type === 'element' && n.tag === 'li') as any
|
|
58
|
+
const classAttr = li?.attributes.find((a: any) => a.name === 'class')
|
|
59
|
+
console.assert(classAttr?.value.id.startsWith('expr_'), 'Inner attribute expression should be an ID')
|
|
60
|
+
|
|
61
|
+
const textExpr = li?.children.find((n: any) => n.type === 'expression') as ExpressionNode
|
|
62
|
+
console.assert(textExpr?.expression.startsWith('expr_'), 'Inner text expression should be an ID')
|
|
63
|
+
|
|
64
|
+
// Final transformation
|
|
65
|
+
const ir: ZenIR = {
|
|
66
|
+
filePath: 'test.zen',
|
|
67
|
+
template,
|
|
68
|
+
script: null,
|
|
69
|
+
styles: []
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const compiled = transformTemplate(ir)
|
|
73
|
+
console.assert(compiled.html.includes('data-zen-loop="loop_0"'), 'HTML should contain loop marker')
|
|
74
|
+
console.assert(compiled.html.includes(`data-zen-source="${loopNode.source}"`), 'HTML should contain source expression ID')
|
|
75
|
+
|
|
76
|
+
console.log('✅ JSX .map() lowering test passed')
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('❌ JSX .map() lowering test failed:', e)
|
|
79
|
+
process.exit(1)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function testInvariantEnforcement() {
|
|
84
|
+
console.log('--- Testing INV-EXPR-REG-001 Invariant Enforcement ---')
|
|
85
|
+
|
|
86
|
+
const expressions: any[] = []
|
|
87
|
+
const badNode: ExpressionNode = {
|
|
88
|
+
type: 'expression',
|
|
89
|
+
expression: 'raw.code.here', // Should be expr_N
|
|
90
|
+
location: { line: 1, column: 1 }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
// We need to call lowerFragments but it calls assertNoRawExpressions
|
|
95
|
+
lowerFragments([badNode], 'test.zen', expressions)
|
|
96
|
+
console.error('❌ Invariant enforcement failed to catch raw expression')
|
|
97
|
+
process.exit(1)
|
|
98
|
+
} catch (e: any) {
|
|
99
|
+
console.assert(e.name === 'InvariantError', 'Expected InvariantError')
|
|
100
|
+
console.assert(e.message.includes('INV-EXPR-REG-001'), 'Expected INV-EXPR-REG-001 in error message')
|
|
101
|
+
console.log('✅ Invariant enforcement caught raw expression')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Test transformNode safety
|
|
105
|
+
try {
|
|
106
|
+
const expressionsList: any[] = []
|
|
107
|
+
const badLoopNode: any = {
|
|
108
|
+
type: 'loop-fragment',
|
|
109
|
+
source: 'raw_source',
|
|
110
|
+
itemVar: 'item',
|
|
111
|
+
body: [],
|
|
112
|
+
location: { line: 1, column: 1 }
|
|
113
|
+
}
|
|
114
|
+
transformNode(badLoopNode, expressionsList)
|
|
115
|
+
console.error('❌ transformNode failed to catch raw loop source')
|
|
116
|
+
process.exit(1)
|
|
117
|
+
} catch (e: any) {
|
|
118
|
+
console.assert(e.name === 'InvariantError', 'Expected InvariantError in transformNode')
|
|
119
|
+
console.assert(e.message.includes('Raw loop source found'), 'Expected Loop source error')
|
|
120
|
+
console.log('✅ transformNode caught raw loop source')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function runTests() {
|
|
125
|
+
await testMapLowering()
|
|
126
|
+
await testInvariantEnforcement()
|
|
127
|
+
console.log('--- All tests completed successfully ---')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
runTests()
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
OptionalFragmentNode,
|
|
19
19
|
LoopFragmentNode,
|
|
20
20
|
LoopContext,
|
|
21
|
+
AttributeIR,
|
|
21
22
|
SourceLocation,
|
|
22
23
|
ExpressionIR
|
|
23
24
|
} from '../ir/types'
|
|
@@ -41,7 +42,12 @@ export function lowerFragments(
|
|
|
41
42
|
filePath: string,
|
|
42
43
|
expressions: ExpressionIR[]
|
|
43
44
|
): TemplateNode[] {
|
|
44
|
-
|
|
45
|
+
const lowered = nodes.map(node => lowerNode(node, filePath, expressions))
|
|
46
|
+
|
|
47
|
+
// INV-EXPR-REG-001 Enforcement
|
|
48
|
+
lowered.forEach(node => assertNoRawExpressions(node, filePath))
|
|
49
|
+
|
|
50
|
+
return lowered
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/**
|
|
@@ -57,16 +63,11 @@ function lowerNode(
|
|
|
57
63
|
return lowerExpressionNode(node, filePath, expressions)
|
|
58
64
|
|
|
59
65
|
case 'element': {
|
|
60
|
-
// Check if this is a <for> element directive
|
|
61
|
-
if (node.tag === 'for') {
|
|
62
|
-
return lowerForElement(node, filePath, expressions)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
66
|
// Check if this is an <html-content> element directive
|
|
66
67
|
if (node.tag === 'html-content') {
|
|
67
68
|
return lowerHtmlContentElement(node, filePath, expressions)
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
+
|
|
70
71
|
return {
|
|
71
72
|
...node,
|
|
72
73
|
children: lowerFragments(node.children, filePath, expressions)
|
|
@@ -112,7 +113,15 @@ function lowerExpressionNode(
|
|
|
112
113
|
filePath: string,
|
|
113
114
|
expressions: ExpressionIR[]
|
|
114
115
|
): TemplateNode {
|
|
115
|
-
|
|
116
|
+
// Resolve the expression ID to its original code for classification
|
|
117
|
+
const exprIR = expressions.find(e => e.id === node.expression)
|
|
118
|
+
if (!exprIR) {
|
|
119
|
+
// If not found in registry, it might already be an ID or an error
|
|
120
|
+
// But for classification we need the code.
|
|
121
|
+
return node
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const classification = classifyExpression(exprIR.code)
|
|
116
125
|
|
|
117
126
|
// Primitive expressions pass through unchanged
|
|
118
127
|
if (classification.type === 'primitive') {
|
|
@@ -190,13 +199,16 @@ function lowerConditionalExpression(
|
|
|
190
199
|
filePath: string,
|
|
191
200
|
expressions: ExpressionIR[]
|
|
192
201
|
): ConditionalFragmentNode {
|
|
202
|
+
// Register condition
|
|
203
|
+
const conditionId = registerExpression(condition, node.location, expressions)
|
|
204
|
+
|
|
193
205
|
// Parse both branches as JSX fragments
|
|
194
206
|
const consequent = parseJSXToNodes(consequentCode, node.location, filePath, expressions, node.loopContext)
|
|
195
207
|
const alternate = parseJSXToNodes(alternateCode, node.location, filePath, expressions, node.loopContext)
|
|
196
208
|
|
|
197
209
|
return {
|
|
198
210
|
type: 'conditional-fragment',
|
|
199
|
-
condition,
|
|
211
|
+
condition: conditionId,
|
|
200
212
|
consequent,
|
|
201
213
|
alternate,
|
|
202
214
|
location: node.location,
|
|
@@ -216,11 +228,14 @@ function lowerOptionalExpression(
|
|
|
216
228
|
filePath: string,
|
|
217
229
|
expressions: ExpressionIR[]
|
|
218
230
|
): OptionalFragmentNode {
|
|
231
|
+
// Register condition
|
|
232
|
+
const conditionId = registerExpression(condition, node.location, expressions)
|
|
233
|
+
|
|
219
234
|
const fragment = parseJSXToNodes(fragmentCode, node.location, filePath, expressions, node.loopContext)
|
|
220
235
|
|
|
221
236
|
return {
|
|
222
237
|
type: 'optional-fragment',
|
|
223
|
-
condition,
|
|
238
|
+
condition: conditionId,
|
|
224
239
|
fragment,
|
|
225
240
|
location: node.location,
|
|
226
241
|
loopContext: node.loopContext
|
|
@@ -241,6 +256,9 @@ function lowerLoopExpression(
|
|
|
241
256
|
filePath: string,
|
|
242
257
|
expressions: ExpressionIR[]
|
|
243
258
|
): LoopFragmentNode {
|
|
259
|
+
// Register loop source as an expression ID
|
|
260
|
+
const sourceId = registerExpression(source, node.location, expressions)
|
|
261
|
+
|
|
244
262
|
// Create loop context for the body
|
|
245
263
|
const loopVariables = [itemVar]
|
|
246
264
|
if (indexVar) {
|
|
@@ -251,7 +269,7 @@ function lowerLoopExpression(
|
|
|
251
269
|
variables: node.loopContext
|
|
252
270
|
? [...node.loopContext.variables, ...loopVariables]
|
|
253
271
|
: loopVariables,
|
|
254
|
-
mapSource:
|
|
272
|
+
mapSource: sourceId // Use expression ID here
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
// Parse body with loop context
|
|
@@ -259,7 +277,7 @@ function lowerLoopExpression(
|
|
|
259
277
|
|
|
260
278
|
return {
|
|
261
279
|
type: 'loop-fragment',
|
|
262
|
-
source,
|
|
280
|
+
source: sourceId, // Use expression ID here
|
|
263
281
|
itemVar,
|
|
264
282
|
indexVar,
|
|
265
283
|
body,
|
|
@@ -287,95 +305,6 @@ function lowerInlineFragment(
|
|
|
287
305
|
return node
|
|
288
306
|
}
|
|
289
307
|
|
|
290
|
-
/**
|
|
291
|
-
* Lower <for> element directive to LoopFragmentNode
|
|
292
|
-
*
|
|
293
|
-
* Syntax: <for each="item" in="items">...body...</for>
|
|
294
|
-
* Or: <for each="item, index" in="items">...body...</for>
|
|
295
|
-
*
|
|
296
|
-
* This is compile-time sugar for {items.map(item => ...)}
|
|
297
|
-
*/
|
|
298
|
-
function lowerForElement(
|
|
299
|
-
node: import('../ir/types').ElementNode,
|
|
300
|
-
filePath: string,
|
|
301
|
-
expressions: ExpressionIR[]
|
|
302
|
-
): LoopFragmentNode {
|
|
303
|
-
// Extract 'each' and 'in' attributes
|
|
304
|
-
const eachAttr = node.attributes.find(a => a.name === 'each')
|
|
305
|
-
const inAttr = node.attributes.find(a => a.name === 'in')
|
|
306
|
-
|
|
307
|
-
if (!eachAttr || typeof eachAttr.value !== 'string') {
|
|
308
|
-
throw new InvariantError(
|
|
309
|
-
'ZEN001',
|
|
310
|
-
`<for> element requires an 'each' attribute specifying the item variable`,
|
|
311
|
-
'Usage: <for each="item" in="items">...body...</for>',
|
|
312
|
-
filePath,
|
|
313
|
-
node.location.line,
|
|
314
|
-
node.location.column
|
|
315
|
-
)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (!inAttr || typeof inAttr.value !== 'string') {
|
|
319
|
-
throw new InvariantError(
|
|
320
|
-
'ZEN001',
|
|
321
|
-
`<for> element requires an 'in' attribute specifying the source array`,
|
|
322
|
-
'Usage: <for each="item" in="items">...body...</for>',
|
|
323
|
-
filePath,
|
|
324
|
-
node.location.line,
|
|
325
|
-
node.location.column
|
|
326
|
-
)
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Parse item variable (may include index: "item, index" or "item, i")
|
|
330
|
-
const eachValue = eachAttr.value.trim()
|
|
331
|
-
let itemVar: string
|
|
332
|
-
let indexVar: string | undefined
|
|
333
|
-
|
|
334
|
-
if (eachValue.includes(',')) {
|
|
335
|
-
const parts = eachValue.split(',').map(p => p.trim())
|
|
336
|
-
itemVar = parts[0]!
|
|
337
|
-
indexVar = parts[1]
|
|
338
|
-
} else {
|
|
339
|
-
itemVar = eachValue
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const source = inAttr.value.trim()
|
|
343
|
-
|
|
344
|
-
// Create loop context for the body
|
|
345
|
-
const loopVariables = [itemVar]
|
|
346
|
-
if (indexVar) {
|
|
347
|
-
loopVariables.push(indexVar)
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const bodyLoopContext: LoopContext = {
|
|
351
|
-
variables: node.loopContext
|
|
352
|
-
? [...node.loopContext.variables, ...loopVariables]
|
|
353
|
-
: loopVariables,
|
|
354
|
-
mapSource: source
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Lower children with loop context
|
|
358
|
-
const body = node.children.map(child => {
|
|
359
|
-
// Recursively lower children
|
|
360
|
-
const lowered = lowerNode(child, filePath, expressions)
|
|
361
|
-
// Attach loop context to children that need it
|
|
362
|
-
if ('loopContext' in lowered) {
|
|
363
|
-
return { ...lowered, loopContext: bodyLoopContext }
|
|
364
|
-
}
|
|
365
|
-
return lowered
|
|
366
|
-
})
|
|
367
|
-
|
|
368
|
-
return {
|
|
369
|
-
type: 'loop-fragment',
|
|
370
|
-
source,
|
|
371
|
-
itemVar,
|
|
372
|
-
indexVar,
|
|
373
|
-
body,
|
|
374
|
-
location: node.location,
|
|
375
|
-
loopContext: bodyLoopContext
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
308
|
/**
|
|
380
309
|
* Lower <html-content> element directive
|
|
381
310
|
*
|
|
@@ -391,7 +320,7 @@ function lowerHtmlContentElement(
|
|
|
391
320
|
): TemplateNode {
|
|
392
321
|
// Extract 'content' attribute
|
|
393
322
|
const contentAttr = node.attributes.find(a => a.name === 'content')
|
|
394
|
-
|
|
323
|
+
|
|
395
324
|
if (!contentAttr || typeof contentAttr.value !== 'string') {
|
|
396
325
|
throw new InvariantError(
|
|
397
326
|
'ZEN001',
|
|
@@ -402,9 +331,9 @@ function lowerHtmlContentElement(
|
|
|
402
331
|
node.location.column
|
|
403
332
|
)
|
|
404
333
|
}
|
|
405
|
-
|
|
334
|
+
|
|
406
335
|
const exprCode = contentAttr.value.trim()
|
|
407
|
-
|
|
336
|
+
|
|
408
337
|
// Generate expression ID and register the expression
|
|
409
338
|
const exprId = `expr_${expressions.length}`
|
|
410
339
|
const exprIR: ExpressionIR = {
|
|
@@ -413,7 +342,7 @@ function lowerHtmlContentElement(
|
|
|
413
342
|
location: node.location
|
|
414
343
|
}
|
|
415
344
|
expressions.push(exprIR)
|
|
416
|
-
|
|
345
|
+
|
|
417
346
|
// Create a span element with data-zen-html attribute for raw HTML binding
|
|
418
347
|
return {
|
|
419
348
|
type: 'element',
|
|
@@ -428,6 +357,101 @@ function lowerHtmlContentElement(
|
|
|
428
357
|
}
|
|
429
358
|
}
|
|
430
359
|
|
|
360
|
+
/**
|
|
361
|
+
* Register a raw expression code string and return its ID
|
|
362
|
+
*/
|
|
363
|
+
function registerExpression(
|
|
364
|
+
code: string,
|
|
365
|
+
location: SourceLocation,
|
|
366
|
+
expressions: ExpressionIR[]
|
|
367
|
+
): string {
|
|
368
|
+
const id = `expr_${expressions.length}`
|
|
369
|
+
expressions.push({ id, code, location })
|
|
370
|
+
return id
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* INV-EXPR-REG-001: Assert no raw expressions exist beyond parse phase
|
|
375
|
+
*/
|
|
376
|
+
function assertNoRawExpressions(node: TemplateNode, filePath: string): void {
|
|
377
|
+
const errorPrefix = `Compiler invariant violated: runtime expression used without registration. This is a compiler bug, not a user error.`
|
|
378
|
+
|
|
379
|
+
switch (node.type) {
|
|
380
|
+
case 'expression':
|
|
381
|
+
if (!node.expression.startsWith('expr_')) {
|
|
382
|
+
throw new InvariantError(
|
|
383
|
+
'INV-EXPR-REG-001',
|
|
384
|
+
`${errorPrefix}\nRaw expression found: "${node.expression}"`,
|
|
385
|
+
'Every expression must be registered and referenced by ID.',
|
|
386
|
+
filePath,
|
|
387
|
+
node.location.line,
|
|
388
|
+
node.location.column
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
break
|
|
392
|
+
|
|
393
|
+
case 'loop-fragment':
|
|
394
|
+
if (!node.source.startsWith('expr_')) {
|
|
395
|
+
throw new InvariantError(
|
|
396
|
+
'INV-EXPR-REG-001',
|
|
397
|
+
`${errorPrefix}\nRaw loop source found: "${node.source}"`,
|
|
398
|
+
'Loop sources must be registered and referenced by ID.',
|
|
399
|
+
filePath,
|
|
400
|
+
node.location.line,
|
|
401
|
+
node.location.column
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
node.body.forEach(child => assertNoRawExpressions(child, filePath))
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
case 'conditional-fragment':
|
|
408
|
+
if (!node.condition.startsWith('expr_')) {
|
|
409
|
+
throw new InvariantError(
|
|
410
|
+
'INV-EXPR-REG-001',
|
|
411
|
+
`${errorPrefix}\nRaw condition found: "${node.condition}"`,
|
|
412
|
+
'Conditional expressions must be registered and referenced by ID.',
|
|
413
|
+
filePath,
|
|
414
|
+
node.location.line,
|
|
415
|
+
node.location.column
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
node.consequent.forEach(child => assertNoRawExpressions(child, filePath))
|
|
419
|
+
node.alternate.forEach(child => assertNoRawExpressions(child, filePath))
|
|
420
|
+
break
|
|
421
|
+
|
|
422
|
+
case 'optional-fragment':
|
|
423
|
+
if (!node.condition.startsWith('expr_')) {
|
|
424
|
+
throw new InvariantError(
|
|
425
|
+
'INV-EXPR-REG-001',
|
|
426
|
+
`${errorPrefix}\nRaw condition found: "${node.condition}"`,
|
|
427
|
+
'Optional expressions must be registered and referenced by ID.',
|
|
428
|
+
filePath,
|
|
429
|
+
node.location.line,
|
|
430
|
+
node.location.column
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
node.fragment.forEach(child => assertNoRawExpressions(child, filePath))
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
case 'element':
|
|
437
|
+
case 'component':
|
|
438
|
+
node.attributes.forEach(attr => {
|
|
439
|
+
if (typeof attr.value !== 'string' && !attr.value.id.startsWith('expr_')) {
|
|
440
|
+
throw new InvariantError(
|
|
441
|
+
'INV-EXPR-REG-001',
|
|
442
|
+
`${errorPrefix}\nRaw attribute expression found: "${(attr.value as any).code}"`,
|
|
443
|
+
'Attribute expressions must be registered and referenced by ID.',
|
|
444
|
+
filePath,
|
|
445
|
+
attr.location.line,
|
|
446
|
+
attr.location.column
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
})
|
|
450
|
+
node.children.forEach(child => assertNoRawExpressions(child, filePath))
|
|
451
|
+
break
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
431
455
|
/**
|
|
432
456
|
* Parse JSX code string into TemplateNode[]
|
|
433
457
|
*
|
|
@@ -537,9 +561,11 @@ function parseJSXChildren(
|
|
|
537
561
|
|
|
538
562
|
const exprCode = content.slice(i + 1, endBrace - 1).trim()
|
|
539
563
|
if (exprCode) {
|
|
564
|
+
// Register inner expression
|
|
565
|
+
const exprId = registerExpression(exprCode, baseLocation, expressions)
|
|
540
566
|
nodes.push({
|
|
541
567
|
type: 'expression',
|
|
542
|
-
expression:
|
|
568
|
+
expression: exprId,
|
|
543
569
|
location: baseLocation,
|
|
544
570
|
loopContext
|
|
545
571
|
})
|
|
@@ -598,7 +624,7 @@ function parseJSXElementWithEnd(
|
|
|
598
624
|
let i = startIndex + tagMatch[0].length
|
|
599
625
|
|
|
600
626
|
// Parse attributes (simplified)
|
|
601
|
-
const attributes:
|
|
627
|
+
const attributes: AttributeIR[] = []
|
|
602
628
|
|
|
603
629
|
// Skip whitespace and parse attributes until > or />
|
|
604
630
|
while (i < code.length) {
|
|
@@ -667,7 +693,14 @@ function parseJSXElementWithEnd(
|
|
|
667
693
|
} else if (code[i] === '{') {
|
|
668
694
|
const endBrace = findBalancedBraceEnd(code, i)
|
|
669
695
|
if (endBrace !== -1) {
|
|
670
|
-
|
|
696
|
+
const exprCode = code.slice(i + 1, endBrace - 1).trim()
|
|
697
|
+
const exprId = registerExpression(exprCode, baseLocation, expressions)
|
|
698
|
+
|
|
699
|
+
attributes.push({
|
|
700
|
+
name: attrName,
|
|
701
|
+
value: { id: exprId, code: exprCode, location: baseLocation },
|
|
702
|
+
location: baseLocation
|
|
703
|
+
})
|
|
671
704
|
i = endBrace
|
|
672
705
|
}
|
|
673
706
|
}
|
|
@@ -6,19 +6,22 @@
|
|
|
6
6
|
* Phase 8: Supports fragment node types (loop-fragment, conditional-fragment, optional-fragment)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
10
|
-
TemplateNode,
|
|
11
|
-
ElementNode,
|
|
12
|
-
TextNode,
|
|
13
|
-
ExpressionNode,
|
|
14
|
-
ExpressionIR,
|
|
9
|
+
import type {
|
|
10
|
+
TemplateNode,
|
|
11
|
+
ElementNode,
|
|
12
|
+
TextNode,
|
|
13
|
+
ExpressionNode,
|
|
14
|
+
ExpressionIR,
|
|
15
15
|
LoopContext,
|
|
16
16
|
LoopFragmentNode,
|
|
17
17
|
ConditionalFragmentNode,
|
|
18
18
|
OptionalFragmentNode,
|
|
19
|
-
ComponentNode
|
|
19
|
+
ComponentNode,
|
|
20
|
+
SourceLocation
|
|
20
21
|
} from '../ir/types'
|
|
21
22
|
import type { Binding } from '../output/types'
|
|
23
|
+
import { InvariantError } from '../errors/compilerError'
|
|
24
|
+
import { INVARIANT } from '../validate/invariants'
|
|
22
25
|
|
|
23
26
|
let loopIdCounter = 0
|
|
24
27
|
|
|
@@ -126,7 +129,8 @@ export function transformNode(
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
case 'loop-fragment': {
|
|
129
|
-
// Loop fragment: {items.map(item => <li>...</li>)}
|
|
132
|
+
// Loop fragment: {items.map(item => <li>...</li>)}
|
|
133
|
+
// .map() is compile-time sugar, lowered to LoopFragmentNode
|
|
130
134
|
// For SSR/SSG, we render one instance of the body as a template
|
|
131
135
|
// The runtime will hydrate and expand this for each actual item
|
|
132
136
|
const loopNode = node as LoopFragmentNode
|
|
@@ -152,7 +156,7 @@ export function transformNode(
|
|
|
152
156
|
// For SSR, we render ONE visible instance of the body as a template/placeholder
|
|
153
157
|
// The runtime will clone this for each item in the array
|
|
154
158
|
const bodyHtml = loopNode.body.map(child => transform(child, activeLoopContext)).join('')
|
|
155
|
-
|
|
159
|
+
|
|
156
160
|
// Render container with body visible for SSR (not in hidden <template>)
|
|
157
161
|
// Runtime will clear and re-render with actual data
|
|
158
162
|
return `<div data-zen-loop="${loopId}" data-zen-source="${escapeHtml(loopNode.source)}" data-zen-item="${loopNode.itemVar}"${loopNode.indexVar ? ` data-zen-index="${loopNode.indexVar}"` : ''} style="display: contents;">${bodyHtml}</div>`
|
|
@@ -207,7 +211,7 @@ export function transformNode(
|
|
|
207
211
|
// This is a fallback for unresolved components
|
|
208
212
|
const compNode = node as ComponentNode
|
|
209
213
|
console.warn(`[Zenith] Unresolved component in transformNode: ${compNode.name}`)
|
|
210
|
-
|
|
214
|
+
|
|
211
215
|
// Render children as a fragment
|
|
212
216
|
const childrenHtml = compNode.children.map(child => transform(child, loopContext)).join('')
|
|
213
217
|
return `<!-- unresolved: ${compNode.name} -->${childrenHtml}`
|
|
@@ -222,9 +226,80 @@ export function transformNode(
|
|
|
222
226
|
}
|
|
223
227
|
|
|
224
228
|
const html = transform(node, parentLoopContext)
|
|
229
|
+
|
|
230
|
+
// INV-EXPR-REG-001 Enforcement: Before emitting HTML, assert all bindings resolve
|
|
231
|
+
checkEveryBindingResolves(bindings, expressions)
|
|
232
|
+
|
|
225
233
|
return { html, bindings }
|
|
226
234
|
}
|
|
227
235
|
|
|
236
|
+
/**
|
|
237
|
+
* INV-EXPR-REG-001: Assert every data-zen-* reference resolves to a registered expression
|
|
238
|
+
*/
|
|
239
|
+
function checkEveryBindingResolves(bindings: Binding[], expressions: ExpressionIR[]): void {
|
|
240
|
+
const errorPrefix = `Compiler invariant violated: runtime expression used without registration. This is a compiler bug, not a user error.`
|
|
241
|
+
|
|
242
|
+
for (const binding of bindings) {
|
|
243
|
+
const exprId = binding.id
|
|
244
|
+
const resolved = expressions.find(e => e.id === exprId)
|
|
245
|
+
|
|
246
|
+
if (!resolved && binding.type !== 'loop') {
|
|
247
|
+
// Loop bindings might have a generated loopId that doesn't
|
|
248
|
+
// exist in the expressions array, but they have a source expression ID
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Check loop source specifically
|
|
252
|
+
if (binding.type === 'loop') {
|
|
253
|
+
const sourceExprId = binding.expression
|
|
254
|
+
if (!sourceExprId.startsWith('expr_')) {
|
|
255
|
+
throw new InvariantError(
|
|
256
|
+
'INV-EXPR-REG-001',
|
|
257
|
+
`${errorPrefix}\nLoop fragment references invalid source expression: "${sourceExprId}"`,
|
|
258
|
+
'Loop fragments must reference a valid registered expression ID.',
|
|
259
|
+
'unknown', // transformNode doesn't have filePath, but we can't easily get it here without changing signature
|
|
260
|
+
binding.location?.line || 1,
|
|
261
|
+
binding.location?.column || 1
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sourceResolved = expressions.find(e => e.id === sourceExprId)
|
|
266
|
+
if (!sourceResolved) {
|
|
267
|
+
throw new InvariantError(
|
|
268
|
+
'INV-EXPR-REG-001',
|
|
269
|
+
`${errorPrefix}\nLoop source expression "${sourceExprId}" not found in registry.`,
|
|
270
|
+
'Every loop source must be registered as an ExpressionIR.',
|
|
271
|
+
'unknown',
|
|
272
|
+
binding.location?.line || 1,
|
|
273
|
+
binding.location?.column || 1
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
// General expression check
|
|
278
|
+
if (!exprId.startsWith('expr_')) {
|
|
279
|
+
throw new InvariantError(
|
|
280
|
+
'INV-EXPR-REG-001',
|
|
281
|
+
`${errorPrefix}\nBinding references invalid expression ID: "${exprId}"`,
|
|
282
|
+
'Every binding must reference a valid registered expression ID.',
|
|
283
|
+
'unknown',
|
|
284
|
+
binding.location?.line || 1,
|
|
285
|
+
binding.location?.column || 1
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (!resolved) {
|
|
290
|
+
throw new InvariantError(
|
|
291
|
+
'INV-EXPR-REG-001',
|
|
292
|
+
`${errorPrefix}\nExpression ID "${exprId}" not found in registry.`,
|
|
293
|
+
'Every binding must resolve to a registered expression.',
|
|
294
|
+
'unknown',
|
|
295
|
+
binding.location?.line || 1,
|
|
296
|
+
binding.location?.column || 1
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
228
303
|
/**
|
|
229
304
|
* Escape HTML special characters
|
|
230
305
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenithbuild/core",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Core library for the Zenith framework",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -77,4 +77,4 @@
|
|
|
77
77
|
"parse5": "^8.0.0",
|
|
78
78
|
"picocolors": "^1.1.1"
|
|
79
79
|
}
|
|
80
|
-
}
|
|
80
|
+
}
|