@zenithbuild/core 0.4.6 → 0.5.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.
@@ -1,5 +1,6 @@
1
1
  import path from 'path'
2
2
  import fs from 'fs'
3
+ import os from 'os'
3
4
  import { serve, type ServerWebSocket } from 'bun'
4
5
  import { requireProject } from '../utils/project'
5
6
  import * as logger from '../utils/logger'
@@ -29,6 +30,53 @@ interface CompiledPage {
29
30
 
30
31
  const pageCache = new Map<string, CompiledPage>()
31
32
 
33
+ /**
34
+ * Bundle page script using Bun's bundler to resolve npm imports at compile time.
35
+ * This allows ES module imports like `import { gsap } from 'gsap'` to work.
36
+ */
37
+ async function bundlePageScript(script: string, projectRoot: string): Promise<string> {
38
+ // If no import statements, return as-is
39
+ if (!script.includes('import ')) {
40
+ return script
41
+ }
42
+
43
+ // Create a temporary file for bundling
44
+ const tempDir = os.tmpdir()
45
+ const tempFile = path.join(tempDir, `zenith-bundle-${Date.now()}.js`)
46
+
47
+ try {
48
+ // Write script to temp file
49
+ fs.writeFileSync(tempFile, script, 'utf-8')
50
+
51
+ // Use Bun.build to bundle with npm resolution
52
+ const result = await Bun.build({
53
+ entrypoints: [tempFile],
54
+ target: 'browser',
55
+ format: 'esm',
56
+ minify: false,
57
+ // Resolve modules from the project's node_modules
58
+ external: [], // Bundle everything
59
+ })
60
+
61
+ if (!result.success || !result.outputs[0]) {
62
+ console.error('[Zenith] Bundle errors:', result.logs)
63
+ return script // Fall back to original
64
+ }
65
+
66
+ // Get the bundled output
67
+ const bundledCode = await result.outputs[0].text()
68
+ return bundledCode
69
+ } catch (error: any) {
70
+ console.error('[Zenith] Failed to bundle page script:', error.message)
71
+ return script // Fall back to original
72
+ } finally {
73
+ // Clean up temp file
74
+ try {
75
+ fs.unlinkSync(tempFile)
76
+ } catch { }
77
+ }
78
+ }
79
+
32
80
  export async function dev(options: DevOptions = {}): Promise<void> {
33
81
  const project = requireProject()
34
82
  const port = options.port || parseInt(process.env.PORT || '3000', 10)
@@ -105,7 +153,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
105
153
  /**
106
154
  * Compile a .zen page in memory
107
155
  */
108
- function compilePageInMemory(pagePath: string): CompiledPage | null {
156
+ async function compilePageInMemory(pagePath: string): Promise<CompiledPage | null> {
109
157
  try {
110
158
  const layoutsDir = path.join(pagesDir, '../layouts')
111
159
  const componentsDir = path.join(pagesDir, '../components')
@@ -117,16 +165,19 @@ export async function dev(options: DevOptions = {}): Promise<void> {
117
165
 
118
166
  if (layoutToUse) processedSource = processLayout(source, layoutToUse)
119
167
 
120
- const result = compileZenSource(processedSource, pagePath, {
168
+ const result = await compileZenSource(processedSource, pagePath, {
121
169
  componentsDir: fs.existsSync(componentsDir) ? componentsDir : undefined
122
170
  })
123
171
  if (!result.finalized) throw new Error('Compilation failed')
124
172
 
125
173
  const routeDef = generateRouteDefinition(pagePath, pagesDir)
126
174
 
175
+ // Bundle the script to resolve npm imports at compile time
176
+ const bundledScript = await bundlePageScript(result.finalized.js, rootDir)
177
+
127
178
  return {
128
179
  html: result.finalized.html,
129
- script: result.finalized.js,
180
+ script: bundledScript,
130
181
  styles: result.finalized.styles,
131
182
  route: routeDef.path,
132
183
  lastModified: Date.now()
@@ -189,7 +240,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
189
240
 
190
241
  const server = serve({
191
242
  port,
192
- fetch(req, server) {
243
+ async fetch(req, server) {
193
244
  const startTime = performance.now()
194
245
  const url = new URL(req.url)
195
246
  const pathname = url.pathname
@@ -246,7 +297,7 @@ export async function dev(options: DevOptions = {}): Promise<void> {
246
297
  const stat = fs.statSync(pagePath)
247
298
 
248
299
  if (!cached || stat.mtimeMs > cached.lastModified) {
249
- cached = compilePageInMemory(pagePath) || undefined
300
+ cached = await compilePageInMemory(pagePath) || undefined
250
301
  if (cached) pageCache.set(pagePath, cached)
251
302
  }
252
303
  const compileEnd = performance.now()
@@ -327,7 +378,8 @@ function generateDevHTML(page: CompiledPage, contentData: any = {}): string {
327
378
  // Escape </script> sequences in JSON content to prevent breaking the script tag
328
379
  const contentJson = JSON.stringify(contentData).replace(/<\//g, '<\\/')
329
380
  const contentTag = `<script>window.__ZENITH_CONTENT__ = ${contentJson};</script>`
330
- const scriptTag = `<script>\n${page.script}\n</script>`
381
+ // Use type="module" to support ES6 imports from npm packages
382
+ const scriptTag = `<script type="module">\n${page.script}\n</script>`
331
383
  const allScripts = `${runtimeTag}\n${contentTag}\n${scriptTag}`
332
384
  return page.html.includes('</body>')
333
385
  ? page.html.replace('</body>', `${allScripts}\n</body>`)
@@ -26,6 +26,8 @@ export interface ComponentMetadata {
26
26
  slots: SlotDefinition[]
27
27
  props: string[] // Declared props
28
28
  styles: string[] // Raw CSS from <style> blocks
29
+ script: string | null // Raw script content for bundling
30
+ scriptAttributes: Record<string, string> | null // Script attributes (setup, lang)
29
31
  hasScript: boolean
30
32
  hasStyles: boolean
31
33
  }
@@ -112,6 +114,8 @@ function parseComponentFile(filePath: string): ComponentMetadata | null {
112
114
  slots,
113
115
  props,
114
116
  styles,
117
+ script: ir.script?.raw || null, // Store raw script content
118
+ scriptAttributes: ir.script?.attributes || null, // Store script attributes
115
119
  hasScript: ir.script !== null,
116
120
  hasStyles: ir.styles.length > 0
117
121
  }
@@ -41,8 +41,17 @@ export function discoverLayouts(layoutsDir: string): Map<string, LayoutMetadata>
41
41
  if (match[1]) styles.push(match[1].trim())
42
42
  }
43
43
 
44
- // Extract HTML (everything except script/style)
45
- let html = source.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
44
+ // Extract HTML (everything except inline scripts/style)
45
+ // Preserve external script tags (<script src="...">) but remove inline <script setup> blocks
46
+ // Use a function-based replace to check for src attribute
47
+ let html = source.replace(/<script([^>]*)>([\s\S]*?)<\/script>/gi, (match, attrs, content) => {
48
+ // Keep script tags with src attribute (external scripts)
49
+ if (attrs.includes('src=')) {
50
+ return match;
51
+ }
52
+ // Remove inline scripts (those without src)
53
+ return '';
54
+ })
46
55
  html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '').trim()
47
56
 
48
57
  layouts.set(name, {
@@ -39,10 +39,10 @@ export interface FinalizedOutput {
39
39
  * @param compiled - Compiled template from Phase 2
40
40
  * @returns Finalized output
41
41
  */
42
- export function finalizeOutput(
42
+ export async function finalizeOutput(
43
43
  ir: ZenIR,
44
44
  compiled: CompiledTemplate
45
- ): FinalizedOutput {
45
+ ): Promise<FinalizedOutput> {
46
46
  const errors: string[] = []
47
47
 
48
48
  // 1. Validate all expressions (Phase 8/9/10 requirement)
@@ -77,7 +77,7 @@ export function finalizeOutput(
77
77
  // 3. Generate runtime code
78
78
  let runtimeCode: RuntimeCode
79
79
  try {
80
- runtimeCode = transformIR(ir)
80
+ runtimeCode = await transformIR(ir)
81
81
  } catch (error: any) {
82
82
  errors.push(`Runtime generation failed: ${error.message}`)
83
83
  return {
@@ -176,11 +176,11 @@ function verifyNoRawExpressions(html: string, filePath: string): string[] {
176
176
  *
177
177
  * Throws if validation fails (build must fail on errors)
178
178
  */
179
- export function finalizeOutputOrThrow(
179
+ export async function finalizeOutputOrThrow(
180
180
  ir: ZenIR,
181
181
  compiled: CompiledTemplate
182
- ): FinalizedOutput {
183
- const output = finalizeOutput(ir, compiled)
182
+ ): Promise<FinalizedOutput> {
183
+ const output = await finalizeOutput(ir, compiled)
184
184
 
185
185
  if (output.hasErrors) {
186
186
  const errorMessage = output.errors.join('\n\n')
package/compiler/index.ts CHANGED
@@ -12,11 +12,11 @@ import type { FinalizedOutput } from './finalize/finalizeOutput'
12
12
  /**
13
13
  * Compile a .zen file into IR and CompiledTemplate
14
14
  */
15
- export function compileZen(filePath: string): {
15
+ export async function compileZen(filePath: string): Promise<{
16
16
  ir: ZenIR
17
17
  compiled: CompiledTemplate
18
18
  finalized?: FinalizedOutput
19
- } {
19
+ }> {
20
20
  const source = readFileSync(filePath, 'utf-8')
21
21
  return compileZenSource(source, filePath)
22
22
  }
@@ -24,17 +24,17 @@ export function compileZen(filePath: string): {
24
24
  /**
25
25
  * Compile Zen source string into IR and CompiledTemplate
26
26
  */
27
- export function compileZenSource(
27
+ export async function compileZenSource(
28
28
  source: string,
29
29
  filePath: string,
30
30
  options?: {
31
31
  componentsDir?: string
32
32
  }
33
- ): {
33
+ ): Promise<{
34
34
  ir: ZenIR
35
35
  compiled: CompiledTemplate
36
36
  finalized?: FinalizedOutput
37
- } {
37
+ }> {
38
38
  // Parse template
39
39
  const template = parseTemplate(source, filePath)
40
40
 
@@ -42,7 +42,7 @@ export function compileZenSource(
42
42
  const script = parseScript(source)
43
43
 
44
44
  // Parse styles
45
- const styleRegex = /\u003cstyle[^\u003e]*\u003e([\s\S]*?)\u003c\/style\u003e/gi
45
+ const styleRegex = /<style[^>]*>([\s\S]*?)<\/style>/gi
46
46
  const styles: StyleIR[] = []
47
47
  let match
48
48
  while ((match = styleRegex.exec(source)) !== null) {
@@ -53,6 +53,7 @@ export function compileZenSource(
53
53
  filePath,
54
54
  template,
55
55
  script,
56
+ // componentScripts: [],
56
57
  styles
57
58
  }
58
59
 
@@ -73,9 +74,10 @@ export function compileZenSource(
73
74
  const compiled = transformTemplate(ir)
74
75
 
75
76
  try {
76
- const finalized = finalizeOutputOrThrow(ir, compiled)
77
+ const finalized = await finalizeOutputOrThrow(ir, compiled)
77
78
  return { ir, compiled, finalized }
78
79
  } catch (error: any) {
79
80
  throw new Error(`Failed to finalize output for ${filePath}:\\n${error.message}`)
80
81
  }
81
82
  }
83
+
@@ -6,11 +6,35 @@
6
6
  * without any runtime execution or transformation.
7
7
  */
8
8
 
9
+ /**
10
+ * Structured ES module import metadata
11
+ * Parsed from component scripts, used for deterministic bundling
12
+ */
13
+ export interface ScriptImport {
14
+ source: string // Module specifier, e.g. 'gsap'
15
+ specifiers: string // Import clause, e.g. '{ gsap }' or 'gsap' or ''
16
+ typeOnly: boolean // TypeScript type-only import
17
+ sideEffect: boolean // Side-effect import (no specifiers)
18
+ }
19
+
20
+ /**
21
+ * Component Script IR - represents a component's script block
22
+ * Used for collecting and bundling component scripts
23
+ */
24
+ export type ComponentScriptIR = {
25
+ name: string // Component name (e.g., 'HeroSection')
26
+ script: string // Raw script content
27
+ props: string[] // Declared props
28
+ scriptAttributes: Record<string, string> // Script attributes (setup, lang)
29
+ imports: ScriptImport[] // Parsed npm imports for bundling
30
+ }
31
+
9
32
  export type ZenIR = {
10
33
  filePath: string
11
34
  template: TemplateIR
12
35
  script: ScriptIR | null
13
36
  styles: StyleIR[]
37
+ componentScripts?: ComponentScriptIR[] // Scripts from used components
14
38
  }
15
39
 
16
40
  export type TemplateIR = {
@@ -22,10 +22,18 @@ function generateExpressionId(): string {
22
22
 
23
23
  /**
24
24
  * Strip script and style blocks from HTML before parsing
25
+ * Preserves external script tags (<script src="...">) but removes inline scripts
25
26
  */
26
27
  function stripBlocks(html: string): string {
27
- // Remove script blocks
28
- let stripped = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
28
+ // Remove only inline script blocks (those WITHOUT src attribute), preserve external scripts
29
+ let stripped = html.replace(/<script([^>]*)>([\s\S]*?)<\/script>/gi, (match, attrs, content) => {
30
+ // Keep script tags with src attribute (external scripts)
31
+ if (attrs.includes('src=')) {
32
+ return match;
33
+ }
34
+ // Remove inline scripts (those without src)
35
+ return '';
36
+ })
29
37
  // Remove style blocks
30
38
  stripped = stripped.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
31
39
  return stripped
@@ -45,6 +45,7 @@ export function extractProps(script: string): string[] {
45
45
 
46
46
  /**
47
47
  * Transform script by removing state and prop declarations
48
+ * Also strips .zen imports (resolved at compile time) and other compile-time-only imports
48
49
  */
49
50
  export function transformStateDeclarations(script: string): string {
50
51
  let transformed = script
@@ -62,6 +63,13 @@ export function transformStateDeclarations(script: string): string {
62
63
  // Remove zenith/runtime imports
63
64
  transformed = transformed.replace(/import\s+{[^}]+}\s+from\s+['"]zenith\/runtime['"]\s*;?[ \t]*/g, '')
64
65
 
66
+ // Remove .zen file imports (resolved at compile time)
67
+ // Matches: import Name from '.../file.zen'; or import Name from '.../file.zen'
68
+ transformed = transformed.replace(/import\s+\w+\s+from\s+['"][^'"]*\.zen['"];?\s*/g, '')
69
+
70
+ // Remove relative imports with destructuring (components are inlined)
71
+ transformed = transformed.replace(/import\s+{[^}]*}\s+from\s+['"][^'"]+\.zen['"];?\s*/g, '')
72
+
65
73
  // Transform zenith:content imports to global lookups
66
74
  transformed = transformed.replace(
67
75
  /import\s*{\s*([^}]+)\s*}\s*from\s*['"]zenith:content['"]\s*;?/g,
@@ -4,13 +4,14 @@
4
4
  * Phase 4: Transform ZenIR into runtime-ready JavaScript code with full reactivity
5
5
  */
6
6
 
7
- import type { ZenIR } from '../ir/types'
7
+ import type { ZenIR, ScriptImport } from '../ir/types'
8
8
  import { generateExpressionWrappers } from './wrapExpression'
9
9
  import { generateDOMFunction } from './generateDOM'
10
10
  import { generateHydrationRuntime, generateExpressionRegistry } from './generateHydrationBundle'
11
11
  import { analyzeAllExpressions } from './dataExposure'
12
12
  import { generateNavigationRuntime } from './navigation'
13
13
  import { extractStateDeclarations, extractProps, transformStateDeclarations } from '../parse/scriptAnalysis'
14
+ import { transformAllComponentScripts, emitImports } from '../transform/componentScriptTransformer'
14
15
 
15
16
  export interface RuntimeCode {
16
17
  expressions: string // Expression wrapper functions
@@ -25,7 +26,7 @@ export interface RuntimeCode {
25
26
  /**
26
27
  * Transform ZenIR into runtime JavaScript code
27
28
  */
28
- export function transformIR(ir: ZenIR): RuntimeCode {
29
+ export async function transformIR(ir: ZenIR): Promise<RuntimeCode> {
29
30
  // Phase 6: Analyze expression dependencies for explicit data exposure
30
31
  const expressionDependencies = analyzeAllExpressions(
31
32
  ir.template.expressions,
@@ -70,6 +71,9 @@ export function transformIR(ir: ZenIR): RuntimeCode {
70
71
  // Transform script (remove state and prop declarations, they're handled by runtime)
71
72
  const scriptCode = transformStateDeclarations(scriptContent)
72
73
 
74
+ // Transform component scripts for instance-scoped execution (async)
75
+ const componentScriptResult = await transformAllComponentScripts(ir.componentScripts || [])
76
+
73
77
  // Generate complete runtime bundle
74
78
  const bundle = generateRuntimeBundle({
75
79
  expressions,
@@ -78,7 +82,9 @@ export function transformIR(ir: ZenIR): RuntimeCode {
78
82
  navigationRuntime,
79
83
  stylesCode,
80
84
  scriptCode,
81
- stateInitCode
85
+ stateInitCode,
86
+ componentScriptCode: componentScriptResult.code,
87
+ npmImports: componentScriptResult.imports
82
88
  })
83
89
 
84
90
  return {
@@ -103,14 +109,21 @@ function generateRuntimeBundle(parts: {
103
109
  stylesCode: string
104
110
  scriptCode: string
105
111
  stateInitCode: string
112
+ componentScriptCode: string // Component factories
113
+ npmImports: ScriptImport[] // Structured npm imports from component scripts
106
114
  }): string {
107
115
  // Extract function declarations from script code to register on window
108
116
  const functionRegistrations = extractFunctionRegistrations(parts.scriptCode)
109
117
 
118
+ // Generate npm imports header (hoisted, deduplicated, deterministic)
119
+ const npmImportsHeader = parts.npmImports.length > 0
120
+ ? `// NPM Imports (hoisted from component scripts)\n${emitImports(parts.npmImports)}\n\n`
121
+ : ''
122
+
110
123
  return `// Zenith Runtime Bundle (Phase 5)
111
124
  // Generated at compile time - no .zen parsing in browser
112
125
 
113
- ${parts.expressions}
126
+ ${npmImportsHeader}${parts.expressions}
114
127
 
115
128
  ${parts.expressionRegistry}
116
129
 
@@ -129,6 +142,9 @@ ${functionRegistrations}
129
142
  ${parts.stateInitCode ? `// State initialization
130
143
  ${parts.stateInitCode}` : ''}
131
144
 
145
+ ${parts.componentScriptCode ? `// Component factories (instance-scoped)
146
+ ${parts.componentScriptCode}` : ''}
147
+
132
148
  // Export hydration functions
133
149
  if (typeof window !== 'undefined') {
134
150
  window.zenithHydrate = window.__zenith_hydrate || function(state, container) {
@@ -187,10 +203,21 @@ if (typeof window !== 'undefined') {
187
203
  // Get the router outlet or body
188
204
  const container = document.querySelector('#app') || document.body;
189
205
 
190
- // Hydrate with state
206
+ // Hydrate with state (expressions, bindings)
191
207
  if (window.__zenith_hydrate) {
192
208
  window.__zenith_hydrate(state, {}, {}, {}, container);
193
209
  }
210
+
211
+ // Hydrate components by discovering data-zen-component markers
212
+ // This is the ONLY place component instantiation happens - driven by DOM markers
213
+ if (window.__zenith && window.__zenith.hydrateComponents) {
214
+ window.__zenith.hydrateComponents(container);
215
+ }
216
+
217
+ // Trigger page-level mount lifecycle
218
+ if (window.__zenith && window.__zenith.triggerMount) {
219
+ window.__zenith.triggerMount();
220
+ }
194
221
  }
195
222
 
196
223
  // Run on DOM ready
@@ -43,11 +43,11 @@ interface SPABuildOptions {
43
43
  /**
44
44
  * Compile a single page file
45
45
  */
46
- function compilePage(
46
+ async function compilePage(
47
47
  pagePath: string,
48
48
  pagesDir: string,
49
49
  baseDir: string = process.cwd()
50
- ): CompiledPage {
50
+ ): Promise<CompiledPage> {
51
51
  try {
52
52
  const layoutsDir = path.join(baseDir, 'app', 'layouts')
53
53
  const layouts = discoverLayouts(layoutsDir)
@@ -63,7 +63,7 @@ function compilePage(
63
63
  }
64
64
 
65
65
  // Use new compiler pipeline on the processed source
66
- const result = compileZenSource(processedSource, pagePath)
66
+ const result = await compileZenSource(processedSource, pagePath)
67
67
 
68
68
  if (!result.finalized) {
69
69
  throw new Error(`Compilation failed: No finalized output`)
@@ -843,7 +843,7 @@ function generateHTMLShell(
843
843
  /**
844
844
  * Build SPA from pages directory
845
845
  */
846
- export function buildSPA(options: SPABuildOptions): void {
846
+ export async function buildSPA(options: SPABuildOptions): Promise<void> {
847
847
  const { pagesDir, outDir, baseDir } = options
848
848
 
849
849
  // Clean output directory
@@ -870,7 +870,7 @@ export function buildSPA(options: SPABuildOptions): void {
870
870
  console.log(`[Zenith Build] Compiling: ${path.relative(pagesDir, pageFile)}`)
871
871
 
872
872
  try {
873
- const compiled = compilePage(pageFile, pagesDir)
873
+ const compiled = await compilePage(pageFile, pagesDir)
874
874
  compiledPages.push(compiled)
875
875
  } catch (error) {
876
876
  console.error(`[Zenith Build] Error compiling ${pageFile}:`, error)
@@ -65,11 +65,11 @@ export interface SSGBuildOptions {
65
65
  /**
66
66
  * Compile a single page file for SSG output
67
67
  */
68
- function compilePage(
68
+ async function compilePage(
69
69
  pagePath: string,
70
70
  pagesDir: string,
71
71
  baseDir: string = process.cwd()
72
- ): CompiledPage {
72
+ ): Promise<CompiledPage> {
73
73
  const source = fs.readFileSync(pagePath, 'utf-8')
74
74
 
75
75
  // Analyze page requirements
@@ -88,7 +88,7 @@ function compilePage(
88
88
  }
89
89
 
90
90
  // Compile with new pipeline
91
- const result = compileZenSource(processedSource, pagePath)
91
+ const result = await compileZenSource(processedSource, pagePath)
92
92
 
93
93
  if (!result.finalized) {
94
94
  throw new Error(`Compilation failed for ${pagePath}: No finalized output`)
@@ -239,7 +239,7 @@ ${page.pageScript}
239
239
  /**
240
240
  * Build all pages using SSG approach
241
241
  */
242
- export function buildSSG(options: SSGBuildOptions): void {
242
+ export async function buildSSG(options: SSGBuildOptions): Promise<void> {
243
243
  const { pagesDir, outDir, baseDir = path.dirname(pagesDir) } = options
244
244
  const contentDir = path.join(baseDir, 'content')
245
245
  const contentData = loadContent(contentDir)
@@ -275,7 +275,7 @@ export function buildSSG(options: SSGBuildOptions): void {
275
275
  console.log(` Compiling: ${relativePath}`)
276
276
 
277
277
  try {
278
- const compiled = compilePage(pageFile, pagesDir, baseDir)
278
+ const compiled = await compilePage(pageFile, pagesDir, baseDir)
279
279
  compiledPages.push(compiled)
280
280
 
281
281
  if (compiled.analysis.needsHydration) {
@@ -356,7 +356,7 @@ export function buildSSG(options: SSGBuildOptions): void {
356
356
  const custom404Path = path.join(pagesDir, candidate)
357
357
  if (fs.existsSync(custom404Path)) {
358
358
  try {
359
- const compiled = compilePage(custom404Path, pagesDir, baseDir)
359
+ const compiled = await compilePage(custom404Path, pagesDir, baseDir)
360
360
  const html = generatePageHTML(compiled, globalStyles, contentData)
361
361
  fs.writeFileSync(path.join(outDir, '404.html'), html)
362
362
  console.log('📦 Generated 404.html (custom)')
@@ -5,19 +5,19 @@
5
5
  * Uses compound component pattern for named slots (Card.Header, Card.Footer).
6
6
  */
7
7
 
8
- import type { TemplateNode, ComponentNode, ElementNode, ZenIR, LoopContext } from '../ir/types'
8
+ import type { TemplateNode, ComponentNode, ElementNode, ZenIR, LoopContext, ComponentScriptIR } from '../ir/types'
9
9
  import type { ComponentMetadata } from '../discovery/componentDiscovery'
10
10
  import { extractSlotsFromChildren, resolveSlots } from './slotResolver'
11
11
  import { throwOrphanCompoundError, throwUnresolvedComponentError } from '../validate/invariants'
12
12
 
13
- // Track which components have been used (for style collection)
13
+ // Track which components have been used (for style and script collection)
14
14
  const usedComponents = new Set<string>()
15
15
 
16
16
  /**
17
17
  * Resolve all component nodes in a template IR
18
18
  *
19
19
  * Recursively replaces ComponentNode instances with their resolved templates
20
- * Also collects styles from used components and adds them to the IR
20
+ * Also collects styles AND scripts from used components and adds them to the IR
21
21
  */
22
22
  export function resolveComponentsInIR(
23
23
  ir: ZenIR,
@@ -35,6 +35,17 @@ export function resolveComponentsInIR(
35
35
  .filter((meta): meta is ComponentMetadata => meta !== undefined && meta.styles.length > 0)
36
36
  .flatMap(meta => meta.styles.map(raw => ({ raw })))
37
37
 
38
+ // Collect scripts from all used components (for bundling)
39
+ const componentScripts: ComponentScriptIR[] = Array.from(usedComponents)
40
+ .map(name => components.get(name))
41
+ .filter((meta): meta is ComponentMetadata => meta !== undefined && meta.script !== null)
42
+ .map(meta => ({
43
+ name: meta.name,
44
+ script: meta.script!,
45
+ props: meta.props,
46
+ scriptAttributes: meta.scriptAttributes || {}
47
+ }))
48
+
38
49
  return {
39
50
  ...ir,
40
51
  template: {
@@ -42,7 +53,9 @@ export function resolveComponentsInIR(
42
53
  nodes: resolvedNodes
43
54
  },
44
55
  // Merge component styles with existing page styles
45
- styles: [...ir.styles, ...componentStyles]
56
+ styles: [...ir.styles, ...componentStyles],
57
+ // Add component scripts for bundling
58
+ componentScripts: [...(ir.componentScripts || []), ...componentScripts]
46
59
  }
47
60
  }
48
61
 
@@ -192,11 +205,12 @@ function resolveComponent(
192
205
  const resolvedTemplate = resolveSlots(templateNodes, resolvedSlots)
193
206
 
194
207
  // Forward attributes from component usage to the root element
195
- // This ensures onclick, class, and other attributes are preserved
208
+ // Also adds data-zen-component marker for hydration-driven instantiation
196
209
  const forwardedTemplate = forwardAttributesToRoot(
197
210
  resolvedTemplate,
198
211
  componentNode.attributes,
199
- componentNode.loopContext
212
+ componentNode.loopContext,
213
+ componentMeta.hasScript ? componentName : undefined // Only mark if component has script
200
214
  )
201
215
 
202
216
  // Recursively resolve any nested components in the resolved template
@@ -210,16 +224,16 @@ function resolveComponent(
210
224
  *
211
225
  * When using <Button onclick="increment">Text</Button>,
212
226
  * the onclick should be applied to the <button> element in Button.zen template.
227
+ *
228
+ * Also adds data-zen-component marker if componentName is provided,
229
+ * enabling hydration-driven instantiation.
213
230
  */
214
231
  function forwardAttributesToRoot(
215
232
  nodes: TemplateNode[],
216
233
  attributes: ComponentNode['attributes'],
217
- loopContext?: LoopContext
234
+ loopContext?: LoopContext,
235
+ componentName?: string // If provided, adds hydration marker
218
236
  ): TemplateNode[] {
219
- if (attributes.length === 0) {
220
- return nodes
221
- }
222
-
223
237
  // Find the first non-text element (the root element)
224
238
  const rootIndex = nodes.findIndex(n => n.type === 'element')
225
239
  if (rootIndex === -1) {
@@ -228,10 +242,19 @@ function forwardAttributesToRoot(
228
242
 
229
243
  const root = nodes[rootIndex] as ElementNode
230
244
 
231
- // Merge attributes: component usage attributes override template defaults
232
- // Also preserve the parent's loopContext on forwarded attributes
245
+ // Start with existing attributes
233
246
  const mergedAttributes = [...root.attributes]
234
247
 
248
+ // Add component hydration marker if this component has a script
249
+ if (componentName) {
250
+ mergedAttributes.push({
251
+ name: 'data-zen-component',
252
+ value: componentName,
253
+ location: { line: 0, column: 0 }
254
+ })
255
+ }
256
+
257
+ // Forward attributes from component usage
235
258
  for (const attr of attributes) {
236
259
  const existingIndex = mergedAttributes.findIndex(a => a.name === attr.name)
237
260