@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,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot Resolution - Compound Component Model
|
|
3
|
+
*
|
|
4
|
+
* Resolves slots using compound component pattern (Card.Header, Card.Body)
|
|
5
|
+
* NOT template tags. This matches React/Astro semantics.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: Slot content must preserve the parent reactive scope.
|
|
8
|
+
* Components are purely structural transforms - they don't create new reactive boundaries.
|
|
9
|
+
*
|
|
10
|
+
* Example usage:
|
|
11
|
+
* <Card>
|
|
12
|
+
* <Card.Header><h3>Title</h3></Card.Header>
|
|
13
|
+
* <p>Body content goes to default slot</p>
|
|
14
|
+
* <Card.Footer><Button>OK</Button></Card.Footer>
|
|
15
|
+
* </Card>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { TemplateNode, ComponentNode, ElementNode, LoopContext } from '../ir/types'
|
|
19
|
+
|
|
20
|
+
export interface ResolvedSlots {
|
|
21
|
+
default: TemplateNode[]
|
|
22
|
+
named: Map<string, TemplateNode[]>
|
|
23
|
+
// Preserve the parent's reactive scope for slot content
|
|
24
|
+
parentLoopContext?: LoopContext
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Extract slots from component children using compound component pattern
|
|
29
|
+
*
|
|
30
|
+
* Children named `ParentComponent.SlotName` become named slots.
|
|
31
|
+
* All other children go to the default slot.
|
|
32
|
+
* Preserves the parent's reactive scope (loopContext) for all slot content.
|
|
33
|
+
*
|
|
34
|
+
* @param parentName - Name of the parent component (e.g., "Card")
|
|
35
|
+
* @param children - Child nodes from component usage
|
|
36
|
+
* @param parentLoopContext - The reactive scope from the parent (must be preserved)
|
|
37
|
+
*/
|
|
38
|
+
export function extractSlotsFromChildren(
|
|
39
|
+
parentName: string,
|
|
40
|
+
children: TemplateNode[],
|
|
41
|
+
parentLoopContext?: LoopContext
|
|
42
|
+
): ResolvedSlots {
|
|
43
|
+
const defaultSlot: TemplateNode[] = []
|
|
44
|
+
const namedSlots = new Map<string, TemplateNode[]>()
|
|
45
|
+
|
|
46
|
+
for (const child of children) {
|
|
47
|
+
// Check if this is a compound component (e.g., Card.Header)
|
|
48
|
+
if (child.type === 'component') {
|
|
49
|
+
const compoundMatch = parseCompoundName(child.name, parentName)
|
|
50
|
+
|
|
51
|
+
if (compoundMatch) {
|
|
52
|
+
// This is a named slot (e.g., Card.Header -> "header")
|
|
53
|
+
const slotName = compoundMatch.toLowerCase()
|
|
54
|
+
|
|
55
|
+
if (!namedSlots.has(slotName)) {
|
|
56
|
+
namedSlots.set(slotName, [])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The compound component's children become the slot content
|
|
60
|
+
// Preserve parent's loopContext on each child
|
|
61
|
+
const scopedChildren = child.children.map(c =>
|
|
62
|
+
rebindNodeToScope(c, parentLoopContext)
|
|
63
|
+
)
|
|
64
|
+
namedSlots.get(slotName)!.push(...scopedChildren)
|
|
65
|
+
} else {
|
|
66
|
+
// Regular component, goes to default slot
|
|
67
|
+
// Preserve parent's loopContext
|
|
68
|
+
defaultSlot.push(rebindNodeToScope(child, parentLoopContext))
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
// Elements, text, expressions go to default slot
|
|
72
|
+
// Preserve parent's loopContext
|
|
73
|
+
defaultSlot.push(rebindNodeToScope(child, parentLoopContext))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
default: defaultSlot,
|
|
79
|
+
named: namedSlots,
|
|
80
|
+
parentLoopContext
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Rebind a node to the parent's reactive scope
|
|
86
|
+
*
|
|
87
|
+
* This ensures that expressions and event bindings in slot content
|
|
88
|
+
* remain connected to the parent component's reactive graph.
|
|
89
|
+
* Components must be purely structural - they don't create new reactive boundaries.
|
|
90
|
+
*/
|
|
91
|
+
function rebindNodeToScope(node: TemplateNode, loopContext?: LoopContext): TemplateNode {
|
|
92
|
+
// If no parent scope to preserve, return as-is
|
|
93
|
+
if (!loopContext) {
|
|
94
|
+
return node
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Merge the parent's loopContext with existing loopContext
|
|
98
|
+
// Parent scope takes precedence to ensure reactivity flows through
|
|
99
|
+
switch (node.type) {
|
|
100
|
+
case 'expression':
|
|
101
|
+
return {
|
|
102
|
+
...node,
|
|
103
|
+
loopContext: mergeLoopContext(node.loopContext, loopContext)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'element':
|
|
107
|
+
return {
|
|
108
|
+
...node,
|
|
109
|
+
loopContext: mergeLoopContext(node.loopContext, loopContext),
|
|
110
|
+
attributes: node.attributes.map(attr => ({
|
|
111
|
+
...attr,
|
|
112
|
+
loopContext: attr.loopContext
|
|
113
|
+
? mergeLoopContext(attr.loopContext, loopContext)
|
|
114
|
+
: loopContext
|
|
115
|
+
})),
|
|
116
|
+
children: node.children.map(c => rebindNodeToScope(c, loopContext))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'component':
|
|
120
|
+
return {
|
|
121
|
+
...node,
|
|
122
|
+
loopContext: mergeLoopContext(node.loopContext, loopContext),
|
|
123
|
+
children: node.children.map(c => rebindNodeToScope(c, loopContext))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case 'text':
|
|
127
|
+
// Text nodes don't have reactive bindings
|
|
128
|
+
return node
|
|
129
|
+
|
|
130
|
+
default:
|
|
131
|
+
return node
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Merge two loop contexts, combining their variables
|
|
137
|
+
* Parent context variables take precedence (added last so they shadow)
|
|
138
|
+
*/
|
|
139
|
+
function mergeLoopContext(existing?: LoopContext, parent?: LoopContext): LoopContext | undefined {
|
|
140
|
+
if (!existing && !parent) return undefined
|
|
141
|
+
if (!existing) return parent
|
|
142
|
+
if (!parent) return existing
|
|
143
|
+
|
|
144
|
+
// Combine variables, parent variables shadow existing
|
|
145
|
+
const allVars = new Set([...existing.variables, ...parent.variables])
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
variables: Array.from(allVars),
|
|
149
|
+
mapSource: parent.mapSource || existing.mapSource
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse compound component name
|
|
155
|
+
*
|
|
156
|
+
* Given "Card.Header" and parent "Card", returns "Header"
|
|
157
|
+
* Given "Card.Footer" and parent "Card", returns "Footer"
|
|
158
|
+
* Given "Button" and parent "Card", returns null (not a compound)
|
|
159
|
+
*
|
|
160
|
+
* @param componentName - Full component name (e.g., "Card.Header")
|
|
161
|
+
* @param parentName - Parent component name (e.g., "Card")
|
|
162
|
+
* @returns Slot name or null if not a compound of this parent
|
|
163
|
+
*/
|
|
164
|
+
function parseCompoundName(componentName: string, parentName: string): string | null {
|
|
165
|
+
const prefix = `${parentName}.`
|
|
166
|
+
|
|
167
|
+
if (componentName.startsWith(prefix)) {
|
|
168
|
+
return componentName.slice(prefix.length)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve slots in component template nodes
|
|
176
|
+
*
|
|
177
|
+
* Replaces <slot /> and <slot name="X" /> with children from resolved slots.
|
|
178
|
+
* All slot content is rebound to the parent's reactive scope.
|
|
179
|
+
*/
|
|
180
|
+
export function resolveSlots(
|
|
181
|
+
componentNodes: TemplateNode[],
|
|
182
|
+
slots: ResolvedSlots
|
|
183
|
+
): TemplateNode[] {
|
|
184
|
+
const resolved: TemplateNode[] = []
|
|
185
|
+
|
|
186
|
+
for (const node of componentNodes) {
|
|
187
|
+
const result = resolveNode(node, slots)
|
|
188
|
+
if (Array.isArray(result)) {
|
|
189
|
+
resolved.push(...result)
|
|
190
|
+
} else {
|
|
191
|
+
resolved.push(result)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return resolved
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve a single node, replacing slot tags with content
|
|
200
|
+
* Ensures all slot content maintains the parent's reactive scope
|
|
201
|
+
*/
|
|
202
|
+
function resolveNode(
|
|
203
|
+
node: TemplateNode,
|
|
204
|
+
slots: ResolvedSlots
|
|
205
|
+
): TemplateNode | TemplateNode[] {
|
|
206
|
+
if (node.type === 'element' && node.tag === 'slot') {
|
|
207
|
+
// This is a slot tag - replace it with children
|
|
208
|
+
const nameAttr = node.attributes.find(attr => attr.name === 'name')
|
|
209
|
+
const slotName = typeof nameAttr?.value === 'string' ? nameAttr.value : null
|
|
210
|
+
|
|
211
|
+
if (slotName) {
|
|
212
|
+
// Named slot
|
|
213
|
+
const namedChildren = slots.named.get(slotName.toLowerCase()) || []
|
|
214
|
+
|
|
215
|
+
// If no children provided and slot has fallback content, use fallback
|
|
216
|
+
if (namedChildren.length === 0 && node.children.length > 0) {
|
|
217
|
+
return node.children
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Return slot content (already scoped during extraction)
|
|
221
|
+
return namedChildren.length > 0 ? namedChildren : []
|
|
222
|
+
} else {
|
|
223
|
+
// Default slot
|
|
224
|
+
// If no children provided and slot has fallback content, use fallback
|
|
225
|
+
if (slots.default.length === 0 && node.children.length > 0) {
|
|
226
|
+
return node.children
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Return slot content (already scoped during extraction)
|
|
230
|
+
return slots.default
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (node.type === 'element') {
|
|
235
|
+
// Recursively resolve slots in children
|
|
236
|
+
const resolvedChildren: TemplateNode[] = []
|
|
237
|
+
for (const child of node.children) {
|
|
238
|
+
const result = resolveNode(child, slots)
|
|
239
|
+
if (Array.isArray(result)) {
|
|
240
|
+
resolvedChildren.push(...result)
|
|
241
|
+
} else {
|
|
242
|
+
resolvedChildren.push(result)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
...node,
|
|
248
|
+
children: resolvedChildren
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (node.type === 'component') {
|
|
253
|
+
// Recursively resolve slots in component children
|
|
254
|
+
const resolvedChildren: TemplateNode[] = []
|
|
255
|
+
for (const child of node.children) {
|
|
256
|
+
const result = resolveNode(child, slots)
|
|
257
|
+
if (Array.isArray(result)) {
|
|
258
|
+
resolvedChildren.push(...result)
|
|
259
|
+
} else {
|
|
260
|
+
resolvedChildren.push(result)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
...node,
|
|
266
|
+
children: resolvedChildren
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Text and expression nodes pass through unchanged
|
|
271
|
+
return node
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check if a node tree contains any slots
|
|
276
|
+
*/
|
|
277
|
+
export function hasSlots(nodes: TemplateNode[]): boolean {
|
|
278
|
+
function checkNode(node: TemplateNode): boolean {
|
|
279
|
+
if (node.type === 'element') {
|
|
280
|
+
if (node.tag === 'slot') {
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
return node.children.some(checkNode)
|
|
284
|
+
}
|
|
285
|
+
if (node.type === 'component') {
|
|
286
|
+
return node.children.some(checkNode)
|
|
287
|
+
}
|
|
288
|
+
return false
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return nodes.some(checkNode)
|
|
292
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariant Validation
|
|
3
|
+
*
|
|
4
|
+
* Compile-time checks that enforce Zenith's non-negotiable invariants.
|
|
5
|
+
* If any invariant is violated, compilation fails immediately with a clear explanation.
|
|
6
|
+
*
|
|
7
|
+
* INVARIANTS:
|
|
8
|
+
* 1. Components are structural only — no reactive scopes, no state
|
|
9
|
+
* 2. Reactivity is scope-owned — flows through components, never into them
|
|
10
|
+
* 3. Slot projection preserves identity — loopContext is preserved or merged upward
|
|
11
|
+
* 4. Attribute ownership belongs to usage — must be forwarded to semantic root
|
|
12
|
+
* 5. All resolution is compile-time — no unresolved components
|
|
13
|
+
* 6. Failure is a compiler error — no silent degradation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ZenIR, TemplateNode, ElementNode, ComponentNode, LoopContext } from '../ir/types'
|
|
17
|
+
import { InvariantError } from '../errors/compilerError'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Invariant Codes
|
|
21
|
+
*
|
|
22
|
+
* Each invariant has a unique ID for tracking and documentation.
|
|
23
|
+
*/
|
|
24
|
+
export const INVARIANT = {
|
|
25
|
+
LOOP_CONTEXT_LOST: 'INV001',
|
|
26
|
+
ATTRIBUTE_NOT_FORWARDED: 'INV002',
|
|
27
|
+
UNRESOLVED_COMPONENT: 'INV003',
|
|
28
|
+
REACTIVE_BOUNDARY: 'INV004',
|
|
29
|
+
TEMPLATE_TAG: 'INV005',
|
|
30
|
+
SLOT_ATTRIBUTE: 'INV006',
|
|
31
|
+
ORPHAN_COMPOUND: 'INV007',
|
|
32
|
+
NON_ENUMERABLE_JSX: 'INV008',
|
|
33
|
+
} as const
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Guarantee Messages
|
|
37
|
+
*
|
|
38
|
+
* Human-readable explanations of what each invariant guarantees.
|
|
39
|
+
*/
|
|
40
|
+
const GUARANTEES: Record<string, string> = {
|
|
41
|
+
[INVARIANT.LOOP_CONTEXT_LOST]:
|
|
42
|
+
'Slot content retains its original reactive scope. Expressions and event handlers continue to work after projection.',
|
|
43
|
+
[INVARIANT.ATTRIBUTE_NOT_FORWARDED]:
|
|
44
|
+
'Attributes passed to components are forwarded to the semantic root element.',
|
|
45
|
+
[INVARIANT.UNRESOLVED_COMPONENT]:
|
|
46
|
+
'All components are resolved at compile time. No runtime component discovery.',
|
|
47
|
+
[INVARIANT.REACTIVE_BOUNDARY]:
|
|
48
|
+
'Components are purely structural transforms. They do not create reactive scopes.',
|
|
49
|
+
[INVARIANT.TEMPLATE_TAG]:
|
|
50
|
+
'Named slots use compound component pattern (Card.Header), not <template> tags.',
|
|
51
|
+
[INVARIANT.SLOT_ATTRIBUTE]:
|
|
52
|
+
'Named slots use compound component pattern (Card.Header), not slot="" attributes.',
|
|
53
|
+
[INVARIANT.ORPHAN_COMPOUND]:
|
|
54
|
+
'Compound slot markers (Card.Header) must be direct children of their parent component.',
|
|
55
|
+
[INVARIANT.NON_ENUMERABLE_JSX]:
|
|
56
|
+
'JSX expressions must have statically enumerable output. The compiler must know all possible DOM shapes at compile time.',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validate all invariants on a compiled IR
|
|
61
|
+
*
|
|
62
|
+
* Called after component resolution to ensure all invariants hold.
|
|
63
|
+
* Throws InvariantError if any invariant is violated.
|
|
64
|
+
*/
|
|
65
|
+
export function validateInvariants(ir: ZenIR, filePath: string): void {
|
|
66
|
+
validateNoUnresolvedComponents(ir.template.nodes, filePath)
|
|
67
|
+
// Additional invariant checks can be added here
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* INV003: Validate that no unresolved components remain
|
|
72
|
+
*
|
|
73
|
+
* After component resolution, all ComponentNode instances should be
|
|
74
|
+
* resolved to ElementNode instances. If any remain, the compiler failed.
|
|
75
|
+
*/
|
|
76
|
+
export function validateNoUnresolvedComponents(
|
|
77
|
+
nodes: TemplateNode[],
|
|
78
|
+
filePath: string
|
|
79
|
+
): void {
|
|
80
|
+
for (const node of nodes) {
|
|
81
|
+
checkNodeForUnresolvedComponent(node, filePath)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function checkNodeForUnresolvedComponent(node: TemplateNode, filePath: string): void {
|
|
86
|
+
if (node.type === 'component') {
|
|
87
|
+
throw new InvariantError(
|
|
88
|
+
INVARIANT.UNRESOLVED_COMPONENT,
|
|
89
|
+
`Unresolved component: <${node.name}>. Component was not found or failed to resolve.`,
|
|
90
|
+
GUARANTEES[INVARIANT.UNRESOLVED_COMPONENT]!,
|
|
91
|
+
filePath,
|
|
92
|
+
node.location.line,
|
|
93
|
+
node.location.column
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (node.type === 'element') {
|
|
98
|
+
for (const child of node.children) {
|
|
99
|
+
checkNodeForUnresolvedComponent(child, filePath)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* INV005: Validate no <template> tags are used
|
|
106
|
+
*
|
|
107
|
+
* Zenith uses compound component pattern for named slots.
|
|
108
|
+
* <template> tags are a different mental model and are forbidden.
|
|
109
|
+
*/
|
|
110
|
+
export function validateNoTemplateTags(
|
|
111
|
+
nodes: TemplateNode[],
|
|
112
|
+
filePath: string
|
|
113
|
+
): void {
|
|
114
|
+
for (const node of nodes) {
|
|
115
|
+
checkNodeForTemplateTag(node, filePath)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function checkNodeForTemplateTag(node: TemplateNode, filePath: string): void {
|
|
120
|
+
if (node.type === 'element' && node.tag === 'template') {
|
|
121
|
+
throw new InvariantError(
|
|
122
|
+
INVARIANT.TEMPLATE_TAG,
|
|
123
|
+
`<template> tags are forbidden in Zenith. Use compound components (e.g., Card.Header) for named slots.`,
|
|
124
|
+
GUARANTEES[INVARIANT.TEMPLATE_TAG]!,
|
|
125
|
+
filePath,
|
|
126
|
+
node.location.line,
|
|
127
|
+
node.location.column
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (node.type === 'element') {
|
|
132
|
+
for (const child of node.children) {
|
|
133
|
+
checkNodeForTemplateTag(child, filePath)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* INV006: Validate no slot="" attributes are used
|
|
140
|
+
*
|
|
141
|
+
* Zenith uses compound component pattern, not slot props.
|
|
142
|
+
*/
|
|
143
|
+
export function validateNoSlotAttributes(
|
|
144
|
+
nodes: TemplateNode[],
|
|
145
|
+
filePath: string
|
|
146
|
+
): void {
|
|
147
|
+
for (const node of nodes) {
|
|
148
|
+
checkNodeForSlotAttribute(node, filePath)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkNodeForSlotAttribute(node: TemplateNode, filePath: string): void {
|
|
153
|
+
if (node.type === 'element') {
|
|
154
|
+
const slotAttr = node.attributes.find(attr => attr.name === 'slot')
|
|
155
|
+
if (slotAttr) {
|
|
156
|
+
throw new InvariantError(
|
|
157
|
+
INVARIANT.SLOT_ATTRIBUTE,
|
|
158
|
+
`slot="${typeof slotAttr.value === 'string' ? slotAttr.value : '...'}" attribute is forbidden. Use compound components (e.g., Card.Header) for named slots.`,
|
|
159
|
+
GUARANTEES[INVARIANT.SLOT_ATTRIBUTE]!,
|
|
160
|
+
filePath,
|
|
161
|
+
slotAttr.location.line,
|
|
162
|
+
slotAttr.location.column
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const child of node.children) {
|
|
167
|
+
checkNodeForSlotAttribute(child, filePath)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (node.type === 'component') {
|
|
172
|
+
for (const child of node.children) {
|
|
173
|
+
checkNodeForSlotAttribute(child, filePath)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* INV001: Validate loopContext is preserved during slot projection
|
|
180
|
+
*
|
|
181
|
+
* When slot content is moved from the usage site to the slot target,
|
|
182
|
+
* the loopContext must be preserved so reactivity continues to work.
|
|
183
|
+
*
|
|
184
|
+
* @param originalNodes - Nodes before slot projection
|
|
185
|
+
* @param projectedNodes - Nodes after slot projection
|
|
186
|
+
*/
|
|
187
|
+
export function validateLoopContextPreservation(
|
|
188
|
+
originalNodes: TemplateNode[],
|
|
189
|
+
projectedNodes: TemplateNode[],
|
|
190
|
+
filePath: string
|
|
191
|
+
): void {
|
|
192
|
+
// Collect all loopContext from original nodes
|
|
193
|
+
const originalContexts = collectLoopContexts(originalNodes)
|
|
194
|
+
|
|
195
|
+
// Verify all contexts are still present in projected nodes
|
|
196
|
+
const projectedContexts = collectLoopContexts(projectedNodes)
|
|
197
|
+
|
|
198
|
+
for (const entry of Array.from(originalContexts.entries())) {
|
|
199
|
+
const [nodeId, context] = entry
|
|
200
|
+
const projected = projectedContexts.get(nodeId)
|
|
201
|
+
if (context && !projected) {
|
|
202
|
+
// loopContext was lost during projection
|
|
203
|
+
// This is a compiler bug, not a user error
|
|
204
|
+
throw new InvariantError(
|
|
205
|
+
INVARIANT.LOOP_CONTEXT_LOST,
|
|
206
|
+
`Reactive scope was lost during slot projection. This is a Zenith compiler bug.`,
|
|
207
|
+
GUARANTEES[INVARIANT.LOOP_CONTEXT_LOST]!,
|
|
208
|
+
filePath,
|
|
209
|
+
1, // We don't have precise location, use line 1
|
|
210
|
+
1
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function collectLoopContexts(nodes: TemplateNode[]): Map<string, LoopContext | undefined> {
|
|
217
|
+
const contexts = new Map<string, LoopContext | undefined>()
|
|
218
|
+
let nodeId = 0
|
|
219
|
+
|
|
220
|
+
function visit(node: TemplateNode): void {
|
|
221
|
+
const id = `node_${nodeId++}`
|
|
222
|
+
|
|
223
|
+
if (node.type === 'expression') {
|
|
224
|
+
contexts.set(id, node.loopContext)
|
|
225
|
+
} else if (node.type === 'element') {
|
|
226
|
+
contexts.set(id, node.loopContext)
|
|
227
|
+
for (const attr of node.attributes) {
|
|
228
|
+
if (attr.loopContext) {
|
|
229
|
+
contexts.set(`${id}_attr_${attr.name}`, attr.loopContext)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const child of node.children) {
|
|
233
|
+
visit(child)
|
|
234
|
+
}
|
|
235
|
+
} else if (node.type === 'component') {
|
|
236
|
+
contexts.set(id, node.loopContext)
|
|
237
|
+
for (const child of node.children) {
|
|
238
|
+
visit(child)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const node of nodes) {
|
|
244
|
+
visit(node)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return contexts
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* INV007: Throw error for orphan compound slot markers
|
|
252
|
+
*
|
|
253
|
+
* Called when a compound component (Card.Header) is found outside
|
|
254
|
+
* its parent component context.
|
|
255
|
+
*/
|
|
256
|
+
export function throwOrphanCompoundError(
|
|
257
|
+
componentName: string,
|
|
258
|
+
parentName: string,
|
|
259
|
+
filePath: string,
|
|
260
|
+
line: number,
|
|
261
|
+
column: number
|
|
262
|
+
): never {
|
|
263
|
+
throw new InvariantError(
|
|
264
|
+
INVARIANT.ORPHAN_COMPOUND,
|
|
265
|
+
`<${componentName}> must be a direct child of <${parentName}>. Compound slot markers cannot be used outside their parent component.`,
|
|
266
|
+
GUARANTEES[INVARIANT.ORPHAN_COMPOUND]!,
|
|
267
|
+
filePath,
|
|
268
|
+
line,
|
|
269
|
+
column
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* INV003: Throw error for unresolved component
|
|
275
|
+
*
|
|
276
|
+
* Called when a component definition cannot be found.
|
|
277
|
+
*/
|
|
278
|
+
export function throwUnresolvedComponentError(
|
|
279
|
+
componentName: string,
|
|
280
|
+
filePath: string,
|
|
281
|
+
line: number,
|
|
282
|
+
column: number
|
|
283
|
+
): never {
|
|
284
|
+
throw new InvariantError(
|
|
285
|
+
INVARIANT.UNRESOLVED_COMPONENT,
|
|
286
|
+
`Component <${componentName}> not found. All components must be defined in the components directory.`,
|
|
287
|
+
GUARANTEES[INVARIANT.UNRESOLVED_COMPONENT]!,
|
|
288
|
+
filePath,
|
|
289
|
+
line,
|
|
290
|
+
column
|
|
291
|
+
)
|
|
292
|
+
}
|