@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.
@@ -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 = html.match(rawExpressionPattern)
117
-
128
+ const matches = htmlToCheck.match(rawExpressionPattern)
129
+
118
130
  if (matches && matches.length > 0) {
119
- // Filter out false positives (comments, data attributes, etc.)
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(source: string, filePath: string): {
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 = /<style[^>]*>([\s\S]*?)<\/style>/gi
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
- const ir: ZenIR = {
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}:\n${error.message}`)
79
+ throw new Error(`Failed to finalize output for ${filePath}:\\n${error.message}`)
58
80
  }
59
81
  }
60
-
@@ -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
- return {
478
- type: 'element',
479
- tag,
480
- attributes,
481
- children,
482
- location,
483
- loopContext: elementLoopContext // Phase 7: Inherited loop context for child processing
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) {