@zenithbuild/core 0.3.3 → 0.4.2

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.
@@ -30,52 +30,124 @@ function stripBlocks(html: string): string {
30
30
  }
31
31
 
32
32
  /**
33
- * Normalize attribute expressions before parsing
34
- * Replaces attr={expr} with attr="__ZEN_EXPR_base64" so parse5 can parse it
35
- * Handles nested braces for complex expressions like props={{ title: 'Home' }}
33
+ * Find the end of a balanced brace expression, handling strings and template literals
34
+ * Returns the index after the closing brace, or -1 if unbalanced
36
35
  */
37
- function normalizeAttributeExpressions(html: string): { normalized: string; expressions: Map<string, string> } {
38
- const exprMap = new Map<string, string>()
39
- let exprCounter = 0
40
-
41
- // Improved matching to handle nested braces: attr={ { foo: { bar: 1 } } }
42
- let lastPos = 0
43
- let normalized = ''
36
+ function findBalancedBraceEnd(html: string, startIndex: number): number {
37
+ let braceCount = 1
38
+ let i = startIndex + 1
39
+ let inString = false
40
+ let stringChar = ''
41
+ let inTemplate = false
42
+
43
+ while (i < html.length && braceCount > 0) {
44
+ const char = html[i]
45
+ const prevChar = i > 0 ? html[i - 1] : ''
46
+
47
+ // Handle escape sequences
48
+ if (prevChar === '\\') {
49
+ i++
50
+ continue
51
+ }
44
52
 
45
- // Use a regex to find the start of an attribute expression: \w+={
46
- const startRegex = /(\w+)=\{/g
47
- let match
53
+ // Handle string literals (not inside template)
54
+ if (!inString && !inTemplate && (char === '"' || char === "'")) {
55
+ inString = true
56
+ stringChar = char
57
+ i++
58
+ continue
59
+ }
48
60
 
49
- while ((match = startRegex.exec(html)) !== null) {
50
- const attrName = match[1]
51
- const startIndex = match.index + match[0].length - 1 // Index of '{'
61
+ if (inString && char === stringChar) {
62
+ inString = false
63
+ stringChar = ''
64
+ i++
65
+ continue
66
+ }
52
67
 
53
- // Find matching closing brace
54
- let braceCount = 1
55
- let i = startIndex + 1
56
- while (i < html.length && braceCount > 0) {
57
- if (html[i] === '{') braceCount++
58
- else if (html[i] === '}') braceCount--
68
+ // Handle template literals
69
+ if (!inString && !inTemplate && char === '`') {
70
+ inTemplate = true
59
71
  i++
72
+ continue
60
73
  }
61
74
 
62
- if (braceCount === 0) {
63
- const expr = html.substring(startIndex + 1, i - 1).trim()
64
- const placeholder = `__ZEN_EXPR_${exprCounter++}`
65
- exprMap.set(placeholder, expr)
75
+ if (inTemplate && char === '`') {
76
+ inTemplate = false
77
+ i++
78
+ continue
79
+ }
66
80
 
67
- normalized += html.substring(lastPos, match.index)
68
- normalized += `${attrName}="${placeholder}"`
69
- lastPos = i
81
+ // Handle ${} inside template literals - need to track nested braces
82
+ if (inTemplate && char === '$' && html[i + 1] === '{') {
83
+ // Skip the ${ and count as opening brace
84
+ i += 2
85
+ let templateBraceCount = 1
86
+ while (i < html.length && templateBraceCount > 0) {
87
+ if (html[i] === '{') templateBraceCount++
88
+ else if (html[i] === '}') templateBraceCount--
89
+ i++
90
+ }
91
+ continue
92
+ }
70
93
 
71
- // Update regex index to continue after the closing brace
72
- startRegex.lastIndex = i
94
+ // Count braces only when not in strings or templates
95
+ if (!inString && !inTemplate) {
96
+ if (char === '{') braceCount++
97
+ else if (char === '}') braceCount--
73
98
  }
99
+
100
+ i++
74
101
  }
75
102
 
76
- normalized += html.substring(lastPos)
103
+ return braceCount === 0 ? i : -1
104
+ }
105
+
106
+ /**
107
+ * Normalize expressions before parsing
108
+ * Replaces both attr={expr} and {textExpr} with placeholders so parse5 can parse the HTML correctly
109
+ * without being confused by tags or braces inside expressions.
110
+ *
111
+ * Uses balanced brace parsing to correctly handle:
112
+ * - String literals with braces inside
113
+ * - Template literals with ${} interpolations
114
+ * - Arrow functions with object returns
115
+ * - Multi-line JSX expressions
116
+ */
117
+ function normalizeAllExpressions(html: string): { normalized: string; expressions: Map<string, string> } {
118
+ const exprMap = new Map<string, string>()
119
+ let exprCounter = 0
120
+ let result = ''
121
+ let lastPos = 0
122
+
123
+ for (let i = 0; i < html.length; i++) {
124
+ // Look for { and check if it's an expression
125
+ // We handle both text expressions and attribute expressions: attr={...}
126
+ if (html[i] === '{') {
127
+ const j = findBalancedBraceEnd(html, i)
128
+
129
+ if (j !== -1 && j > i + 1) {
130
+ const expr = html.substring(i + 1, j - 1).trim()
131
+
132
+ // Skip empty expressions
133
+ if (expr.length === 0) {
134
+ i++
135
+ continue
136
+ }
137
+
138
+ const placeholder = `__ZEN_EXPR_${exprCounter++}`
139
+ exprMap.set(placeholder, expr)
77
140
 
78
- return { normalized, expressions: exprMap }
141
+ result += html.substring(lastPos, i)
142
+ result += placeholder
143
+ lastPos = j
144
+ i = j - 1
145
+ }
146
+ }
147
+ }
148
+ result += html.substring(lastPos)
149
+
150
+ return { normalized: result, expressions: exprMap }
79
151
  }
80
152
 
81
153
 
@@ -103,14 +175,15 @@ function extractExpressionsFromText(
103
175
  text: string,
104
176
  baseLocation: SourceLocation,
105
177
  expressions: ExpressionIR[],
106
- loopContext?: LoopContext // Phase 7: Loop context from parent map expressions
178
+ normalizedExprs: Map<string, string>,
179
+ loopContext?: LoopContext
107
180
  ): { processedText: string; nodes: (TextNode | ExpressionNode)[] } {
108
181
  const nodes: (TextNode | ExpressionNode)[] = []
109
182
  let processedText = ''
110
183
  let currentIndex = 0
111
184
 
112
- // Match { ... } expressions (non-greedy)
113
- const expressionRegex = /\{([^}]+)\}/g
185
+ // Match __ZEN_EXPR_N placeholders
186
+ const expressionRegex = /__ZEN_EXPR_\d+/g
114
187
  let match
115
188
 
116
189
  while ((match = expressionRegex.exec(text)) !== null) {
@@ -127,12 +200,13 @@ function extractExpressionsFromText(
127
200
  processedText += beforeExpr
128
201
  }
129
202
 
130
- // Extract expression
131
- const exprCode = (match[1] || '').trim()
203
+ // Resolve placeholder to original expression code
204
+ const placeholder = match[0]
205
+ const exprCode = (normalizedExprs.get(placeholder) || '').trim()
132
206
  const exprId = generateExpressionId()
133
207
  const exprLocation: SourceLocation = {
134
208
  line: baseLocation.line,
135
- column: baseLocation.column + match.index + 1 // +1 for opening brace
209
+ column: baseLocation.column + match.index
136
210
  }
137
211
 
138
212
  const exprIR: ExpressionIR = {
@@ -142,11 +216,9 @@ function extractExpressionsFromText(
142
216
  }
143
217
  expressions.push(exprIR)
144
218
 
145
- // Phase 7: Detect if this is a map expression and extract loop context
219
+ // Phase 7: Loop context detection and attachment
146
220
  const mapLoopContext = extractLoopContextFromExpression(exprIR)
147
221
  const activeLoopContext = mergeLoopContext(loopContext, mapLoopContext)
148
-
149
- // Phase 7: Attach loop context if expression references loop variables
150
222
  const attachedLoopContext = shouldAttachLoopContext(exprIR, activeLoopContext)
151
223
 
152
224
  nodes.push({
@@ -156,7 +228,7 @@ function extractExpressionsFromText(
156
228
  loopContext: attachedLoopContext
157
229
  })
158
230
 
159
- processedText += `{${exprCode}}` // Keep in processed text for now
231
+ processedText += `{${exprCode}}`
160
232
  currentIndex = match.index + match[0].length
161
233
  }
162
234
 
@@ -260,7 +332,7 @@ function parseNode(
260
332
 
261
333
  // Extract expressions from text
262
334
  // Phase 7: Pass loop context to detect map expressions and attach context
263
- const { nodes } = extractExpressionsFromText(text, location, expressions, parentLoopContext)
335
+ const { nodes } = extractExpressionsFromText(node.value, location, expressions, normalizedExprs, parentLoopContext)
264
336
 
265
337
  // If single text node with no expressions, return it
266
338
  if (nodes.length === 1 && nodes[0] && nodes[0].type === 'text') {
@@ -375,7 +447,7 @@ function parseNode(
375
447
  // Handle text nodes that may contain expressions
376
448
  const text = child.value || ''
377
449
  const location = getLocation(child, originalHtml)
378
- const { nodes: textNodes } = extractExpressionsFromText(text, location, expressions, parentLoopContext)
450
+ const { nodes: textNodes } = extractExpressionsFromText(text, location, expressions, normalizedExprs, parentLoopContext)
379
451
 
380
452
  // Add all nodes from text (can be multiple: text + expression + text)
381
453
  for (const textNode of textNodes) {
@@ -422,12 +494,12 @@ export function parseTemplate(html: string, filePath: string): TemplateIR {
422
494
  // Strip script and style blocks
423
495
  let templateHtml = stripBlocks(html)
424
496
 
425
- // Normalize attribute expressions so parse5 can parse them
426
- const { normalized, expressions: normalizedExprs } = normalizeAttributeExpressions(templateHtml)
497
+ // Normalize all expressions so parse5 can parse them safely
498
+ const { normalized, expressions: normalizedExprs } = normalizeAllExpressions(templateHtml)
427
499
  templateHtml = normalized
428
500
 
429
501
  try {
430
- // Parse HTML using parseFragment (handles fragments without html/body wrapper)
502
+ // Parse HTML using parseFragment
431
503
  const fragment = parseFragment(templateHtml, {
432
504
  sourceCodeLocationInfo: true
433
505
  })
@@ -436,7 +508,6 @@ export function parseTemplate(html: string, filePath: string): TemplateIR {
436
508
  const nodes: TemplateNode[] = []
437
509
 
438
510
  // Parse fragment children
439
- // Phase 7: Start with no loop context (top-level expressions)
440
511
  if (fragment.childNodes) {
441
512
  for (const node of fragment.childNodes) {
442
513
  const parsed = parseNode(node, templateHtml, expressions, normalizedExprs, undefined)
@@ -62,6 +62,12 @@ export function transformStateDeclarations(script: string): string {
62
62
  // Remove zenith/runtime imports
63
63
  transformed = transformed.replace(/import\s+{[^}]+}\s+from\s+['"]zenith\/runtime['"]\s*;?[ \t]*/g, '')
64
64
 
65
+ // Transform zenith:content imports to global lookups
66
+ transformed = transformed.replace(
67
+ /import\s*{\s*([^}]+)\s*}\s*from\s*['"]zenith:content['"]\s*;?/g,
68
+ (_, imports) => `const { ${imports.trim()} } = window.__zenith;`
69
+ )
70
+
65
71
  return transformed.trim()
66
72
  }
67
73
 
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { ExpressionIR } from '../ir/types'
9
9
  import { CompilerError } from '../errors/compilerError'
10
+ import { transformExpressionJSX } from '../transform/expressionTransformer'
10
11
 
11
12
  /**
12
13
  * Data dependency information for an expression
@@ -263,10 +264,17 @@ export function generateExplicitExpressionWrapper(
263
264
  ? `const __ctx = Object.assign({}, ${contextParts.join(', ')});\n with (__ctx) {`
264
265
  : 'with (state) {'
265
266
 
266
- const escapedCode = code.replace(/`/g, '\\`').replace(/\$/g, '\\$')
267
+ // Escape the code for use in a single-line comment (replace newlines with spaces)
268
+ const commentCode = code.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').substring(0, 100)
269
+
270
+ // JSON.stringify the code for error messages (properly escapes quotes, newlines, etc.)
271
+ const jsonEscapedCode = JSON.stringify(code)
272
+
273
+ // Transform JSX
274
+ const transformedCode = transformExpressionJSX(code)
267
275
 
268
276
  return `
269
- // Expression: ${escapedCode}
277
+ // Expression: ${commentCode}${code.length > 100 ? '...' : ''}
270
278
  // Dependencies: ${JSON.stringify({
271
279
  loaderData: dependencies.usesLoaderData,
272
280
  props: dependencies.usesProps,
@@ -276,10 +284,10 @@ export function generateExplicitExpressionWrapper(
276
284
  const ${id} = (${paramList}) => {
277
285
  try {
278
286
  ${contextCode}
279
- return ${code};
287
+ return ${transformedCode};
280
288
  }
281
289
  } catch (e) {
282
- console.warn('[Zenith] Expression evaluation error:', ${JSON.stringify(code)}, e);
290
+ console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
283
291
  return undefined;
284
292
  }
285
293
  };`
@@ -45,27 +45,32 @@ export function generateHydrationRuntime(): string {
45
45
  // Handle different result types
46
46
  if (result === null || result === undefined || result === false) {
47
47
  node.textContent = '';
48
- } else if (typeof result === 'string' || typeof result === 'number') {
48
+ } else if (typeof result === 'string') {
49
+ if (result.trim().startsWith('<')) {
50
+ // Render as HTML
51
+ node.innerHTML = result;
52
+ } else {
53
+ node.textContent = result;
54
+ }
55
+ } else if (typeof result === 'number') {
49
56
  node.textContent = String(result);
50
57
  } else if (result instanceof Node) {
51
- // Replace node with result node
52
- if (node.parentNode) {
53
- node.parentNode.replaceChild(result, node);
54
- }
58
+ // Clear node and append result
59
+ node.innerHTML = '';
60
+ node.appendChild(result);
55
61
  } else if (Array.isArray(result)) {
56
62
  // Handle array results (for map expressions)
57
- if (node.parentNode) {
58
- const fragment = document.createDocumentFragment();
59
- for (let i = 0; i < result.length; i++) {
60
- const item = result[i];
61
- if (item instanceof Node) {
62
- fragment.appendChild(item);
63
- } else {
64
- fragment.appendChild(document.createTextNode(String(item)));
65
- }
63
+ node.innerHTML = '';
64
+ const fragment = document.createDocumentFragment();
65
+ for (let i = 0; i < result.length; i++) {
66
+ const item = result[i];
67
+ if (item instanceof Node) {
68
+ fragment.appendChild(item);
69
+ } else {
70
+ fragment.appendChild(document.createTextNode(String(item)));
66
71
  }
67
- node.parentNode.replaceChild(fragment, node);
68
72
  }
73
+ node.appendChild(fragment);
69
74
  } else {
70
75
  node.textContent = String(result);
71
76
  }
@@ -118,20 +118,16 @@ ${parts.hydrationRuntime}
118
118
 
119
119
  ${parts.navigationRuntime}
120
120
 
121
- ${parts.stateInitCode ? `// State initialization
122
- ${parts.stateInitCode}` : ''}
123
-
124
121
  ${parts.stylesCode ? `// Style injection
125
122
  ${parts.stylesCode}` : ''}
126
123
 
127
- // User script code and function registration
128
- (function() {
129
- 'use strict';
130
-
124
+ // User script code - executed first to define variables needed by state initialization
131
125
  ${parts.scriptCode ? parts.scriptCode : ''}
132
126
 
133
127
  ${functionRegistrations}
134
- })();
128
+
129
+ ${parts.stateInitCode ? `// State initialization
130
+ ${parts.stateInitCode}` : ''}
135
131
 
136
132
  // Export hydration functions
137
133
  if (typeof window !== 'undefined') {
@@ -12,6 +12,8 @@ import type { ExpressionDataDependencies } from './dataExposure'
12
12
  import { generateExplicitExpressionWrapper } from './dataExposure'
13
13
  import { wrapExpressionWithLoopContext } from './wrapExpressionWithLoop'
14
14
 
15
+ import { transformExpressionJSX } from '../transform/expressionTransformer'
16
+
15
17
  /**
16
18
  * Wrap an expression into a runtime function with explicit data arguments
17
19
  *
@@ -23,29 +25,38 @@ export function wrapExpression(
23
25
  dependencies?: ExpressionDataDependencies,
24
26
  loopContext?: LoopContext // Phase 7: Loop context for map expressions
25
27
  ): string {
28
+ const { id, code } = expr
29
+
26
30
  // Phase 7: If loop context is provided, use loop-aware wrapper
27
31
  if (loopContext && loopContext.variables.length > 0) {
28
32
  return wrapExpressionWithLoopContext(expr, loopContext, dependencies)
29
33
  }
30
-
34
+
31
35
  // If dependencies are provided, use explicit wrapper (Phase 6)
32
36
  if (dependencies) {
33
37
  return generateExplicitExpressionWrapper(expr, dependencies)
34
38
  }
35
-
39
+
36
40
  // Fallback to legacy wrapper (backwards compatibility)
37
- const { id, code } = expr
38
- const escapedCode = code.replace(/`/g, '\\`').replace(/\$/g, '\\$')
39
-
41
+ // Transform JSX-like tags inside expression code
42
+ const transformedCode = transformExpressionJSX(code)
43
+ // Escape the code for use in a single-line comment (replace newlines with spaces)
44
+ const commentCode = code.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').substring(0, 100)
45
+ const jsonEscapedCode = JSON.stringify(code)
46
+
40
47
  return `
41
- // Expression: ${escapedCode}
48
+ // Expression: ${commentCode}${code.length > 100 ? '...' : ''}
42
49
  const ${id} = (state) => {
43
50
  try {
51
+ // Expose zenith helpers for JSX and content
52
+ const __zenith = window.__zenith || {};
53
+ const zenCollection = __zenith.zenCollection || ((name) => ({ get: () => [] }));
54
+
44
55
  with (state) {
45
- return ${code};
56
+ return ${transformedCode};
46
57
  }
47
58
  } catch (e) {
48
- console.warn('[Zenith] Expression evaluation error:', ${JSON.stringify(code)}, e);
59
+ console.warn('[Zenith] Expression evaluation error:', ${jsonEscapedCode}, e);
49
60
  return undefined;
50
61
  }
51
62
  };`
@@ -65,19 +76,19 @@ export function generateExpressionWrappers(
65
76
  if (expressions.length === 0) {
66
77
  return ''
67
78
  }
68
-
79
+
69
80
  if (dependencies && dependencies.length === expressions.length) {
70
81
  // Use explicit wrappers with dependencies and optional loop contexts
71
82
  return expressions
72
83
  .map((expr, index) => {
73
- const loopCtx = loopContexts && loopContexts[index] !== undefined
74
- ? loopContexts[index]
84
+ const loopCtx = loopContexts && loopContexts[index] !== undefined
85
+ ? loopContexts[index]
75
86
  : undefined
76
87
  return wrapExpression(expr, dependencies[index], loopCtx)
77
88
  })
78
89
  .join('\n')
79
90
  }
80
-
91
+
81
92
  // Fallback to legacy wrappers (no dependencies, no loop contexts)
82
93
  return expressions.map(expr => wrapExpression(expr)).join('\n')
83
94
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { ExpressionIR, LoopContext } from '../ir/types'
11
11
  import type { ExpressionDataDependencies } from './dataExposure'
12
+ import { transformExpressionJSX } from '../transform/expressionTransformer'
12
13
 
13
14
  /**
14
15
  * Generate an expression wrapper that accepts loop context
@@ -59,6 +60,12 @@ export function wrapExpressionWithLoopContext(
59
60
  ? `const __ctx = Object.assign({}, ${contextMerge.join(', ')});`
60
61
  : `const __ctx = loopContext || {};`
61
62
 
63
+ // Transform JSX
64
+ // The fix for 'undefined' string assignment is applied within transformExpressionJSX
65
+ // by ensuring that any remaining text is properly quoted as a string literal
66
+ // or recognized as an existing h() call.
67
+ const transformedCode = transformExpressionJSX(code)
68
+
62
69
  return `
63
70
  // Expression with loop context: ${escapedCode}
64
71
  // Loop variables: ${loopContext.variables.join(', ')}
@@ -66,7 +73,7 @@ export function wrapExpressionWithLoopContext(
66
73
  try {
67
74
  ${contextObject}
68
75
  with (__ctx) {
69
- return ${code};
76
+ return ${transformedCode};
70
77
  }
71
78
  } catch (e) {
72
79
  console.warn('[Zenith] Expression evaluation error for "${escapedCode}":', e);
@@ -74,4 +81,3 @@ export function wrapExpressionWithLoopContext(
74
81
  }
75
82
  };`
76
83
  }
77
-
@@ -19,6 +19,7 @@ import { processLayout } from "./transform/layoutProcessor"
19
19
  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
+ import { loadContent } from "../cli/utils/content"
22
23
 
23
24
  // ============================================
24
25
  // Types
@@ -130,7 +131,7 @@ function compilePage(
130
131
  * Static pages: no JS references
131
132
  * Hydrated pages: bundle.js + page-specific JS
132
133
  */
133
- function generatePageHTML(page: CompiledPage, globalStyles: string): string {
134
+ function generatePageHTML(page: CompiledPage, globalStyles: string, contentData: any): string {
134
135
  const { html, styles, analysis, routePath, pageScript } = page
135
136
 
136
137
  // Combine styles
@@ -141,6 +142,7 @@ function generatePageHTML(page: CompiledPage, globalStyles: string): string {
141
142
  let scriptTags = ''
142
143
  if (analysis.needsHydration) {
143
144
  scriptTags = `
145
+ <script>window.__ZENITH_CONTENT__ = ${JSON.stringify(contentData)};</script>
144
146
  <script src="/assets/bundle.js"></script>`
145
147
 
146
148
  if (pageScript) {
@@ -238,6 +240,8 @@ ${page.pageScript}
238
240
  */
239
241
  export function buildSSG(options: SSGBuildOptions): void {
240
242
  const { pagesDir, outDir, baseDir = path.dirname(pagesDir) } = options
243
+ const contentDir = path.join(baseDir, 'content')
244
+ const contentData = loadContent(contentDir)
241
245
 
242
246
  console.log('🔨 Zenith SSG Build')
243
247
  console.log(` Pages: ${pagesDir}`)
@@ -316,7 +320,7 @@ export function buildSSG(options: SSGBuildOptions): void {
316
320
  fs.mkdirSync(pageOutDir, { recursive: true })
317
321
 
318
322
  // Generate and write HTML
319
- const html = generatePageHTML(page, globalStyles)
323
+ const html = generatePageHTML(page, globalStyles, contentData)
320
324
  fs.writeFileSync(path.join(pageOutDir, 'index.html'), html)
321
325
 
322
326
  // Write page-specific JS if needed
@@ -347,7 +351,7 @@ export function buildSSG(options: SSGBuildOptions): void {
347
351
  if (fs.existsSync(custom404Path)) {
348
352
  try {
349
353
  const compiled = compilePage(custom404Path, pagesDir, baseDir)
350
- const html = generatePageHTML(compiled, globalStyles)
354
+ const html = generatePageHTML(compiled, globalStyles, contentData)
351
355
  fs.writeFileSync(path.join(outDir, '404.html'), html)
352
356
  console.log('📦 Generated 404.html (custom)')
353
357
  has404 = true