@zenithbuild/core 0.6.2 → 1.1.0

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.
@@ -56,11 +56,22 @@ function lowerNode(
56
56
  case 'expression':
57
57
  return lowerExpressionNode(node, filePath, expressions)
58
58
 
59
- case 'element':
59
+ case 'element': {
60
+ // Check if this is a <for> element directive
61
+ if (node.tag === 'for') {
62
+ return lowerForElement(node, filePath, expressions)
63
+ }
64
+
65
+ // Check if this is an <html-content> element directive
66
+ if (node.tag === 'html-content') {
67
+ return lowerHtmlContentElement(node, filePath, expressions)
68
+ }
69
+
60
70
  return {
61
71
  ...node,
62
72
  children: lowerFragments(node.children, filePath, expressions)
63
73
  }
74
+ }
64
75
 
65
76
  case 'component':
66
77
  return {
@@ -276,6 +287,147 @@ function lowerInlineFragment(
276
287
  return node
277
288
  }
278
289
 
290
+ /**
291
+ * Lower <for> element directive to LoopFragmentNode
292
+ *
293
+ * Syntax: <for each="item" in="items">...body...</for>
294
+ * Or: <for each="item, index" in="items">...body...</for>
295
+ *
296
+ * This is compile-time sugar for {items.map(item => ...)}
297
+ */
298
+ function lowerForElement(
299
+ node: import('../ir/types').ElementNode,
300
+ filePath: string,
301
+ expressions: ExpressionIR[]
302
+ ): LoopFragmentNode {
303
+ // Extract 'each' and 'in' attributes
304
+ const eachAttr = node.attributes.find(a => a.name === 'each')
305
+ const inAttr = node.attributes.find(a => a.name === 'in')
306
+
307
+ if (!eachAttr || typeof eachAttr.value !== 'string') {
308
+ throw new InvariantError(
309
+ 'ZEN001',
310
+ `<for> element requires an 'each' attribute specifying the item variable`,
311
+ 'Usage: <for each="item" in="items">...body...</for>',
312
+ filePath,
313
+ node.location.line,
314
+ node.location.column
315
+ )
316
+ }
317
+
318
+ if (!inAttr || typeof inAttr.value !== 'string') {
319
+ throw new InvariantError(
320
+ 'ZEN001',
321
+ `<for> element requires an 'in' attribute specifying the source array`,
322
+ 'Usage: <for each="item" in="items">...body...</for>',
323
+ filePath,
324
+ node.location.line,
325
+ node.location.column
326
+ )
327
+ }
328
+
329
+ // Parse item variable (may include index: "item, index" or "item, i")
330
+ const eachValue = eachAttr.value.trim()
331
+ let itemVar: string
332
+ let indexVar: string | undefined
333
+
334
+ if (eachValue.includes(',')) {
335
+ const parts = eachValue.split(',').map(p => p.trim())
336
+ itemVar = parts[0]!
337
+ indexVar = parts[1]
338
+ } else {
339
+ itemVar = eachValue
340
+ }
341
+
342
+ const source = inAttr.value.trim()
343
+
344
+ // Create loop context for the body
345
+ const loopVariables = [itemVar]
346
+ if (indexVar) {
347
+ loopVariables.push(indexVar)
348
+ }
349
+
350
+ const bodyLoopContext: LoopContext = {
351
+ variables: node.loopContext
352
+ ? [...node.loopContext.variables, ...loopVariables]
353
+ : loopVariables,
354
+ mapSource: source
355
+ }
356
+
357
+ // Lower children with loop context
358
+ const body = node.children.map(child => {
359
+ // Recursively lower children
360
+ const lowered = lowerNode(child, filePath, expressions)
361
+ // Attach loop context to children that need it
362
+ if ('loopContext' in lowered) {
363
+ return { ...lowered, loopContext: bodyLoopContext }
364
+ }
365
+ return lowered
366
+ })
367
+
368
+ return {
369
+ type: 'loop-fragment',
370
+ source,
371
+ itemVar,
372
+ indexVar,
373
+ body,
374
+ location: node.location,
375
+ loopContext: bodyLoopContext
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Lower <html-content> element directive
381
+ *
382
+ * Syntax: <html-content content="expr" />
383
+ *
384
+ * This renders the expression value as raw HTML (innerHTML).
385
+ * Use with caution - only for trusted content like icons defined in code.
386
+ */
387
+ function lowerHtmlContentElement(
388
+ node: import('../ir/types').ElementNode,
389
+ filePath: string,
390
+ expressions: ExpressionIR[]
391
+ ): TemplateNode {
392
+ // Extract 'content' attribute
393
+ const contentAttr = node.attributes.find(a => a.name === 'content')
394
+
395
+ if (!contentAttr || typeof contentAttr.value !== 'string') {
396
+ throw new InvariantError(
397
+ 'ZEN001',
398
+ `<html-content> element requires a 'content' attribute specifying the expression`,
399
+ 'Usage: <html-content content="item.html" />',
400
+ filePath,
401
+ node.location.line,
402
+ node.location.column
403
+ )
404
+ }
405
+
406
+ const exprCode = contentAttr.value.trim()
407
+
408
+ // Generate expression ID and register the expression
409
+ const exprId = `expr_${expressions.length}`
410
+ const exprIR: ExpressionIR = {
411
+ id: exprId,
412
+ code: exprCode,
413
+ location: node.location
414
+ }
415
+ expressions.push(exprIR)
416
+
417
+ // Create a span element with data-zen-html attribute for raw HTML binding
418
+ return {
419
+ type: 'element',
420
+ tag: 'span',
421
+ attributes: [
422
+ { name: 'data-zen-html', value: exprIR, location: node.location, loopContext: node.loopContext },
423
+ { name: 'style', value: 'display: contents;', location: node.location }
424
+ ],
425
+ children: [],
426
+ location: node.location,
427
+ loopContext: node.loopContext
428
+ }
429
+ }
430
+
279
431
  /**
280
432
  * Parse JSX code string into TemplateNode[]
281
433
  *
@@ -6,6 +6,11 @@
6
6
 
7
7
  import type { Binding } from '../output/types'
8
8
 
9
+ /**
10
+ * Valid binding types
11
+ */
12
+ const VALID_BINDING_TYPES = new Set(['text', 'attribute', 'loop', 'conditional', 'optional'])
13
+
9
14
  /**
10
15
  * Validate bindings structure
11
16
  */
@@ -15,19 +20,35 @@ export function validateBindings(bindings: Binding[]): void {
15
20
  throw new Error(`Invalid binding: ${JSON.stringify(binding)}`)
16
21
  }
17
22
 
18
- if (binding.type !== 'text' && binding.type !== 'attribute') {
23
+ if (!VALID_BINDING_TYPES.has(binding.type)) {
19
24
  throw new Error(`Invalid binding type: ${binding.type}`)
20
25
  }
21
26
 
22
- if (binding.type === 'text' && binding.target !== 'data-zen-text') {
23
- throw new Error(`Text binding must have target 'data-zen-text', got: ${binding.target}`)
24
- }
25
-
26
- if (binding.type === 'attribute' && !binding.target.startsWith('data-zen-attr-')) {
27
- // This is handled in transformNode, but validate here too
28
- // Actually, the target should be the attribute name (e.g., "class")
29
- // and we prepend "data-zen-attr-" when generating HTML
30
- // So this validation is correct
27
+ // Validate specific binding types
28
+ switch (binding.type) {
29
+ case 'text':
30
+ if (binding.target !== 'data-zen-text') {
31
+ throw new Error(`Text binding must have target 'data-zen-text', got: ${binding.target}`)
32
+ }
33
+ break
34
+ case 'loop':
35
+ if (binding.target !== 'data-zen-loop') {
36
+ throw new Error(`Loop binding must have target 'data-zen-loop', got: ${binding.target}`)
37
+ }
38
+ break
39
+ case 'conditional':
40
+ if (binding.target !== 'data-zen-cond') {
41
+ throw new Error(`Conditional binding must have target 'data-zen-cond', got: ${binding.target}`)
42
+ }
43
+ break
44
+ case 'optional':
45
+ if (binding.target !== 'data-zen-opt') {
46
+ throw new Error(`Optional binding must have target 'data-zen-opt', got: ${binding.target}`)
47
+ }
48
+ break
49
+ case 'attribute':
50
+ // Attribute bindings can have various targets
51
+ break
31
52
  }
32
53
  }
33
54
  }
@@ -2,11 +2,30 @@
2
2
  * Transform Template Nodes
3
3
  *
4
4
  * Transforms IR nodes into HTML strings and collects bindings
5
+ *
6
+ * Phase 8: Supports fragment node types (loop-fragment, conditional-fragment, optional-fragment)
5
7
  */
6
8
 
7
- import type { TemplateNode, ElementNode, TextNode, ExpressionNode, ExpressionIR, LoopContext } from '../ir/types'
9
+ import type {
10
+ TemplateNode,
11
+ ElementNode,
12
+ TextNode,
13
+ ExpressionNode,
14
+ ExpressionIR,
15
+ LoopContext,
16
+ LoopFragmentNode,
17
+ ConditionalFragmentNode,
18
+ OptionalFragmentNode,
19
+ ComponentNode
20
+ } from '../ir/types'
8
21
  import type { Binding } from '../output/types'
9
22
 
23
+ let loopIdCounter = 0
24
+
25
+ function generateLoopId(): string {
26
+ return `loop_${loopIdCounter++}`
27
+ }
28
+
10
29
  let bindingIdCounter = 0
11
30
 
12
31
  function generateBindingId(): string {
@@ -105,6 +124,100 @@ export function transformNode(
105
124
 
106
125
  return `<${tag}${attrStr}>${childrenHtml}</${tag}>`
107
126
  }
127
+
128
+ case 'loop-fragment': {
129
+ // Loop fragment: {items.map(item => <li>...</li>)} or <for each="item" in="items">
130
+ // For SSR/SSG, we render one instance of the body as a template
131
+ // The runtime will hydrate and expand this for each actual item
132
+ const loopNode = node as LoopFragmentNode
133
+ const loopId = generateLoopId()
134
+ const activeLoopContext = loopNode.loopContext || loopContext
135
+
136
+ // Create a binding for the loop expression
137
+ bindings.push({
138
+ id: loopId,
139
+ type: 'loop',
140
+ target: 'data-zen-loop',
141
+ expression: loopNode.source,
142
+ location: loopNode.location,
143
+ loopContext: activeLoopContext,
144
+ loopMeta: {
145
+ itemVar: loopNode.itemVar,
146
+ indexVar: loopNode.indexVar,
147
+ bodyTemplate: loopNode.body
148
+ }
149
+ })
150
+
151
+ // Generate the loop body template HTML
152
+ // For SSR, we render ONE visible instance of the body as a template/placeholder
153
+ // The runtime will clone this for each item in the array
154
+ const bodyHtml = loopNode.body.map(child => transform(child, activeLoopContext)).join('')
155
+
156
+ // Render container with body visible for SSR (not in hidden <template>)
157
+ // Runtime will clear and re-render with actual data
158
+ return `<div data-zen-loop="${loopId}" data-zen-source="${escapeHtml(loopNode.source)}" data-zen-item="${loopNode.itemVar}"${loopNode.indexVar ? ` data-zen-index="${loopNode.indexVar}"` : ''} style="display: contents;">${bodyHtml}</div>`
159
+ }
160
+
161
+ case 'conditional-fragment': {
162
+ // Conditional fragment: {cond ? <A /> : <B />}
163
+ // Both branches are pre-rendered, runtime toggles visibility
164
+ const condNode = node as ConditionalFragmentNode
165
+ const condId = generateBindingId()
166
+ const activeLoopContext = condNode.loopContext || loopContext
167
+
168
+ bindings.push({
169
+ id: condId,
170
+ type: 'conditional',
171
+ target: 'data-zen-cond',
172
+ expression: condNode.condition,
173
+ location: condNode.location,
174
+ loopContext: activeLoopContext
175
+ })
176
+
177
+ // Render both branches
178
+ const consequentHtml = condNode.consequent.map(child => transform(child, activeLoopContext)).join('')
179
+ const alternateHtml = condNode.alternate.map(child => transform(child, activeLoopContext)).join('')
180
+
181
+ return `<div data-zen-cond="${condId}" data-zen-cond-true style="display: contents;">${consequentHtml}</div><div data-zen-cond="${condId}" data-zen-cond-false style="display: none;">${alternateHtml}</div>`
182
+ }
183
+
184
+ case 'optional-fragment': {
185
+ // Optional fragment: {cond && <A />}
186
+ // Fragment is pre-rendered, runtime toggles mount/unmount
187
+ const optNode = node as OptionalFragmentNode
188
+ const optId = generateBindingId()
189
+ const activeLoopContext = optNode.loopContext || loopContext
190
+
191
+ bindings.push({
192
+ id: optId,
193
+ type: 'optional',
194
+ target: 'data-zen-opt',
195
+ expression: optNode.condition,
196
+ location: optNode.location,
197
+ loopContext: activeLoopContext
198
+ })
199
+
200
+ const fragmentHtml = optNode.fragment.map(child => transform(child, activeLoopContext)).join('')
201
+
202
+ return `<div data-zen-opt="${optId}" style="display: contents;">${fragmentHtml}</div>`
203
+ }
204
+
205
+ case 'component': {
206
+ // Component node - should have been resolved before reaching here
207
+ // This is a fallback for unresolved components
208
+ const compNode = node as ComponentNode
209
+ console.warn(`[Zenith] Unresolved component in transformNode: ${compNode.name}`)
210
+
211
+ // Render children as a fragment
212
+ const childrenHtml = compNode.children.map(child => transform(child, loopContext)).join('')
213
+ return `<!-- unresolved: ${compNode.name} -->${childrenHtml}`
214
+ }
215
+
216
+ default: {
217
+ // Handle any unknown node types
218
+ console.warn(`[Zenith] Unknown node type in transformNode: ${(node as any).type}`)
219
+ return ''
220
+ }
108
221
  }
109
222
  }
110
223
 
@@ -2,6 +2,9 @@
2
2
  * Zenith Config
3
3
  *
4
4
  * Public exports for zenith/config
5
+ *
6
+ * Core exports ONLY generic plugin infrastructure.
7
+ * Plugin-specific types are owned by their respective plugins.
5
8
  */
6
9
 
7
10
  export { defineConfig } from './types';
@@ -9,8 +12,7 @@ export type {
9
12
  ZenithConfig,
10
13
  ZenithPlugin,
11
14
  PluginContext,
12
- ContentSourceConfig,
13
- ContentPluginOptions,
14
- ContentItem
15
+ PluginData
15
16
  } from './types';
16
17
  export { loadZenithConfig, hasZenithConfig } from './loader';
18
+
@@ -2,71 +2,101 @@
2
2
  * Zenith Config Types
3
3
  *
4
4
  * Configuration interfaces for zenith.config.ts
5
+ *
6
+ * ═══════════════════════════════════════════════════════════════════════════════
7
+ * HOOK OWNERSHIP RULE (CANONICAL)
8
+ * ═══════════════════════════════════════════════════════════════════════════════
9
+ *
10
+ * Core may ONLY define types that are universally valid in all Zenith applications.
11
+ * Plugin-specific types MUST be owned by their respective plugins.
12
+ *
13
+ * ✅ ALLOWED in Core:
14
+ * - ZenithConfig, ZenithPlugin, PluginContext (generic plugin infrastructure)
15
+ * - Universal lifecycle hooks (onMount, onUnmount)
16
+ * - Reactivity primitives (signal, effect, etc.)
17
+ *
18
+ * ❌ PROHIBITED in Core:
19
+ * - Content plugin types (ContentItem, ContentSourceConfig, etc.)
20
+ * - Router plugin types (RouteState, NavigationGuard, etc.)
21
+ * - Documentation plugin types
22
+ * - Any type that exists only because a plugin exists
23
+ *
24
+ * If removing a plugin would make a type meaningless, that type belongs to the plugin.
25
+ * ═══════════════════════════════════════════════════════════════════════════════
5
26
  */
6
27
 
28
+ import type { CLIBridgeAPI } from '../plugins/bridge';
29
+
7
30
  // ============================================
8
- // Content Plugin Types
31
+ // Core Plugin Types (Generic Infrastructure)
9
32
  // ============================================
10
33
 
11
34
  /**
12
- * Configuration for a content source
35
+ * Generic data record for plugin data exchange
36
+ * Plugins define their own specific types internally
13
37
  */
14
- export interface ContentSourceConfig {
15
- /** Root directory relative to project root (e.g., "../zenith-docs" or "content") */
16
- root: string;
17
- /** Folders to include from the root (e.g., ["documentation"]). Defaults to all. */
18
- include?: string[];
19
- /** Folders to exclude from the root (e.g., ["changelog"]) */
20
- exclude?: string[];
21
- }
22
-
23
- /**
24
- * Options for the content plugin
25
- */
26
- export interface ContentPluginOptions {
27
- /** Named content sources mapped to their configuration */
28
- sources?: Record<string, ContentSourceConfig>;
29
- /** Legacy: Single content directory (deprecated, use sources instead) */
30
- contentDir?: string;
31
- }
32
-
33
- // ============================================
34
- // Core Plugin Types
35
- // ============================================
38
+ export type PluginData = Record<string, unknown[]>;
36
39
 
37
40
  /**
38
41
  * Context passed to plugins during setup
42
+ *
43
+ * This is intentionally generic - plugins define their own data shapes.
44
+ * Core provides the stage, plugins bring the actors.
39
45
  */
40
46
  export interface PluginContext {
41
47
  /** Absolute path to project root */
42
48
  projectRoot: string;
43
- /** Set content data for the runtime */
44
- setContentData: (data: Record<string, ContentItem[]>) => void;
49
+
50
+ /**
51
+ * Set plugin data for the runtime
52
+ *
53
+ * Generic setter - plugins define their own data structures.
54
+ * The runtime stores this data and makes it available to components.
55
+ *
56
+ * @example
57
+ * // Content plugin uses it for content items
58
+ * ctx.setPluginData('content', contentItems);
59
+ *
60
+ * // Analytics plugin uses it for tracking config
61
+ * ctx.setPluginData('analytics', analyticsConfig);
62
+ */
63
+ setPluginData: (namespace: string, data: unknown[]) => void;
64
+
45
65
  /** Additional options passed from config */
46
66
  options?: Record<string, unknown>;
47
67
  }
48
68
 
49
- /**
50
- * A content item loaded from a source
51
- */
52
- export interface ContentItem {
53
- id?: string | number;
54
- slug?: string | null;
55
- collection?: string | null;
56
- content?: string | null;
57
- [key: string]: unknown;
58
- }
59
-
60
69
  /**
61
70
  * A Zenith plugin definition
71
+ *
72
+ * Plugins are self-contained, removable extensions.
73
+ * Core must build and run identically with or without any plugin installed.
62
74
  */
63
75
  export interface ZenithPlugin {
64
76
  /** Unique plugin name */
65
77
  name: string;
78
+
66
79
  /** Setup function called during initialization */
67
80
  setup: (ctx: PluginContext) => void | Promise<void>;
81
+
68
82
  /** Plugin-specific configuration (preserved for reference) */
69
83
  config?: unknown;
84
+
85
+ /**
86
+ * Optional CLI registration
87
+ *
88
+ * Plugin receives the CLI bridge API to register namespaced hooks.
89
+ * CLI lifecycle hooks: 'cli:*' (owned by CLI)
90
+ * Plugin hooks: '<namespace>:*' (owned by plugin)
91
+ *
92
+ * @example
93
+ * registerCLI(api) {
94
+ * api.on('cli:runtime:collect', (ctx) => {
95
+ * return { namespace: 'myPlugin', payload: ctx.getPluginData('myPlugin') }
96
+ * })
97
+ * }
98
+ */
99
+ registerCLI?: (api: CLIBridgeAPI) => void;
70
100
  }
71
101
 
72
102
  // ============================================