@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.
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Component Resolution
3
+ *
4
+ * Resolves component nodes in IR by inlining component templates with slot substitution.
5
+ * Uses compound component pattern for named slots (Card.Header, Card.Footer).
6
+ */
7
+
8
+ import type { TemplateNode, ComponentNode, ElementNode, ZenIR, LoopContext } from '../ir/types'
9
+ import type { ComponentMetadata } from '../discovery/componentDiscovery'
10
+ import { extractSlotsFromChildren, resolveSlots } from './slotResolver'
11
+ import { throwOrphanCompoundError, throwUnresolvedComponentError } from '../validate/invariants'
12
+
13
+ // Track which components have been used (for style collection)
14
+ const usedComponents = new Set<string>()
15
+
16
+ /**
17
+ * Resolve all component nodes in a template IR
18
+ *
19
+ * Recursively replaces ComponentNode instances with their resolved templates
20
+ * Also collects styles from used components and adds them to the IR
21
+ */
22
+ export function resolveComponentsInIR(
23
+ ir: ZenIR,
24
+ components: Map<string, ComponentMetadata>
25
+ ): ZenIR {
26
+ // Clear used components tracking for this compilation
27
+ usedComponents.clear()
28
+
29
+ // Resolve components in template nodes
30
+ const resolvedNodes = resolveComponentsInNodes(ir.template.nodes, components)
31
+
32
+ // Collect styles from all used components
33
+ const componentStyles = Array.from(usedComponents)
34
+ .map(name => components.get(name))
35
+ .filter((meta): meta is ComponentMetadata => meta !== undefined && meta.styles.length > 0)
36
+ .flatMap(meta => meta.styles.map(raw => ({ raw })))
37
+
38
+ return {
39
+ ...ir,
40
+ template: {
41
+ ...ir.template,
42
+ nodes: resolvedNodes
43
+ },
44
+ // Merge component styles with existing page styles
45
+ styles: [...ir.styles, ...componentStyles]
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolve component nodes in a list of template nodes
51
+ */
52
+ function resolveComponentsInNodes(
53
+ nodes: TemplateNode[],
54
+ components: Map<string, ComponentMetadata>,
55
+ depth: number = 0
56
+ ): TemplateNode[] {
57
+ const resolved: TemplateNode[] = []
58
+
59
+ for (const node of nodes) {
60
+ const resolvedNode = resolveComponentNode(node, components, depth)
61
+
62
+ if (Array.isArray(resolvedNode)) {
63
+ resolved.push(...resolvedNode)
64
+ } else {
65
+ resolved.push(resolvedNode)
66
+ }
67
+ }
68
+
69
+ return resolved
70
+ }
71
+
72
+ /**
73
+ * Resolve a single component node
74
+ *
75
+ * If the node is a component, look up its definition and inline it with slot resolution.
76
+ * Otherwise, recursively process children.
77
+ */
78
+ function resolveComponentNode(
79
+ node: TemplateNode,
80
+ components: Map<string, ComponentMetadata>,
81
+ depth: number = 0
82
+ ): TemplateNode | TemplateNode[] {
83
+ // Handle component nodes
84
+ if (node.type === 'component') {
85
+ return resolveComponent(node, components, depth)
86
+ }
87
+
88
+ // Handle element nodes - recursively resolve children
89
+ if (node.type === 'element') {
90
+ const resolvedChildren = resolveComponentsInNodes(node.children, components, depth + 1)
91
+
92
+ return {
93
+ ...node,
94
+ children: resolvedChildren
95
+ }
96
+ }
97
+
98
+ // Text and expression nodes pass through unchanged
99
+ return node
100
+ }
101
+
102
+ /**
103
+ * Get base component name from compound name
104
+ *
105
+ * "Card.Header" -> "Card"
106
+ * "Button" -> "Button"
107
+ */
108
+ function getBaseComponentName(name: string): string {
109
+ const dotIndex = name.indexOf('.')
110
+ return dotIndex > 0 ? name.slice(0, dotIndex) : name
111
+ }
112
+
113
+ /**
114
+ * Check if a component name is a compound slot marker
115
+ *
116
+ * "Card.Header" -> true (if Card exists)
117
+ * "Card" -> false
118
+ * "Button" -> false
119
+ */
120
+ function isCompoundSlotMarker(name: string, components: Map<string, ComponentMetadata>): boolean {
121
+ const dotIndex = name.indexOf('.')
122
+ if (dotIndex <= 0) return false
123
+
124
+ const baseName = name.slice(0, dotIndex)
125
+ return components.has(baseName)
126
+ }
127
+
128
+ /**
129
+ * Resolve a component by inlining its template with slot substitution
130
+ */
131
+ function resolveComponent(
132
+ componentNode: ComponentNode,
133
+ components: Map<string, ComponentMetadata>,
134
+ depth: number = 0
135
+ ): TemplateNode | TemplateNode[] {
136
+ const componentName = componentNode.name
137
+
138
+ // Check if this is a compound slot marker (Card.Header, Card.Footer)
139
+ // These are handled by the parent component, not resolved directly
140
+ // INV007: Orphan compound slot markers are a compile-time error
141
+ if (isCompoundSlotMarker(componentName, components)) {
142
+ throwOrphanCompoundError(
143
+ componentName,
144
+ getBaseComponentName(componentName),
145
+ 'component', // filePath not available here, will be caught by caller
146
+ componentNode.location.line,
147
+ componentNode.location.column
148
+ )
149
+ }
150
+
151
+ // Look up component metadata
152
+ const componentMeta = components.get(componentName)
153
+
154
+ // INV003: Unresolved components are a compile-time error
155
+ if (!componentMeta) {
156
+ throwUnresolvedComponentError(
157
+ componentName,
158
+ 'component', // filePath not available here, will be caught by caller
159
+ componentNode.location.line,
160
+ componentNode.location.column
161
+ )
162
+ }
163
+
164
+ // Track this component as used (for style collection)
165
+ usedComponents.add(componentName)
166
+
167
+ // Extract slots from component children FIRST (before resolving nested components)
168
+ // This preserves compound component structure (Card.Header, Card.Footer)
169
+ // IMPORTANT: Pass parent's loopContext to preserve reactive scope
170
+ // Components are purely structural - they don't create new reactive boundaries
171
+ const slots = extractSlotsFromChildren(
172
+ componentName,
173
+ componentNode.children,
174
+ componentNode.loopContext // Preserve parent's reactive scope
175
+ )
176
+
177
+ // Now resolve nested components within the extracted slot content
178
+ const resolvedSlots = {
179
+ default: resolveComponentsInNodes(slots.default, components, depth + 1),
180
+ named: new Map<string, TemplateNode[]>(),
181
+ parentLoopContext: slots.parentLoopContext // Carry through the parent scope
182
+ }
183
+
184
+ for (const [slotName, slotContent] of slots.named) {
185
+ resolvedSlots.named.set(slotName, resolveComponentsInNodes(slotContent, components, depth + 1))
186
+ }
187
+
188
+ // Deep clone the component template nodes to avoid mutation
189
+ const templateNodes = JSON.parse(JSON.stringify(componentMeta.nodes)) as TemplateNode[]
190
+
191
+ // Resolve slots in component template
192
+ const resolvedTemplate = resolveSlots(templateNodes, resolvedSlots)
193
+
194
+ // Forward attributes from component usage to the root element
195
+ // This ensures onclick, class, and other attributes are preserved
196
+ const forwardedTemplate = forwardAttributesToRoot(
197
+ resolvedTemplate,
198
+ componentNode.attributes,
199
+ componentNode.loopContext
200
+ )
201
+
202
+ // Recursively resolve any nested components in the resolved template
203
+ const fullyResolved = resolveComponentsInNodes(forwardedTemplate, components, depth + 1)
204
+
205
+ return fullyResolved
206
+ }
207
+
208
+ /**
209
+ * Forward attributes from component usage to the template's root element
210
+ *
211
+ * When using <Button onclick="increment">Text</Button>,
212
+ * the onclick should be applied to the <button> element in Button.zen template.
213
+ */
214
+ function forwardAttributesToRoot(
215
+ nodes: TemplateNode[],
216
+ attributes: ComponentNode['attributes'],
217
+ loopContext?: LoopContext
218
+ ): TemplateNode[] {
219
+ if (attributes.length === 0) {
220
+ return nodes
221
+ }
222
+
223
+ // Find the first non-text element (the root element)
224
+ const rootIndex = nodes.findIndex(n => n.type === 'element')
225
+ if (rootIndex === -1) {
226
+ return nodes
227
+ }
228
+
229
+ const root = nodes[rootIndex] as ElementNode
230
+
231
+ // Merge attributes: component usage attributes override template defaults
232
+ // Also preserve the parent's loopContext on forwarded attributes
233
+ const mergedAttributes = [...root.attributes]
234
+
235
+ for (const attr of attributes) {
236
+ const existingIndex = mergedAttributes.findIndex(a => a.name === attr.name)
237
+
238
+ // Attach parent's loopContext to forwarded attributes to preserve reactivity
239
+ const forwardedAttr = {
240
+ ...attr,
241
+ loopContext: attr.loopContext || loopContext
242
+ }
243
+
244
+ if (existingIndex >= 0) {
245
+ const existingAttr = mergedAttributes[existingIndex]!
246
+ // Special handling for class: merge classes
247
+ if (attr.name === 'class' && typeof attr.value === 'string' && typeof existingAttr.value === 'string') {
248
+ mergedAttributes[existingIndex] = {
249
+ ...existingAttr,
250
+ value: `${existingAttr.value} ${attr.value}`
251
+ }
252
+ } else {
253
+ // Override other attributes
254
+ mergedAttributes[existingIndex] = forwardedAttr
255
+ }
256
+ } else {
257
+ // Add new attribute
258
+ mergedAttributes.push(forwardedAttr)
259
+ }
260
+ }
261
+
262
+ // Return updated nodes with root element having merged attributes
263
+ return [
264
+ ...nodes.slice(0, rootIndex),
265
+ {
266
+ ...root,
267
+ attributes: mergedAttributes,
268
+ loopContext: root.loopContext || loopContext
269
+ },
270
+ ...nodes.slice(rootIndex + 1)
271
+ ]
272
+ }
273
+
274
+ /**
275
+ * Check if an IR contains any component nodes
276
+ */
277
+ export function hasComponents(nodes: TemplateNode[]): boolean {
278
+ function checkNode(node: TemplateNode): boolean {
279
+ if (node.type === 'component') {
280
+ return true
281
+ }
282
+ if (node.type === 'element') {
283
+ return node.children.some(checkNode)
284
+ }
285
+ return false
286
+ }
287
+
288
+ return nodes.some(checkNode)
289
+ }