@zenithbuild/core 0.4.2 → 0.4.5

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.
@@ -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) {
@@ -4,7 +4,16 @@
4
4
  * Generates JavaScript code that creates DOM elements from template nodes
5
5
  */
6
6
 
7
- import type { TemplateNode, ElementNode, TextNode, ExpressionNode, ExpressionIR } from '../ir/types'
7
+ import type {
8
+ TemplateNode,
9
+ ElementNode,
10
+ TextNode,
11
+ ExpressionNode,
12
+ ExpressionIR,
13
+ ConditionalFragmentNode,
14
+ OptionalFragmentNode,
15
+ LoopFragmentNode
16
+ } from '../ir/types'
8
17
 
9
18
  /**
10
19
  * Generate DOM creation code from a template node
@@ -95,6 +104,98 @@ ${indent}}\n`
95
104
 
96
105
  return { code, varName }
97
106
  }
107
+
108
+ case 'component': {
109
+ // Components should be resolved before reaching DOM generation
110
+ // If we get here, it means component resolution failed
111
+ throw new Error(`[Zenith] Unresolved component: ${(node as any).name}. Components must be resolved before DOM generation.`)
112
+ }
113
+
114
+ case 'conditional-fragment': {
115
+ // Conditional fragment: {condition ? <A /> : <B />}
116
+ // Both branches are precompiled, runtime toggles visibility
117
+ const condNode = node as ConditionalFragmentNode
118
+ const containerVar = varName
119
+ const conditionId = `cond_${varCounter.count++}`
120
+
121
+ let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
122
+ code += `${indent}const ${conditionId}_result = (function() { with (state) { return ${condNode.condition}; } })();\n`
123
+
124
+ // Generate consequent branch
125
+ code += `${indent}if (${conditionId}_result) {\n`
126
+ for (const child of condNode.consequent) {
127
+ const childResult = generateDOMCode(child, expressions, indent + ' ', varCounter)
128
+ code += `${childResult.code}\n`
129
+ code += `${indent} ${containerVar}.appendChild(${childResult.varName});\n`
130
+ }
131
+ code += `${indent}} else {\n`
132
+
133
+ // Generate alternate branch
134
+ for (const child of condNode.alternate) {
135
+ const childResult = generateDOMCode(child, expressions, indent + ' ', varCounter)
136
+ code += `${childResult.code}\n`
137
+ code += `${indent} ${containerVar}.appendChild(${childResult.varName});\n`
138
+ }
139
+ code += `${indent}}\n`
140
+
141
+ return { code, varName: containerVar }
142
+ }
143
+
144
+ case 'optional-fragment': {
145
+ // Optional fragment: {condition && <A />}
146
+ // Fragment is precompiled, runtime mounts/unmounts based on condition
147
+ const optNode = node as OptionalFragmentNode
148
+ const containerVar = varName
149
+ const conditionId = `opt_${varCounter.count++}`
150
+
151
+ let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
152
+ code += `${indent}const ${conditionId}_result = (function() { with (state) { return ${optNode.condition}; } })();\n`
153
+ code += `${indent}if (${conditionId}_result) {\n`
154
+
155
+ for (const child of optNode.fragment) {
156
+ const childResult = generateDOMCode(child, expressions, indent + ' ', varCounter)
157
+ code += `${childResult.code}\n`
158
+ code += `${indent} ${containerVar}.appendChild(${childResult.varName});\n`
159
+ }
160
+
161
+ code += `${indent}}\n`
162
+
163
+ return { code, varName: containerVar }
164
+ }
165
+
166
+ case 'loop-fragment': {
167
+ // Loop fragment: {items.map(item => <li>...</li>)}
168
+ // Body is precompiled once, instantiated per item at runtime
169
+ const loopNode = node as LoopFragmentNode
170
+ const containerVar = varName
171
+ const loopId = `loop_${varCounter.count++}`
172
+
173
+ let code = `${indent}const ${containerVar} = document.createDocumentFragment();\n`
174
+ code += `${indent}const ${loopId}_items = (function() { with (state) { return ${loopNode.source}; } })() || [];\n`
175
+
176
+ // Loop parameters
177
+ const itemVar = loopNode.itemVar
178
+ const indexVar = loopNode.indexVar || `${loopId}_idx`
179
+
180
+ code += `${indent}${loopId}_items.forEach(function(${itemVar}, ${indexVar}) {\n`
181
+
182
+ // Generate loop body with loop context variables in scope
183
+ for (const child of loopNode.body) {
184
+ const childResult = generateDOMCode(child, expressions, indent + ' ', varCounter)
185
+ // Inject loop variables into the child code
186
+ let childCode = childResult.code
187
+ code += `${childCode}\n`
188
+ code += `${indent} ${containerVar}.appendChild(${childResult.varName});\n`
189
+ }
190
+
191
+ code += `${indent}});\n`
192
+
193
+ return { code, varName: containerVar }
194
+ }
195
+
196
+ default: {
197
+ throw new Error(`[Zenith] Unknown node type: ${(node as any).type}`)
198
+ }
98
199
  }
99
200
  }
100
201
 
@@ -197,8 +197,8 @@ if (typeof window !== 'undefined') {
197
197
  if (document.readyState === 'loading') {
198
198
  document.addEventListener('DOMContentLoaded', autoHydrate);
199
199
  } else {
200
- // DOM already loaded, run on next tick to ensure all scripts are executed
201
- setTimeout(autoHydrate, 0);
200
+ // DOM already loaded, hydrate immediately
201
+ autoHydrate();
202
202
  }
203
203
  })();
204
204
  `
@@ -20,6 +20,7 @@ import { discoverPages, generateRouteDefinition } from "../router/manifest"
20
20
  import { analyzePageSource, getAnalysisSummary, getBuildOutputType, type PageAnalysis } from "./build-analyzer"
21
21
  import { generateBundleJS } from "../runtime/bundle-generator"
22
22
  import { loadContent } from "../cli/utils/content"
23
+ import { compileCss, resolveGlobalsCss } from "./css"
23
24
 
24
25
  // ============================================
25
26
  // Types
@@ -292,12 +293,23 @@ export function buildSSG(options: SSGBuildOptions): void {
292
293
 
293
294
  console.log('')
294
295
 
295
- // Load global styles
296
+ // Compile global styles (Tailwind CSS)
296
297
  let globalStyles = ''
297
- const globalCssPath = path.join(baseDir, 'styles', 'global.css')
298
- if (fs.existsSync(globalCssPath)) {
299
- globalStyles = fs.readFileSync(globalCssPath, 'utf-8')
300
- console.log('📦 Loaded global.css')
298
+ const globalsCssPath = resolveGlobalsCss(baseDir)
299
+ if (globalsCssPath) {
300
+ console.log('📦 Compiling CSS:', path.relative(baseDir, globalsCssPath))
301
+ const cssOutputPath = path.join(outDir, 'assets', 'styles.css')
302
+ const result = compileCss({
303
+ input: globalsCssPath,
304
+ output: cssOutputPath,
305
+ minify: true
306
+ })
307
+ if (result.success) {
308
+ globalStyles = result.css
309
+ console.log(`📦 Generated assets/styles.css (${result.duration}ms)`)
310
+ } else {
311
+ console.error('❌ CSS compilation failed:', result.error)
312
+ }
301
313
  }
302
314
 
303
315
  // Write bundle.js if any pages need hydration
@@ -307,12 +319,6 @@ export function buildSSG(options: SSGBuildOptions): void {
307
319
  console.log('📦 Generated assets/bundle.js')
308
320
  }
309
321
 
310
- // Write global styles
311
- if (globalStyles) {
312
- fs.writeFileSync(path.join(outDir, 'assets', 'styles.css'), globalStyles)
313
- console.log('📦 Generated assets/styles.css')
314
- }
315
-
316
322
  // Write each page
317
323
  for (const page of compiledPages) {
318
324
  // Create output directory