@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
package/cli/commands/dev.ts
CHANGED
|
@@ -90,6 +90,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
|
|
|
90
90
|
function compilePageInMemory(pagePath: string): CompiledPage | null {
|
|
91
91
|
try {
|
|
92
92
|
const layoutsDir = path.join(pagesDir, '../layouts')
|
|
93
|
+
const componentsDir = path.join(pagesDir, '../components')
|
|
93
94
|
const layouts = discoverLayouts(layoutsDir)
|
|
94
95
|
const source = fs.readFileSync(pagePath, 'utf-8')
|
|
95
96
|
|
|
@@ -98,7 +99,9 @@ export async function dev(options: DevOptions = {}): Promise<void> {
|
|
|
98
99
|
|
|
99
100
|
if (layoutToUse) processedSource = processLayout(source, layoutToUse)
|
|
100
101
|
|
|
101
|
-
const result = compileZenSource(processedSource, pagePath
|
|
102
|
+
const result = compileZenSource(processedSource, pagePath, {
|
|
103
|
+
componentsDir: fs.existsSync(componentsDir) ? componentsDir : undefined
|
|
104
|
+
})
|
|
102
105
|
if (!result.finalized) throw new Error('Compilation failed')
|
|
103
106
|
|
|
104
107
|
const routeDef = generateRouteDefinition(pagePath, pagesDir)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component Discovery
|
|
3
|
+
*
|
|
4
|
+
* Discovers and catalogs components in a Zenith project
|
|
5
|
+
* Similar to layout discovery but for reusable components
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs'
|
|
9
|
+
import * as path from 'path'
|
|
10
|
+
import { parseZenFile } from '../parse/parseZenFile'
|
|
11
|
+
import type { TemplateNode } from '../ir/types'
|
|
12
|
+
|
|
13
|
+
export interface SlotDefinition {
|
|
14
|
+
name: string | null // null = default slot, string = named slot
|
|
15
|
+
location: {
|
|
16
|
+
line: number
|
|
17
|
+
column: number
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ComponentMetadata {
|
|
22
|
+
name: string // Component name (e.g., "Card", "Button")
|
|
23
|
+
path: string // Absolute path to .zen file
|
|
24
|
+
template: string // Raw template HTML
|
|
25
|
+
nodes: TemplateNode[] // Parsed template nodes
|
|
26
|
+
slots: SlotDefinition[]
|
|
27
|
+
props: string[] // Declared props
|
|
28
|
+
styles: string[] // Raw CSS from <style> blocks
|
|
29
|
+
hasScript: boolean
|
|
30
|
+
hasStyles: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Discover all components in a directory
|
|
35
|
+
* @param baseDir - Base directory to search (e.g., src/components)
|
|
36
|
+
* @returns Map of component name to metadata
|
|
37
|
+
*/
|
|
38
|
+
export function discoverComponents(baseDir: string): Map<string, ComponentMetadata> {
|
|
39
|
+
const components = new Map<string, ComponentMetadata>()
|
|
40
|
+
|
|
41
|
+
// Check if components directory exists
|
|
42
|
+
if (!fs.existsSync(baseDir)) {
|
|
43
|
+
return components
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Recursively find all .zen files
|
|
47
|
+
const zenFiles = findZenFiles(baseDir)
|
|
48
|
+
|
|
49
|
+
for (const filePath of zenFiles) {
|
|
50
|
+
try {
|
|
51
|
+
const metadata = parseComponentFile(filePath)
|
|
52
|
+
if (metadata) {
|
|
53
|
+
components.set(metadata.name, metadata)
|
|
54
|
+
}
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
console.warn(`[Zenith] Failed to parse component ${filePath}: ${error.message}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return components
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Recursively find all .zen files in a directory
|
|
65
|
+
*/
|
|
66
|
+
function findZenFiles(dir: string): string[] {
|
|
67
|
+
const files: string[] = []
|
|
68
|
+
|
|
69
|
+
if (!fs.existsSync(dir)) {
|
|
70
|
+
return files
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
74
|
+
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const fullPath = path.join(dir, entry.name)
|
|
77
|
+
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
files.push(...findZenFiles(fullPath))
|
|
80
|
+
} else if (entry.isFile() && entry.name.endsWith('.zen')) {
|
|
81
|
+
files.push(fullPath)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return files
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse a component file and extract metadata
|
|
90
|
+
*/
|
|
91
|
+
function parseComponentFile(filePath: string): ComponentMetadata | null {
|
|
92
|
+
const ir = parseZenFile(filePath)
|
|
93
|
+
|
|
94
|
+
// Extract component name from filename
|
|
95
|
+
const basename = path.basename(filePath, '.zen')
|
|
96
|
+
const componentName = basename
|
|
97
|
+
|
|
98
|
+
// Extract slots from template
|
|
99
|
+
const slots = extractSlots(ir.template.nodes)
|
|
100
|
+
|
|
101
|
+
// Extract props from script attributes
|
|
102
|
+
const props = ir.script?.attributes['props']?.split(',').map(p => p.trim()) || []
|
|
103
|
+
|
|
104
|
+
// Extract raw CSS from styles
|
|
105
|
+
const styles = ir.styles.map(s => s.raw)
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
name: componentName,
|
|
109
|
+
path: filePath,
|
|
110
|
+
template: ir.template.raw,
|
|
111
|
+
nodes: ir.template.nodes,
|
|
112
|
+
slots,
|
|
113
|
+
props,
|
|
114
|
+
styles,
|
|
115
|
+
hasScript: ir.script !== null,
|
|
116
|
+
hasStyles: ir.styles.length > 0
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract slot definitions from template nodes
|
|
122
|
+
*/
|
|
123
|
+
function extractSlots(nodes: TemplateNode[]): SlotDefinition[] {
|
|
124
|
+
const slots: SlotDefinition[] = []
|
|
125
|
+
|
|
126
|
+
function traverse(node: TemplateNode) {
|
|
127
|
+
if (node.type === 'element') {
|
|
128
|
+
// Check if this is a <slot> tag
|
|
129
|
+
if (node.tag === 'slot') {
|
|
130
|
+
// Extract slot name from attributes
|
|
131
|
+
const nameAttr = node.attributes.find(attr => attr.name === 'name')
|
|
132
|
+
const slotName = typeof nameAttr?.value === 'string' ? nameAttr.value : null
|
|
133
|
+
|
|
134
|
+
slots.push({
|
|
135
|
+
name: slotName,
|
|
136
|
+
location: node.location
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Traverse children
|
|
141
|
+
for (const child of node.children) {
|
|
142
|
+
traverse(child)
|
|
143
|
+
}
|
|
144
|
+
} else if (node.type === 'component') {
|
|
145
|
+
// Also traverse component children
|
|
146
|
+
for (const child of node.children) {
|
|
147
|
+
traverse(child)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const node of nodes) {
|
|
153
|
+
traverse(node)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return slots
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a tag name represents a component (starts with uppercase)
|
|
161
|
+
*/
|
|
162
|
+
export function isComponentTag(tagName: string): boolean {
|
|
163
|
+
return tagName.length > 0 && tagName[0] !== undefined && tagName[0] === tagName[0].toUpperCase()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Get component metadata by name
|
|
168
|
+
*/
|
|
169
|
+
export function getComponent(
|
|
170
|
+
components: Map<string, ComponentMetadata>,
|
|
171
|
+
name: string
|
|
172
|
+
): ComponentMetadata | undefined {
|
|
173
|
+
return components.get(name)
|
|
174
|
+
}
|
|
@@ -22,3 +22,35 @@ export class CompilerError extends Error {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Invariant Error
|
|
27
|
+
*
|
|
28
|
+
* Thrown when a Zenith compiler invariant is violated.
|
|
29
|
+
* Invariants are non-negotiable rules that guarantee correct behavior.
|
|
30
|
+
*
|
|
31
|
+
* If an invariant fails, the compiler is at fault — not the user.
|
|
32
|
+
* The user receives a clear explanation of what is forbidden and why.
|
|
33
|
+
*/
|
|
34
|
+
export class InvariantError extends CompilerError {
|
|
35
|
+
invariantId: string
|
|
36
|
+
guarantee: string
|
|
37
|
+
|
|
38
|
+
constructor(
|
|
39
|
+
invariantId: string,
|
|
40
|
+
message: string,
|
|
41
|
+
guarantee: string,
|
|
42
|
+
file: string,
|
|
43
|
+
line: number,
|
|
44
|
+
column: number
|
|
45
|
+
) {
|
|
46
|
+
super(`[${invariantId}] ${message}\n\n Zenith Guarantee: ${guarantee}`, file, line, column)
|
|
47
|
+
this.name = 'InvariantError'
|
|
48
|
+
this.invariantId = invariantId
|
|
49
|
+
this.guarantee = guarantee
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
override toString(): string {
|
|
53
|
+
return `${this.file}:${this.line}:${this.column} [${this.invariantId}] ${this.message}`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
@@ -106,17 +106,29 @@ export function finalizeOutput(
|
|
|
106
106
|
* Verify HTML contains no raw {expression} syntax
|
|
107
107
|
*
|
|
108
108
|
* This is a critical check - browser must never see raw expressions
|
|
109
|
+
*
|
|
110
|
+
* Excludes:
|
|
111
|
+
* - Content inside <pre>, <code> tags (display code samples)
|
|
112
|
+
* - Content that looks like HTML tags (from entity decoding)
|
|
113
|
+
* - Comments
|
|
114
|
+
* - Data attributes
|
|
109
115
|
*/
|
|
110
116
|
function verifyNoRawExpressions(html: string, filePath: string): string[] {
|
|
111
117
|
const errors: string[] = []
|
|
112
|
-
|
|
118
|
+
|
|
119
|
+
// Remove content inside <pre> and <code> tags before checking
|
|
120
|
+
// These are code samples that may contain { } legitimately
|
|
121
|
+
let htmlToCheck = html
|
|
122
|
+
.replace(/<pre[^>]*>[\s\S]*?<\/pre>/gi, '')
|
|
123
|
+
.replace(/<code[^>]*>[\s\S]*?<\/code>/gi, '')
|
|
124
|
+
|
|
113
125
|
// Check for raw {expression} patterns (not data-zen-* attributes)
|
|
114
126
|
// Allow data-zen-text, data-zen-attr-* but not raw { }
|
|
115
127
|
const rawExpressionPattern = /\{[^}]*\}/g
|
|
116
|
-
const matches =
|
|
117
|
-
|
|
128
|
+
const matches = htmlToCheck.match(rawExpressionPattern)
|
|
129
|
+
|
|
118
130
|
if (matches && matches.length > 0) {
|
|
119
|
-
// Filter out false positives
|
|
131
|
+
// Filter out false positives
|
|
120
132
|
const actualExpressions = matches.filter(match => {
|
|
121
133
|
// Exclude if it's in a comment
|
|
122
134
|
if (html.includes(`<!--${match}`) || html.includes(`${match}-->`)) {
|
|
@@ -126,10 +138,27 @@ function verifyNoRawExpressions(html: string, filePath: string): string[] {
|
|
|
126
138
|
if (match.includes('data-zen-')) {
|
|
127
139
|
return false
|
|
128
140
|
}
|
|
141
|
+
// Exclude if it contains HTML tags (likely from entity decoding in display content)
|
|
142
|
+
// Real expressions don't start with < inside braces
|
|
143
|
+
if (match.match(/^\{[\s]*</)) {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
// Exclude if it looks like display content containing HTML (spans, divs, etc)
|
|
147
|
+
if (/<[a-zA-Z]/.test(match)) {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
// Exclude CSS-like content (common in style attributes)
|
|
151
|
+
if (match.includes(';') && match.includes(':')) {
|
|
152
|
+
return false
|
|
153
|
+
}
|
|
154
|
+
// Exclude if it's a single closing tag pattern (from multiline display)
|
|
155
|
+
if (/^\{[\s]*<\//.test(match)) {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
129
158
|
// This looks like a raw expression
|
|
130
159
|
return true
|
|
131
160
|
})
|
|
132
|
-
|
|
161
|
+
|
|
133
162
|
if (actualExpressions.length > 0) {
|
|
134
163
|
errors.push(
|
|
135
164
|
`HTML contains raw expressions that were not compiled: ${actualExpressions.join(', ')}\n` +
|
|
@@ -138,7 +167,7 @@ function verifyNoRawExpressions(html: string, filePath: string): string[] {
|
|
|
138
167
|
)
|
|
139
168
|
}
|
|
140
169
|
}
|
|
141
|
-
|
|
170
|
+
|
|
142
171
|
return errors
|
|
143
172
|
}
|
|
144
173
|
|
|
@@ -152,12 +181,12 @@ export function finalizeOutputOrThrow(
|
|
|
152
181
|
compiled: CompiledTemplate
|
|
153
182
|
): FinalizedOutput {
|
|
154
183
|
const output = finalizeOutput(ir, compiled)
|
|
155
|
-
|
|
184
|
+
|
|
156
185
|
if (output.hasErrors) {
|
|
157
186
|
const errorMessage = output.errors.join('\n\n')
|
|
158
187
|
throw new Error(`Compilation failed:\n\n${errorMessage}`)
|
|
159
188
|
}
|
|
160
|
-
|
|
189
|
+
|
|
161
190
|
return output
|
|
162
191
|
}
|
|
163
192
|
|
package/compiler/index.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { parseTemplate } from './parse/parseTemplate'
|
|
|
3
3
|
import { parseScript } from './parse/parseScript'
|
|
4
4
|
import { transformTemplate } from './transform/transformTemplate'
|
|
5
5
|
import { finalizeOutputOrThrow } from './finalize/finalizeOutput'
|
|
6
|
+
import { validateInvariants } from './validate/invariants'
|
|
7
|
+
import { InvariantError } from './errors/compilerError'
|
|
6
8
|
import type { ZenIR, StyleIR } from './ir/types'
|
|
7
9
|
import type { CompiledTemplate } from './output/types'
|
|
8
10
|
import type { FinalizedOutput } from './finalize/finalizeOutput'
|
|
@@ -22,7 +24,13 @@ export function compileZen(filePath: string): {
|
|
|
22
24
|
/**
|
|
23
25
|
* Compile Zen source string into IR and CompiledTemplate
|
|
24
26
|
*/
|
|
25
|
-
export function compileZenSource(
|
|
27
|
+
export function compileZenSource(
|
|
28
|
+
source: string,
|
|
29
|
+
filePath: string,
|
|
30
|
+
options?: {
|
|
31
|
+
componentsDir?: string
|
|
32
|
+
}
|
|
33
|
+
): {
|
|
26
34
|
ir: ZenIR
|
|
27
35
|
compiled: CompiledTemplate
|
|
28
36
|
finalized?: FinalizedOutput
|
|
@@ -34,27 +42,40 @@ export function compileZenSource(source: string, filePath: string): {
|
|
|
34
42
|
const script = parseScript(source)
|
|
35
43
|
|
|
36
44
|
// Parse styles
|
|
37
|
-
const styleRegex =
|
|
45
|
+
const styleRegex = /\u003cstyle[^\u003e]*\u003e([\s\S]*?)\u003c\/style\u003e/gi
|
|
38
46
|
const styles: StyleIR[] = []
|
|
39
47
|
let match
|
|
40
48
|
while ((match = styleRegex.exec(source)) !== null) {
|
|
41
49
|
if (match[1]) styles.push({ raw: match[1].trim() })
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
|
|
52
|
+
let ir: ZenIR = {
|
|
45
53
|
filePath,
|
|
46
54
|
template,
|
|
47
55
|
script,
|
|
48
56
|
styles
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
// Resolve components if components directory is provided
|
|
60
|
+
if (options?.componentsDir) {
|
|
61
|
+
const { discoverComponents } = require('./discovery/componentDiscovery')
|
|
62
|
+
const { resolveComponentsInIR } = require('./transform/componentResolver')
|
|
63
|
+
|
|
64
|
+
// Component resolution may throw InvariantError — let it propagate
|
|
65
|
+
const components = discoverComponents(options.componentsDir)
|
|
66
|
+
ir = resolveComponentsInIR(ir, components)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Validate all compiler invariants after resolution
|
|
70
|
+
// Throws InvariantError if any invariant is violated
|
|
71
|
+
validateInvariants(ir, filePath)
|
|
72
|
+
|
|
51
73
|
const compiled = transformTemplate(ir)
|
|
52
74
|
|
|
53
75
|
try {
|
|
54
76
|
const finalized = finalizeOutputOrThrow(ir, compiled)
|
|
55
77
|
return { ir, compiled, finalized }
|
|
56
78
|
} catch (error: any) {
|
|
57
|
-
throw new Error(`Failed to finalize output for ${filePath}
|
|
79
|
+
throw new Error(`Failed to finalize output for ${filePath}:\\n${error.message}`)
|
|
58
80
|
}
|
|
59
81
|
}
|
|
60
|
-
|
package/compiler/ir/types.ts
CHANGED
|
@@ -23,6 +23,10 @@ export type TemplateNode =
|
|
|
23
23
|
| ElementNode
|
|
24
24
|
| TextNode
|
|
25
25
|
| ExpressionNode
|
|
26
|
+
| ComponentNode
|
|
27
|
+
| ConditionalFragmentNode // JSX ternary: {cond ? <A /> : <B />}
|
|
28
|
+
| OptionalFragmentNode // JSX logical AND: {cond && <A />}
|
|
29
|
+
| LoopFragmentNode // JSX map: {items.map(i => <li>...</li>)}
|
|
26
30
|
|
|
27
31
|
export type ElementNode = {
|
|
28
32
|
type: 'element'
|
|
@@ -33,6 +37,15 @@ export type ElementNode = {
|
|
|
33
37
|
loopContext?: LoopContext // Phase 7: Inherited loop context from parent map expressions
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
export type ComponentNode = {
|
|
41
|
+
type: 'component'
|
|
42
|
+
name: string
|
|
43
|
+
attributes: AttributeIR[]
|
|
44
|
+
children: TemplateNode[]
|
|
45
|
+
location: SourceLocation
|
|
46
|
+
loopContext?: LoopContext
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
export type TextNode = {
|
|
37
50
|
type: 'text'
|
|
38
51
|
value: string
|
|
@@ -46,6 +59,58 @@ export type ExpressionNode = {
|
|
|
46
59
|
loopContext?: LoopContext // Phase 7: Loop context for expressions inside map iterations
|
|
47
60
|
}
|
|
48
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Conditional Fragment Node
|
|
64
|
+
*
|
|
65
|
+
* Represents ternary expressions with JSX branches: {cond ? <A /> : <B />}
|
|
66
|
+
*
|
|
67
|
+
* BOTH branches are compiled at compile time.
|
|
68
|
+
* Runtime toggles visibility — never creates DOM.
|
|
69
|
+
*/
|
|
70
|
+
export type ConditionalFragmentNode = {
|
|
71
|
+
type: 'conditional-fragment'
|
|
72
|
+
condition: string // The condition expression code
|
|
73
|
+
consequent: TemplateNode[] // Precompiled "true" branch
|
|
74
|
+
alternate: TemplateNode[] // Precompiled "false" branch
|
|
75
|
+
location: SourceLocation
|
|
76
|
+
loopContext?: LoopContext
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Optional Fragment Node
|
|
81
|
+
*
|
|
82
|
+
* Represents logical AND expressions with JSX: {cond && <A />}
|
|
83
|
+
*
|
|
84
|
+
* Fragment is compiled at compile time.
|
|
85
|
+
* Runtime toggles mount/unmount based on condition.
|
|
86
|
+
*/
|
|
87
|
+
export type OptionalFragmentNode = {
|
|
88
|
+
type: 'optional-fragment'
|
|
89
|
+
condition: string // The condition expression code
|
|
90
|
+
fragment: TemplateNode[] // Precompiled fragment
|
|
91
|
+
location: SourceLocation
|
|
92
|
+
loopContext?: LoopContext
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Loop Fragment Node
|
|
97
|
+
*
|
|
98
|
+
* Represents .map() expressions with JSX body: {items.map(i => <li>...</li>)}
|
|
99
|
+
*
|
|
100
|
+
* Desugars to @for loop semantics at compile time.
|
|
101
|
+
* Body is compiled once, instantiated per item at runtime.
|
|
102
|
+
* Node identity is compiler-owned via stable keys.
|
|
103
|
+
*/
|
|
104
|
+
export type LoopFragmentNode = {
|
|
105
|
+
type: 'loop-fragment'
|
|
106
|
+
source: string // Array expression (e.g., 'items')
|
|
107
|
+
itemVar: string // Loop variable (e.g., 'item')
|
|
108
|
+
indexVar?: string // Optional index variable
|
|
109
|
+
body: TemplateNode[] // Precompiled loop body template
|
|
110
|
+
location: SourceLocation
|
|
111
|
+
loopContext: LoopContext // Extended with this loop's variables
|
|
112
|
+
}
|
|
113
|
+
|
|
49
114
|
export type AttributeIR = {
|
|
50
115
|
name: string
|
|
51
116
|
value: string | ExpressionIR
|
|
@@ -82,3 +147,4 @@ export type SourceLocation = {
|
|
|
82
147
|
column: number
|
|
83
148
|
}
|
|
84
149
|
|
|
150
|
+
|
|
@@ -7,10 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import { parse, parseFragment } from 'parse5'
|
|
9
9
|
import type { TemplateIR, TemplateNode, ElementNode, TextNode, ExpressionNode, AttributeIR, ExpressionIR, SourceLocation, LoopContext } from '../ir/types'
|
|
10
|
-
import { CompilerError } from '../errors/compilerError'
|
|
10
|
+
import { CompilerError, InvariantError } from '../errors/compilerError'
|
|
11
11
|
import { parseScript } from './parseScript'
|
|
12
12
|
import { detectMapExpression, extractLoopVariables, referencesLoopVariable } from './detectMapExpressions'
|
|
13
13
|
import { shouldAttachLoopContext, mergeLoopContext, extractLoopContextFromExpression } from './trackLoopContext'
|
|
14
|
+
import { INVARIANT } from '../validate/invariants'
|
|
15
|
+
import { lowerFragments } from '../transform/fragmentLowering'
|
|
14
16
|
|
|
15
17
|
// Generate stable IDs for expressions
|
|
16
18
|
let expressionIdCounter = 0
|
|
@@ -362,6 +364,29 @@ function parseNode(
|
|
|
362
364
|
const location = getLocation(node, originalHtml)
|
|
363
365
|
const tag = node.tagName?.toLowerCase() || node.nodeName
|
|
364
366
|
|
|
367
|
+
// Extract original tag name from source HTML to preserve casing (parse5 lowercases everything)
|
|
368
|
+
let originalTag = node.tagName || node.nodeName
|
|
369
|
+
if (node.sourceCodeLocation && node.sourceCodeLocation.startOffset !== undefined) {
|
|
370
|
+
const startOffset = node.sourceCodeLocation.startOffset
|
|
371
|
+
// Find the tag name in original HTML (after '<')
|
|
372
|
+
const tagMatch = originalHtml.slice(startOffset).match(/^<([a-zA-Z][a-zA-Z0-9._-]*)/)
|
|
373
|
+
if (tagMatch && tagMatch[1]) {
|
|
374
|
+
originalTag = tagMatch[1]
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// INV005: <template> tags are forbidden — use compound components instead
|
|
379
|
+
if (tag === 'template') {
|
|
380
|
+
throw new InvariantError(
|
|
381
|
+
INVARIANT.TEMPLATE_TAG,
|
|
382
|
+
`<template> tags are forbidden in Zenith. Use compound components (e.g., Card.Header) for named slots.`,
|
|
383
|
+
'Named slots use compound component pattern (Card.Header), not <template> tags.',
|
|
384
|
+
'unknown', // filePath passed to parseTemplate
|
|
385
|
+
location.line,
|
|
386
|
+
location.column
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
365
390
|
// Parse attributes
|
|
366
391
|
const attributes: AttributeIR[] = []
|
|
367
392
|
if (node.attrs) {
|
|
@@ -373,6 +398,18 @@ function parseNode(
|
|
|
373
398
|
}
|
|
374
399
|
: location
|
|
375
400
|
|
|
401
|
+
// INV006: slot="" attributes are forbidden — use compound components instead
|
|
402
|
+
if (attr.name === 'slot') {
|
|
403
|
+
throw new InvariantError(
|
|
404
|
+
INVARIANT.SLOT_ATTRIBUTE,
|
|
405
|
+
`slot="${attr.value || ''}" attribute is forbidden. Use compound components (e.g., Card.Header) for named slots.`,
|
|
406
|
+
'Named slots use compound component pattern (Card.Header), not slot="" attributes.',
|
|
407
|
+
'unknown',
|
|
408
|
+
attrLocation.line,
|
|
409
|
+
attrLocation.column
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
376
413
|
// Handle :attr="expr" syntax (colon-prefixed reactive attributes)
|
|
377
414
|
let attrName = attr.name
|
|
378
415
|
let attrValue = attr.value || ''
|
|
@@ -474,13 +511,29 @@ function parseNode(
|
|
|
474
511
|
}
|
|
475
512
|
}
|
|
476
513
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
514
|
+
// Check if this is a custom component (starts with uppercase)
|
|
515
|
+
const isComponent = originalTag.length > 0 && originalTag[0] === originalTag[0].toUpperCase()
|
|
516
|
+
|
|
517
|
+
if (isComponent) {
|
|
518
|
+
// This is a component node
|
|
519
|
+
return {
|
|
520
|
+
type: 'component',
|
|
521
|
+
name: originalTag,
|
|
522
|
+
attributes,
|
|
523
|
+
children,
|
|
524
|
+
location,
|
|
525
|
+
loopContext: elementLoopContext
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
// This is a regular HTML element
|
|
529
|
+
return {
|
|
530
|
+
type: 'element',
|
|
531
|
+
tag,
|
|
532
|
+
attributes,
|
|
533
|
+
children,
|
|
534
|
+
location,
|
|
535
|
+
loopContext: elementLoopContext
|
|
536
|
+
}
|
|
484
537
|
}
|
|
485
538
|
}
|
|
486
539
|
|
|
@@ -517,9 +570,13 @@ export function parseTemplate(html: string, filePath: string): TemplateIR {
|
|
|
517
570
|
}
|
|
518
571
|
}
|
|
519
572
|
|
|
573
|
+
// Phase 8: Lower JSX expressions to structural fragments
|
|
574
|
+
// This transforms expressions like {cond ? <A /> : <B />} into ConditionalFragmentNode
|
|
575
|
+
const loweredNodes = lowerFragments(nodes, filePath, expressions)
|
|
576
|
+
|
|
520
577
|
return {
|
|
521
578
|
raw: templateHtml,
|
|
522
|
-
nodes,
|
|
579
|
+
nodes: loweredNodes,
|
|
523
580
|
expressions
|
|
524
581
|
}
|
|
525
582
|
} catch (error: any) {
|