@zenithbuild/core 0.4.2 → 0.4.4
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 +4 -1
- package/compiler/discovery/componentDiscovery.ts +174 -0
- package/compiler/errors/compilerError.ts +32 -0
- package/compiler/finalize/finalizeOutput.ts +37 -8
- package/compiler/index.ts +26 -5
- package/compiler/ir/types.ts +66 -0
- package/compiler/parse/parseTemplate.ts +66 -9
- package/compiler/runtime/generateDOM.ts +102 -1
- package/compiler/runtime/transformIR.ts +2 -2
- package/compiler/transform/classifyExpression.ts +444 -0
- package/compiler/transform/componentResolver.ts +289 -0
- package/compiler/transform/fragmentLowering.ts +634 -0
- package/compiler/transform/slotResolver.ts +292 -0
- package/compiler/validate/invariants.ts +292 -0
- package/package.json +1 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fragment Lowering
|
|
3
|
+
*
|
|
4
|
+
* Transforms JSX-returning expressions into structural fragment nodes.
|
|
5
|
+
*
|
|
6
|
+
* This phase runs AFTER parsing, BEFORE component resolution.
|
|
7
|
+
* Transforms ExpressionNode → ConditionalFragmentNode | OptionalFragmentNode | LoopFragmentNode
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: JSX in Zenith is compile-time sugar only.
|
|
10
|
+
* The compiler enumerates all possible DOM shapes and lowers them at compile time.
|
|
11
|
+
* Runtime never creates DOM — it only toggles visibility and binds values.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
TemplateNode,
|
|
16
|
+
ExpressionNode,
|
|
17
|
+
ConditionalFragmentNode,
|
|
18
|
+
OptionalFragmentNode,
|
|
19
|
+
LoopFragmentNode,
|
|
20
|
+
LoopContext,
|
|
21
|
+
SourceLocation,
|
|
22
|
+
ExpressionIR
|
|
23
|
+
} from '../ir/types'
|
|
24
|
+
import { classifyExpression, requiresStructuralLowering } from './classifyExpression'
|
|
25
|
+
import { InvariantError } from '../errors/compilerError'
|
|
26
|
+
import { INVARIANT } from '../validate/invariants'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lower JSX-returning expressions into structural fragments
|
|
30
|
+
*
|
|
31
|
+
* Walks the node tree and transforms ExpressionNode instances
|
|
32
|
+
* that return JSX into the appropriate fragment node types.
|
|
33
|
+
*
|
|
34
|
+
* @param nodes - Template nodes to process
|
|
35
|
+
* @param filePath - Source file path for error reporting
|
|
36
|
+
* @param expressions - Expression registry (mutated to add new expressions)
|
|
37
|
+
* @returns Lowered nodes with fragment bindings
|
|
38
|
+
*/
|
|
39
|
+
export function lowerFragments(
|
|
40
|
+
nodes: TemplateNode[],
|
|
41
|
+
filePath: string,
|
|
42
|
+
expressions: ExpressionIR[]
|
|
43
|
+
): TemplateNode[] {
|
|
44
|
+
return nodes.map(node => lowerNode(node, filePath, expressions))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Lower a single node
|
|
49
|
+
*/
|
|
50
|
+
function lowerNode(
|
|
51
|
+
node: TemplateNode,
|
|
52
|
+
filePath: string,
|
|
53
|
+
expressions: ExpressionIR[]
|
|
54
|
+
): TemplateNode {
|
|
55
|
+
switch (node.type) {
|
|
56
|
+
case 'expression':
|
|
57
|
+
return lowerExpressionNode(node, filePath, expressions)
|
|
58
|
+
|
|
59
|
+
case 'element':
|
|
60
|
+
return {
|
|
61
|
+
...node,
|
|
62
|
+
children: lowerFragments(node.children, filePath, expressions)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
case 'component':
|
|
66
|
+
return {
|
|
67
|
+
...node,
|
|
68
|
+
children: lowerFragments(node.children, filePath, expressions)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'conditional-fragment':
|
|
72
|
+
return {
|
|
73
|
+
...node,
|
|
74
|
+
consequent: lowerFragments(node.consequent, filePath, expressions),
|
|
75
|
+
alternate: lowerFragments(node.alternate, filePath, expressions)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case 'optional-fragment':
|
|
79
|
+
return {
|
|
80
|
+
...node,
|
|
81
|
+
fragment: lowerFragments(node.fragment, filePath, expressions)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
case 'loop-fragment':
|
|
85
|
+
return {
|
|
86
|
+
...node,
|
|
87
|
+
body: lowerFragments(node.body, filePath, expressions)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
case 'text':
|
|
91
|
+
default:
|
|
92
|
+
return node
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Lower an expression node to a fragment if it returns JSX
|
|
98
|
+
*/
|
|
99
|
+
function lowerExpressionNode(
|
|
100
|
+
node: ExpressionNode,
|
|
101
|
+
filePath: string,
|
|
102
|
+
expressions: ExpressionIR[]
|
|
103
|
+
): TemplateNode {
|
|
104
|
+
const classification = classifyExpression(node.expression)
|
|
105
|
+
|
|
106
|
+
// Primitive expressions pass through unchanged
|
|
107
|
+
if (classification.type === 'primitive') {
|
|
108
|
+
return node
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Unknown expressions with JSX are compile errors
|
|
112
|
+
if (classification.type === 'unknown') {
|
|
113
|
+
throw new InvariantError(
|
|
114
|
+
INVARIANT.NON_ENUMERABLE_JSX,
|
|
115
|
+
`JSX expression output cannot be statically determined: ${node.expression.slice(0, 50)}...`,
|
|
116
|
+
'JSX expressions must have statically enumerable output. The compiler must know all possible DOM shapes at compile time.',
|
|
117
|
+
filePath,
|
|
118
|
+
node.location.line,
|
|
119
|
+
node.location.column
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Lower based on classification type
|
|
124
|
+
switch (classification.type) {
|
|
125
|
+
case 'conditional':
|
|
126
|
+
return lowerConditionalExpression(
|
|
127
|
+
node,
|
|
128
|
+
classification.condition!,
|
|
129
|
+
classification.consequent!,
|
|
130
|
+
classification.alternate!,
|
|
131
|
+
filePath,
|
|
132
|
+
expressions
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
case 'optional':
|
|
136
|
+
return lowerOptionalExpression(
|
|
137
|
+
node,
|
|
138
|
+
classification.optionalCondition!,
|
|
139
|
+
classification.optionalFragment!,
|
|
140
|
+
filePath,
|
|
141
|
+
expressions
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
case 'loop':
|
|
145
|
+
return lowerLoopExpression(
|
|
146
|
+
node,
|
|
147
|
+
classification.loopSource!,
|
|
148
|
+
classification.loopItemVar!,
|
|
149
|
+
classification.loopIndexVar,
|
|
150
|
+
classification.loopBody!,
|
|
151
|
+
filePath,
|
|
152
|
+
expressions
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
case 'fragment':
|
|
156
|
+
return lowerInlineFragment(
|
|
157
|
+
node,
|
|
158
|
+
classification.fragmentCode!,
|
|
159
|
+
filePath,
|
|
160
|
+
expressions
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
default:
|
|
164
|
+
// Should not reach here
|
|
165
|
+
return node
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Lower conditional expression: condition ? <A /> : <B />
|
|
171
|
+
*
|
|
172
|
+
* Both branches are parsed and compiled at compile time.
|
|
173
|
+
*/
|
|
174
|
+
function lowerConditionalExpression(
|
|
175
|
+
node: ExpressionNode,
|
|
176
|
+
condition: string,
|
|
177
|
+
consequentCode: string,
|
|
178
|
+
alternateCode: string,
|
|
179
|
+
filePath: string,
|
|
180
|
+
expressions: ExpressionIR[]
|
|
181
|
+
): ConditionalFragmentNode {
|
|
182
|
+
// Parse both branches as JSX fragments
|
|
183
|
+
const consequent = parseJSXToNodes(consequentCode, node.location, filePath, expressions, node.loopContext)
|
|
184
|
+
const alternate = parseJSXToNodes(alternateCode, node.location, filePath, expressions, node.loopContext)
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
type: 'conditional-fragment',
|
|
188
|
+
condition,
|
|
189
|
+
consequent,
|
|
190
|
+
alternate,
|
|
191
|
+
location: node.location,
|
|
192
|
+
loopContext: node.loopContext
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Lower optional expression: condition && <A />
|
|
198
|
+
*
|
|
199
|
+
* Fragment is parsed and compiled at compile time.
|
|
200
|
+
*/
|
|
201
|
+
function lowerOptionalExpression(
|
|
202
|
+
node: ExpressionNode,
|
|
203
|
+
condition: string,
|
|
204
|
+
fragmentCode: string,
|
|
205
|
+
filePath: string,
|
|
206
|
+
expressions: ExpressionIR[]
|
|
207
|
+
): OptionalFragmentNode {
|
|
208
|
+
const fragment = parseJSXToNodes(fragmentCode, node.location, filePath, expressions, node.loopContext)
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
type: 'optional-fragment',
|
|
212
|
+
condition,
|
|
213
|
+
fragment,
|
|
214
|
+
location: node.location,
|
|
215
|
+
loopContext: node.loopContext
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Lower loop expression: items.map(item => <li>...</li>)
|
|
221
|
+
*
|
|
222
|
+
* Body is parsed and compiled once, instantiated per item at runtime.
|
|
223
|
+
*/
|
|
224
|
+
function lowerLoopExpression(
|
|
225
|
+
node: ExpressionNode,
|
|
226
|
+
source: string,
|
|
227
|
+
itemVar: string,
|
|
228
|
+
indexVar: string | undefined,
|
|
229
|
+
bodyCode: string,
|
|
230
|
+
filePath: string,
|
|
231
|
+
expressions: ExpressionIR[]
|
|
232
|
+
): LoopFragmentNode {
|
|
233
|
+
// Create loop context for the body
|
|
234
|
+
const loopVariables = [itemVar]
|
|
235
|
+
if (indexVar) {
|
|
236
|
+
loopVariables.push(indexVar)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const bodyLoopContext: LoopContext = {
|
|
240
|
+
variables: node.loopContext
|
|
241
|
+
? [...node.loopContext.variables, ...loopVariables]
|
|
242
|
+
: loopVariables,
|
|
243
|
+
mapSource: source
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Parse body with loop context
|
|
247
|
+
const body = parseJSXToNodes(bodyCode, node.location, filePath, expressions, bodyLoopContext)
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
type: 'loop-fragment',
|
|
251
|
+
source,
|
|
252
|
+
itemVar,
|
|
253
|
+
indexVar,
|
|
254
|
+
body,
|
|
255
|
+
location: node.location,
|
|
256
|
+
loopContext: bodyLoopContext
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Lower inline fragment: <A /> or <><A /><B /></>
|
|
262
|
+
*
|
|
263
|
+
* JSX is parsed and inlined directly into the node tree.
|
|
264
|
+
* Returns the original expression node since inline JSX
|
|
265
|
+
* is already handled by the expression transformer.
|
|
266
|
+
*/
|
|
267
|
+
function lowerInlineFragment(
|
|
268
|
+
node: ExpressionNode,
|
|
269
|
+
fragmentCode: string,
|
|
270
|
+
filePath: string,
|
|
271
|
+
expressions: ExpressionIR[]
|
|
272
|
+
): TemplateNode {
|
|
273
|
+
// For now, inline fragments are handled by the existing expression transformer
|
|
274
|
+
// which converts JSX to __zenith.h() calls
|
|
275
|
+
// In a future iteration, we could parse them to static nodes here
|
|
276
|
+
return node
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parse JSX code string into TemplateNode[]
|
|
281
|
+
*
|
|
282
|
+
* This is a simplified parser for JSX fragments within expressions.
|
|
283
|
+
* It handles basic JSX structure for lowering purposes.
|
|
284
|
+
*/
|
|
285
|
+
function parseJSXToNodes(
|
|
286
|
+
code: string,
|
|
287
|
+
baseLocation: SourceLocation,
|
|
288
|
+
filePath: string,
|
|
289
|
+
expressions: ExpressionIR[],
|
|
290
|
+
loopContext?: LoopContext
|
|
291
|
+
): TemplateNode[] {
|
|
292
|
+
const trimmed = code.trim()
|
|
293
|
+
|
|
294
|
+
// Handle fragment syntax <>...</>
|
|
295
|
+
if (trimmed.startsWith('<>')) {
|
|
296
|
+
const content = extractFragmentContent(trimmed)
|
|
297
|
+
return parseJSXChildren(content, baseLocation, filePath, expressions, loopContext)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Handle single element
|
|
301
|
+
if (trimmed.startsWith('<')) {
|
|
302
|
+
const element = parseJSXElement(trimmed, baseLocation, filePath, expressions, loopContext)
|
|
303
|
+
return element ? [element] : []
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Handle parenthesized expression
|
|
307
|
+
if (trimmed.startsWith('(')) {
|
|
308
|
+
const inner = trimmed.slice(1, -1).trim()
|
|
309
|
+
return parseJSXToNodes(inner, baseLocation, filePath, expressions, loopContext)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Not JSX - return as expression node
|
|
313
|
+
return [{
|
|
314
|
+
type: 'expression',
|
|
315
|
+
expression: trimmed,
|
|
316
|
+
location: baseLocation,
|
|
317
|
+
loopContext
|
|
318
|
+
}]
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Extract content from fragment syntax <>content</>
|
|
323
|
+
*/
|
|
324
|
+
function extractFragmentContent(code: string): string {
|
|
325
|
+
// Remove <> prefix and </> suffix
|
|
326
|
+
const withoutOpen = code.slice(2)
|
|
327
|
+
const closeIndex = withoutOpen.lastIndexOf('</>')
|
|
328
|
+
if (closeIndex === -1) {
|
|
329
|
+
return withoutOpen
|
|
330
|
+
}
|
|
331
|
+
return withoutOpen.slice(0, closeIndex)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Parse JSX children content
|
|
336
|
+
*/
|
|
337
|
+
function parseJSXChildren(
|
|
338
|
+
content: string,
|
|
339
|
+
baseLocation: SourceLocation,
|
|
340
|
+
filePath: string,
|
|
341
|
+
expressions: ExpressionIR[],
|
|
342
|
+
loopContext?: LoopContext
|
|
343
|
+
): TemplateNode[] {
|
|
344
|
+
const nodes: TemplateNode[] = []
|
|
345
|
+
let i = 0
|
|
346
|
+
let currentText = ''
|
|
347
|
+
|
|
348
|
+
while (i < content.length) {
|
|
349
|
+
const char = content[i]
|
|
350
|
+
|
|
351
|
+
// Check for JSX element
|
|
352
|
+
if (char === '<' && /[a-zA-Z]/.test(content[i + 1] || '')) {
|
|
353
|
+
// Save accumulated text
|
|
354
|
+
if (currentText.trim()) {
|
|
355
|
+
nodes.push({
|
|
356
|
+
type: 'text',
|
|
357
|
+
value: currentText.trim(),
|
|
358
|
+
location: baseLocation
|
|
359
|
+
})
|
|
360
|
+
currentText = ''
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Parse element
|
|
364
|
+
const result = parseJSXElementWithEnd(content, i, baseLocation, filePath, expressions, loopContext)
|
|
365
|
+
if (result) {
|
|
366
|
+
nodes.push(result.node)
|
|
367
|
+
i = result.endIndex
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Check for expression {expr}
|
|
373
|
+
if (char === '{') {
|
|
374
|
+
const endBrace = findBalancedBraceEnd(content, i)
|
|
375
|
+
if (endBrace !== -1) {
|
|
376
|
+
// Save accumulated text
|
|
377
|
+
if (currentText.trim()) {
|
|
378
|
+
nodes.push({
|
|
379
|
+
type: 'text',
|
|
380
|
+
value: currentText.trim(),
|
|
381
|
+
location: baseLocation
|
|
382
|
+
})
|
|
383
|
+
currentText = ''
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const exprCode = content.slice(i + 1, endBrace - 1).trim()
|
|
387
|
+
if (exprCode) {
|
|
388
|
+
nodes.push({
|
|
389
|
+
type: 'expression',
|
|
390
|
+
expression: exprCode,
|
|
391
|
+
location: baseLocation,
|
|
392
|
+
loopContext
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
i = endBrace
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
currentText += char
|
|
401
|
+
i++
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Add remaining text
|
|
405
|
+
if (currentText.trim()) {
|
|
406
|
+
nodes.push({
|
|
407
|
+
type: 'text',
|
|
408
|
+
value: currentText.trim(),
|
|
409
|
+
location: baseLocation
|
|
410
|
+
})
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return nodes
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Parse a single JSX element
|
|
418
|
+
*/
|
|
419
|
+
function parseJSXElement(
|
|
420
|
+
code: string,
|
|
421
|
+
baseLocation: SourceLocation,
|
|
422
|
+
filePath: string,
|
|
423
|
+
expressions: ExpressionIR[],
|
|
424
|
+
loopContext?: LoopContext
|
|
425
|
+
): TemplateNode | null {
|
|
426
|
+
const result = parseJSXElementWithEnd(code, 0, baseLocation, filePath, expressions, loopContext)
|
|
427
|
+
return result ? result.node : null
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Parse JSX element and return end index
|
|
432
|
+
*/
|
|
433
|
+
function parseJSXElementWithEnd(
|
|
434
|
+
code: string,
|
|
435
|
+
startIndex: number,
|
|
436
|
+
baseLocation: SourceLocation,
|
|
437
|
+
filePath: string,
|
|
438
|
+
expressions: ExpressionIR[],
|
|
439
|
+
loopContext?: LoopContext
|
|
440
|
+
): { node: TemplateNode; endIndex: number } | null {
|
|
441
|
+
// Extract tag name
|
|
442
|
+
const tagMatch = code.slice(startIndex).match(/^<([a-zA-Z][a-zA-Z0-9.]*)/)
|
|
443
|
+
if (!tagMatch) return null
|
|
444
|
+
|
|
445
|
+
const tagName = tagMatch[1]!
|
|
446
|
+
let i = startIndex + tagMatch[0].length
|
|
447
|
+
|
|
448
|
+
// Parse attributes (simplified)
|
|
449
|
+
const attributes: Array<{ name: string; value: string; location: SourceLocation }> = []
|
|
450
|
+
|
|
451
|
+
// Skip whitespace and parse attributes until > or />
|
|
452
|
+
while (i < code.length) {
|
|
453
|
+
// Skip whitespace
|
|
454
|
+
while (i < code.length && /\s/.test(code[i]!)) i++
|
|
455
|
+
|
|
456
|
+
// Check for end of opening tag
|
|
457
|
+
if (code[i] === '>') {
|
|
458
|
+
i++
|
|
459
|
+
break
|
|
460
|
+
}
|
|
461
|
+
if (code[i] === '/' && code[i + 1] === '>') {
|
|
462
|
+
// Self-closing tag
|
|
463
|
+
const isComponent = tagName[0] === tagName[0]!.toUpperCase()
|
|
464
|
+
const node: TemplateNode = isComponent ? {
|
|
465
|
+
type: 'component',
|
|
466
|
+
name: tagName,
|
|
467
|
+
attributes: attributes.map(a => ({ ...a, value: a.value })),
|
|
468
|
+
children: [],
|
|
469
|
+
location: baseLocation,
|
|
470
|
+
loopContext
|
|
471
|
+
} : {
|
|
472
|
+
type: 'element',
|
|
473
|
+
tag: tagName.toLowerCase(),
|
|
474
|
+
attributes: attributes.map(a => ({ ...a, value: a.value })),
|
|
475
|
+
children: [],
|
|
476
|
+
location: baseLocation,
|
|
477
|
+
loopContext
|
|
478
|
+
}
|
|
479
|
+
return { node, endIndex: i + 2 }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Parse attribute name
|
|
483
|
+
const attrMatch = code.slice(i).match(/^([a-zA-Z_][a-zA-Z0-9_-]*)/)
|
|
484
|
+
if (!attrMatch) {
|
|
485
|
+
i++
|
|
486
|
+
continue
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const attrName = attrMatch[1]!
|
|
490
|
+
i += attrName.length
|
|
491
|
+
|
|
492
|
+
// Skip whitespace
|
|
493
|
+
while (i < code.length && /\s/.test(code[i]!)) i++
|
|
494
|
+
|
|
495
|
+
// Check for value
|
|
496
|
+
if (code[i] !== '=') {
|
|
497
|
+
attributes.push({ name: attrName, value: 'true', location: baseLocation })
|
|
498
|
+
continue
|
|
499
|
+
}
|
|
500
|
+
i++ // Skip =
|
|
501
|
+
|
|
502
|
+
// Skip whitespace
|
|
503
|
+
while (i < code.length && /\s/.test(code[i]!)) i++
|
|
504
|
+
|
|
505
|
+
// Parse value
|
|
506
|
+
if (code[i] === '"' || code[i] === "'") {
|
|
507
|
+
const quote = code[i]
|
|
508
|
+
let endQuote = i + 1
|
|
509
|
+
while (endQuote < code.length && code[endQuote] !== quote) {
|
|
510
|
+
if (code[endQuote] === '\\') endQuote++
|
|
511
|
+
endQuote++
|
|
512
|
+
}
|
|
513
|
+
attributes.push({ name: attrName, value: code.slice(i + 1, endQuote), location: baseLocation })
|
|
514
|
+
i = endQuote + 1
|
|
515
|
+
} else if (code[i] === '{') {
|
|
516
|
+
const endBrace = findBalancedBraceEnd(code, i)
|
|
517
|
+
if (endBrace !== -1) {
|
|
518
|
+
attributes.push({ name: attrName, value: code.slice(i, endBrace), location: baseLocation })
|
|
519
|
+
i = endBrace
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Parse children until closing tag
|
|
525
|
+
const closeTag = `</${tagName}>`
|
|
526
|
+
const closeIndex = findClosingTag(code, i, tagName)
|
|
527
|
+
|
|
528
|
+
let children: TemplateNode[] = []
|
|
529
|
+
if (closeIndex !== -1 && closeIndex > i) {
|
|
530
|
+
const childContent = code.slice(i, closeIndex)
|
|
531
|
+
children = parseJSXChildren(childContent, baseLocation, filePath, expressions, loopContext)
|
|
532
|
+
i = closeIndex + closeTag.length
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const isComponent = tagName[0] === tagName[0]!.toUpperCase()
|
|
536
|
+
const node: TemplateNode = isComponent ? {
|
|
537
|
+
type: 'component',
|
|
538
|
+
name: tagName,
|
|
539
|
+
attributes: attributes.map(a => ({ ...a, value: a.value })),
|
|
540
|
+
children,
|
|
541
|
+
location: baseLocation,
|
|
542
|
+
loopContext
|
|
543
|
+
} : {
|
|
544
|
+
type: 'element',
|
|
545
|
+
tag: tagName.toLowerCase(),
|
|
546
|
+
attributes: attributes.map(a => ({ ...a, value: a.value })),
|
|
547
|
+
children,
|
|
548
|
+
location: baseLocation,
|
|
549
|
+
loopContext
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { node, endIndex: i }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Find closing tag for an element
|
|
557
|
+
*/
|
|
558
|
+
function findClosingTag(code: string, startIndex: number, tagName: string): number {
|
|
559
|
+
const closeTag = `</${tagName}>`
|
|
560
|
+
let depth = 1
|
|
561
|
+
let i = startIndex
|
|
562
|
+
|
|
563
|
+
while (i < code.length && depth > 0) {
|
|
564
|
+
// Check for closing tag
|
|
565
|
+
if (code.slice(i, i + closeTag.length) === closeTag) {
|
|
566
|
+
depth--
|
|
567
|
+
if (depth === 0) return i
|
|
568
|
+
i += closeTag.length
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check for opening tag (same name, nested)
|
|
573
|
+
const openPattern = new RegExp(`^<${tagName}(?:\\s|>|/>)`)
|
|
574
|
+
const match = code.slice(i).match(openPattern)
|
|
575
|
+
if (match) {
|
|
576
|
+
// Check if self-closing
|
|
577
|
+
const selfClosing = code.slice(i).match(new RegExp(`^<${tagName}[^>]*/>`))
|
|
578
|
+
if (!selfClosing) {
|
|
579
|
+
depth++
|
|
580
|
+
}
|
|
581
|
+
i += match[0].length
|
|
582
|
+
continue
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
i++
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return -1
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Find balanced brace end
|
|
593
|
+
*/
|
|
594
|
+
function findBalancedBraceEnd(code: string, startIndex: number): number {
|
|
595
|
+
if (code[startIndex] !== '{') return -1
|
|
596
|
+
|
|
597
|
+
let depth = 1
|
|
598
|
+
let i = startIndex + 1
|
|
599
|
+
let inString = false
|
|
600
|
+
let stringChar = ''
|
|
601
|
+
|
|
602
|
+
while (i < code.length && depth > 0) {
|
|
603
|
+
const char = code[i]
|
|
604
|
+
const prevChar = code[i - 1]
|
|
605
|
+
|
|
606
|
+
// Handle escape
|
|
607
|
+
if (prevChar === '\\') {
|
|
608
|
+
i++
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Handle strings
|
|
613
|
+
if (!inString && (char === '"' || char === "'")) {
|
|
614
|
+
inString = true
|
|
615
|
+
stringChar = char
|
|
616
|
+
i++
|
|
617
|
+
continue
|
|
618
|
+
}
|
|
619
|
+
if (inString && char === stringChar) {
|
|
620
|
+
inString = false
|
|
621
|
+
i++
|
|
622
|
+
continue
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (!inString) {
|
|
626
|
+
if (char === '{') depth++
|
|
627
|
+
else if (char === '}') depth--
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
i++
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return depth === 0 ? i : -1
|
|
634
|
+
}
|