@zenithbuild/core 0.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.
Files changed (101) hide show
  1. package/.eslintignore +15 -0
  2. package/.gitattributes +2 -0
  3. package/.github/ISSUE_TEMPLATE/compiler-errors-for-invalid-state-declarations.md +25 -0
  4. package/.github/ISSUE_TEMPLATE/new_ticket.yaml +34 -0
  5. package/.github/pull_request_template.md +15 -0
  6. package/.github/workflows/discord-changelog.yml +141 -0
  7. package/.github/workflows/discord-notify.yml +242 -0
  8. package/.github/workflows/discord-version.yml +195 -0
  9. package/.prettierignore +13 -0
  10. package/.prettierrc +21 -0
  11. package/.zen.d.ts +15 -0
  12. package/LICENSE +21 -0
  13. package/README.md +55 -0
  14. package/app/components/Button.zen +46 -0
  15. package/app/components/Link.zen +11 -0
  16. package/app/favicon.ico +0 -0
  17. package/app/layouts/Main.zen +59 -0
  18. package/app/pages/about.zen +23 -0
  19. package/app/pages/blog/[id].zen +53 -0
  20. package/app/pages/blog/index.zen +32 -0
  21. package/app/pages/dynamic-dx.zen +712 -0
  22. package/app/pages/dynamic-primitives.zen +453 -0
  23. package/app/pages/index.zen +154 -0
  24. package/app/pages/navigation-demo.zen +229 -0
  25. package/app/pages/posts/[...slug].zen +61 -0
  26. package/app/pages/primitives-demo.zen +273 -0
  27. package/assets/logos/0E3B5DDD-605C-4839-BB2E-DFCA8ADC9604.PNG +0 -0
  28. package/assets/logos/760971E5-79A1-44F9-90B9-925DF30F4278.PNG +0 -0
  29. package/assets/logos/8A06ED80-9ED2-4689-BCBD-13B2E95EE8E4.JPG +0 -0
  30. package/assets/logos/C691FF58-ED13-4E8D-B6A3-02E835849340.PNG +0 -0
  31. package/assets/logos/C691FF58-ED13-4E8D-B6A3-02E835849340.svg +601 -0
  32. package/assets/logos/README.md +54 -0
  33. package/assets/logos/zen.icns +0 -0
  34. package/bun.lock +39 -0
  35. package/compiler/README.md +380 -0
  36. package/compiler/errors/compilerError.ts +24 -0
  37. package/compiler/finalize/finalizeOutput.ts +163 -0
  38. package/compiler/finalize/generateFinalBundle.ts +82 -0
  39. package/compiler/index.ts +44 -0
  40. package/compiler/ir/types.ts +83 -0
  41. package/compiler/legacy/binding.ts +254 -0
  42. package/compiler/legacy/bindings.ts +338 -0
  43. package/compiler/legacy/component-process.ts +1208 -0
  44. package/compiler/legacy/component.ts +301 -0
  45. package/compiler/legacy/event.ts +50 -0
  46. package/compiler/legacy/expression.ts +1149 -0
  47. package/compiler/legacy/mutation.ts +280 -0
  48. package/compiler/legacy/parse.ts +299 -0
  49. package/compiler/legacy/split.ts +608 -0
  50. package/compiler/legacy/types.ts +32 -0
  51. package/compiler/output/types.ts +34 -0
  52. package/compiler/parse/detectMapExpressions.ts +102 -0
  53. package/compiler/parse/parseScript.ts +22 -0
  54. package/compiler/parse/parseTemplate.ts +425 -0
  55. package/compiler/parse/parseZenFile.ts +66 -0
  56. package/compiler/parse/trackLoopContext.ts +82 -0
  57. package/compiler/runtime/dataExposure.ts +291 -0
  58. package/compiler/runtime/generateDOM.ts +144 -0
  59. package/compiler/runtime/generateHydrationBundle.ts +383 -0
  60. package/compiler/runtime/hydration.ts +309 -0
  61. package/compiler/runtime/navigation.ts +432 -0
  62. package/compiler/runtime/thinRuntime.ts +160 -0
  63. package/compiler/runtime/transformIR.ts +256 -0
  64. package/compiler/runtime/wrapExpression.ts +84 -0
  65. package/compiler/runtime/wrapExpressionWithLoop.ts +77 -0
  66. package/compiler/spa-build.ts +1000 -0
  67. package/compiler/test/validate-test.ts +104 -0
  68. package/compiler/transform/generateBindings.ts +47 -0
  69. package/compiler/transform/generateHTML.ts +28 -0
  70. package/compiler/transform/transformNode.ts +126 -0
  71. package/compiler/transform/transformTemplate.ts +38 -0
  72. package/compiler/validate/validateExpressions.ts +168 -0
  73. package/core/index.ts +135 -0
  74. package/core/lifecycle/index.ts +49 -0
  75. package/core/lifecycle/zen-mount.ts +182 -0
  76. package/core/lifecycle/zen-unmount.ts +88 -0
  77. package/core/reactivity/index.ts +54 -0
  78. package/core/reactivity/tracking.ts +167 -0
  79. package/core/reactivity/zen-batch.ts +57 -0
  80. package/core/reactivity/zen-effect.ts +139 -0
  81. package/core/reactivity/zen-memo.ts +146 -0
  82. package/core/reactivity/zen-ref.ts +52 -0
  83. package/core/reactivity/zen-signal.ts +121 -0
  84. package/core/reactivity/zen-state.ts +180 -0
  85. package/core/reactivity/zen-untrack.ts +44 -0
  86. package/docs/COMMENTS.md +111 -0
  87. package/docs/COMMITS.md +36 -0
  88. package/docs/CONTRIBUTING.md +116 -0
  89. package/docs/STYLEGUIDE.md +62 -0
  90. package/package.json +44 -0
  91. package/router/index.ts +76 -0
  92. package/router/manifest.ts +314 -0
  93. package/router/navigation/ZenLink.zen +231 -0
  94. package/router/navigation/index.ts +78 -0
  95. package/router/navigation/zen-link.ts +584 -0
  96. package/router/runtime.ts +458 -0
  97. package/router/types.ts +168 -0
  98. package/runtime/build.ts +17 -0
  99. package/runtime/serve.ts +93 -0
  100. package/scripts/webhook-proxy.ts +213 -0
  101. package/tsconfig.json +28 -0
@@ -0,0 +1,1208 @@
1
+ // compiler/component-process.ts
2
+ // Phase 3: Main component processing pipeline
3
+
4
+ import path from "path";
5
+ import fs from "fs";
6
+ import * as parse5 from "parse5";
7
+ import type { ZenFile, ScriptBlock, StyleBlock } from "./types";
8
+ import { parseZen } from "./parse";
9
+ import { discoverComponents, discoverLayouts, type ComponentMetadata } from "./component";
10
+ import { extractStateDeclarations } from "./parse";
11
+
12
+ // Native HTML elements that should never be matched as components
13
+ // This prevents <button> from matching Button.zen, etc.
14
+ const NATIVE_HTML_ELEMENTS = new Set([
15
+ 'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
16
+ 'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
17
+ 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
18
+ 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
19
+ 'em', 'embed',
20
+ 'fieldset', 'figcaption', 'figure', 'footer', 'form',
21
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
22
+ 'i', 'iframe', 'img', 'input', 'ins',
23
+ 'kbd',
24
+ 'label', 'legend', 'li', 'link',
25
+ 'main', 'map', 'mark', 'menu', 'meta', 'meter',
26
+ 'nav', 'noscript',
27
+ 'object', 'ol', 'optgroup', 'option', 'output',
28
+ 'p', 'param', 'picture', 'pre', 'progress',
29
+ 'q',
30
+ 'rp', 'rt', 'ruby',
31
+ 's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
32
+ 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
33
+ 'u', 'ul',
34
+ 'var', 'video',
35
+ 'wbr'
36
+ ]);
37
+
38
+ interface ProcessedComponent {
39
+ metadata: ComponentMetadata;
40
+ instanceId: string;
41
+ props: Map<string, string>; // prop name -> value from parent
42
+ }
43
+
44
+ /**
45
+ * Process a ZenFile to inline all component usages and handle layouts
46
+ * Returns a new ZenFile with components inlined
47
+ */
48
+ export function processComponents(entryFile: ZenFile, entryPath: string): ZenFile {
49
+ const entryDir = path.dirname(entryPath);
50
+
51
+ // For file-based routing: if entry is in pages/, look for components/layouts in parent directory
52
+ // Otherwise, look in the same directory as the entry file
53
+ let baseDir = entryDir;
54
+ if (entryDir.endsWith("pages") || entryDir.endsWith(path.join("app", "pages"))) {
55
+ baseDir = path.dirname(entryDir);
56
+ }
57
+
58
+ const componentsDir = path.join(baseDir, "components");
59
+ const layoutsDir = path.join(baseDir, "layouts");
60
+
61
+ // Also look for components in router/navigation (for ZenLink)
62
+ // Use routerNavigationDir as the base so component names are derived correctly
63
+ // (e.g., ZenLink.zen -> ZenLink, not router/navigation/ZenLink -> RouterNavigationZenlink)
64
+ const routerNavigationDir = path.join(baseDir, "..", "router", "navigation");
65
+
66
+ // Discover components from both app/components and router/navigation
67
+ const appComponents = discoverComponents(componentsDir, baseDir);
68
+ const routerComponents = discoverComponents(routerNavigationDir, routerNavigationDir);
69
+
70
+ // Merge components (router components take precedence if name conflicts)
71
+ const components = new Map(appComponents);
72
+ for (const [name, metadata] of routerComponents.entries()) {
73
+ components.set(name, metadata);
74
+ }
75
+
76
+ // Discover layouts
77
+ const layouts = discoverLayouts(layoutsDir, baseDir);
78
+
79
+ // Keep components and layouts separate
80
+ // Components will be processed in the component loop
81
+ // Layouts are processed separately first
82
+ const allComponents = new Map<string, ComponentMetadata>();
83
+ const componentNameMap = new Map<string, string>(); // lowercase -> original case
84
+
85
+ // Only add actual components (not layouts) to the component processing map
86
+ for (const [name, metadata] of components.entries()) {
87
+ allComponents.set(name, metadata);
88
+ componentNameMap.set(name.toLowerCase(), name);
89
+ }
90
+
91
+ // Layouts are stored separately and processed before components
92
+ const layoutNames = new Set(layouts.keys());
93
+ const layoutNamesLowercase = new Set(Array.from(layoutNames).map(n => n.toLowerCase()));
94
+
95
+ if (allComponents.size === 0) {
96
+ // No components, return original file
97
+ return entryFile;
98
+ }
99
+
100
+ // Check if entry file uses a layout
101
+ const layoutUsage = findLayoutUsage(entryFile.html, layouts);
102
+
103
+ // Process layout first (if used)
104
+ let processedHtml = entryFile.html;
105
+ let processedScripts = [...entryFile.scripts];
106
+ let processedStyles = [...entryFile.styles];
107
+
108
+ if (layoutUsage) {
109
+ const layout = layouts.get(layoutUsage.layoutName);
110
+ if (layout) {
111
+ // Extract page content (everything except html/head/body tags)
112
+ const pageContent = extractPageContent(entryFile.html);
113
+
114
+ // For layouts, we need special handling - layouts define the HTML structure
115
+ const layoutResult = inlineLayout(layout, pageContent, `layout-${layoutUsage.layoutName}`, layoutUsage.props);
116
+
117
+ processedHtml = layoutResult.html;
118
+ // Layout scripts must come BEFORE page scripts so layout state is declared first
119
+ processedScripts = [...layoutResult.scripts, ...processedScripts];
120
+ processedStyles = [...layoutResult.styles, ...processedStyles];
121
+ }
122
+ }
123
+
124
+ // Process all component usages (process from innermost to outermost)
125
+ // Exclude layouts from component processing (they're already processed)
126
+ let instanceCounter = 0;
127
+ const componentNamesLowercase = new Set(Array.from(allComponents.keys()).map(n => n.toLowerCase()));
128
+ const functionProps = new Set<string>(); // Track function names passed as props
129
+ const maxIterations = 100; // Prevent infinite loops
130
+ let iterations = 0;
131
+
132
+ // Filter out layouts from component usage check
133
+ // Also skip elements that have data-zen-component attribute (already processed)
134
+ function hasComponentUsageFiltered(html: string, componentNamesLowercase: Set<string>, componentNameMap: Map<string, string>, excludeLayouts: Set<string>): boolean {
135
+ const document = parse5.parse(html);
136
+ let found = false;
137
+
138
+ function walk(node: any): void {
139
+ if (found) return; // Early exit if already found
140
+
141
+ if (node.tagName) {
142
+ const tagLower = node.tagName.toLowerCase();
143
+ // Skip layouts and already-replaced nodes (have data-zen-replaced)
144
+ const hasReplaced = node.attrs && node.attrs.some((attr: any) => attr.name === 'data-zen-replaced' && attr.value === 'true');
145
+ const hasZenComponent = node.attrs && node.attrs.some((attr: any) => attr.name === 'data-zen-component');
146
+
147
+ // Only count as found if: not a layout, not already replaced, not a native HTML element, and matches a component name
148
+ if (!hasReplaced && !hasZenComponent && !excludeLayouts.has(tagLower) && !NATIVE_HTML_ELEMENTS.has(tagLower) && componentNamesLowercase.has(tagLower)) {
149
+ found = true;
150
+ return;
151
+ }
152
+ }
153
+ if (node.childNodes && !found) {
154
+ node.childNodes.forEach(walk);
155
+ }
156
+ }
157
+
158
+ walk(document);
159
+ return found;
160
+ }
161
+
162
+ // Helper function to find first component usage (excluding layouts)
163
+ function findFirstComponentUsageFiltered(html: string, componentNamesLowercase: Set<string>, componentNameMap: Map<string, string>, excludeLayouts: Set<string>): { tagName: string; node: any; children: any[]; props: Map<string, string> } | null {
164
+ const document = parse5.parse(html);
165
+ let found: { tagName: string; node: any; children: any[]; props: Map<string, string> } | null = null;
166
+
167
+ function walk(node: any): void {
168
+ if (found) return; // Early exit if already found
169
+
170
+ if (node.tagName) {
171
+ const tagLower = node.tagName.toLowerCase();
172
+ // Skip layouts and already-replaced nodes (have data-zen-replaced)
173
+ const hasReplaced = node.attrs && node.attrs.some((attr: any) => attr.name === 'data-zen-replaced' && attr.value === 'true');
174
+ const hasZenComponent = node.attrs && node.attrs.some((attr: any) => attr.name === 'data-zen-component');
175
+
176
+ // Only process if: not a layout, not already replaced, not a native HTML element, and matches a component name
177
+ if (!hasReplaced && !hasZenComponent && !excludeLayouts.has(tagLower) && !NATIVE_HTML_ELEMENTS.has(tagLower)) {
178
+ const originalName = componentNameMap.get(tagLower);
179
+ if (originalName) {
180
+ // Extract props from attributes
181
+ const props = new Map<string, string>();
182
+ if (node.attrs) {
183
+ for (const attr of node.attrs) {
184
+ if (attr.name !== "slot") {
185
+ props.set(attr.name, attr.value || "");
186
+ }
187
+ }
188
+ }
189
+
190
+ // Get children (excluding slot attribute nodes)
191
+ const children = (node.childNodes || []).filter((child: any) => {
192
+ // Keep element nodes and text nodes, but filter out slot attributes
193
+ return child.nodeName !== "#comment";
194
+ });
195
+
196
+ found = { tagName: originalName, node, children, props };
197
+ return;
198
+ }
199
+ }
200
+ }
201
+ if (node.childNodes && !found) {
202
+ node.childNodes.forEach(walk);
203
+ }
204
+ }
205
+
206
+ walk(document);
207
+ return found;
208
+ }
209
+
210
+ while (iterations < maxIterations) {
211
+ // Re-check for components at the start of each iteration
212
+ const hasComponents = hasComponentUsageFiltered(processedHtml, componentNamesLowercase, componentNameMap, layoutNamesLowercase);
213
+ if (!hasComponents) {
214
+ break;
215
+ }
216
+
217
+ iterations++;
218
+ const usage = findFirstComponentUsageFiltered(processedHtml, componentNamesLowercase, componentNameMap, layoutNamesLowercase);
219
+ if (!usage) {
220
+ break;
221
+ }
222
+
223
+ const component = allComponents.get(usage.tagName);
224
+ if (!component) {
225
+ break;
226
+ }
227
+
228
+ // Extract props from parent script context if available
229
+ // Look for variables/functions in parent scripts that match prop names
230
+ const enhancedProps = new Map(usage.props);
231
+ // Create case-insensitive lookup for enhancedProps (parse5 lowercases attribute names)
232
+ const enhancedPropsLowercase = new Map<string, string>();
233
+ for (const [key, value] of enhancedProps.entries()) {
234
+ enhancedPropsLowercase.set(key.toLowerCase(), value);
235
+ }
236
+ // Track function props from usage.props
237
+ for (const [propName, propValue] of usage.props.entries()) {
238
+ // Check if propValue is a function name (not quoted, valid identifier)
239
+ // Handle both {variable} and variable syntax
240
+ let trimmedValue = propValue.trim();
241
+ // Strip braces if present: {increment} -> increment
242
+ if (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) {
243
+ trimmedValue = trimmedValue.slice(1, -1).trim();
244
+ }
245
+ const isFunctionOrVar = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(trimmedValue) &&
246
+ !trimmedValue.startsWith('"') &&
247
+ !trimmedValue.startsWith("'");
248
+ if (isFunctionOrVar) {
249
+ // This is a function prop - track it for mutation validator
250
+ functionProps.add(trimmedValue);
251
+ }
252
+ }
253
+ for (const [propName] of component.props.entries()) {
254
+ // Check case-insensitively (parse5 lowercases attribute names)
255
+ const hasProp = enhancedProps.has(propName) || enhancedPropsLowercase.has(propName.toLowerCase());
256
+ if (!hasProp) {
257
+ // Check if this prop name exists as a variable/function in parent scripts
258
+ // This allows passing functions and variables from parent script context
259
+ for (const script of processedScripts) {
260
+ const scriptContent = script.content;
261
+ // Check if propName is a function or variable in the script
262
+ // Simple heuristic: look for "function propName" or "const propName" or "let propName" or "var propName"
263
+ const functionMatch = new RegExp(`function\\s+${propName}\\s*\\(`).test(scriptContent);
264
+ const varMatch = new RegExp(`(?:const|let|var)\\s+${propName}\\s*[=;]`).test(scriptContent);
265
+ if (functionMatch || varMatch) {
266
+ // Found in parent script - use it as-is (don't quote it)
267
+ enhancedProps.set(propName, propName);
268
+ functionProps.add(propName); // Track as function prop
269
+ break;
270
+ }
271
+ }
272
+ }
273
+ }
274
+
275
+ const instanceId = `comp-${instanceCounter++}`;
276
+ const result = inlineComponentIntoHtml(
277
+ processedHtml,
278
+ component,
279
+ usage.children,
280
+ instanceId,
281
+ enhancedProps
282
+ );
283
+
284
+ // Verify the replacement actually happened
285
+ if (!result || result.html === processedHtml) {
286
+ break;
287
+ }
288
+
289
+ processedHtml = result.html;
290
+ processedScripts = [...processedScripts, ...result.scripts];
291
+ processedStyles = [...processedStyles, ...result.styles];
292
+
293
+ // Safety check: if we've processed more than expected, something is wrong
294
+ if (instanceCounter > 100) {
295
+ break;
296
+ }
297
+ }
298
+
299
+ return {
300
+ html: processedHtml,
301
+ scripts: processedScripts,
302
+ styles: processedStyles,
303
+ functionProps: functionProps
304
+ };
305
+ }
306
+
307
+ /**
308
+ * Inline a layout - layouts define the HTML structure (html/head/body)
309
+ * Replaces <Slot /> with page content
310
+ */
311
+ function inlineLayout(
312
+ layout: ComponentMetadata,
313
+ pageContent: any[],
314
+ instanceId: string,
315
+ layoutUsageProps: Map<string, string> = new Map()
316
+ ): { html: string; scripts: ScriptBlock[]; styles: StyleBlock[] } {
317
+ // Layout HTML already has html/head/body structure
318
+ const layoutDoc = parse5.parse(layout.html);
319
+
320
+ // Find the body element to replace slots
321
+ let bodyElement: any = null;
322
+ function findBody(node: any): void {
323
+ if (node.tagName === "body") {
324
+ bodyElement = node;
325
+ return;
326
+ }
327
+ if (node.childNodes) {
328
+ for (const child of node.childNodes) {
329
+ findBody(child);
330
+ if (bodyElement) return;
331
+ }
332
+ }
333
+ }
334
+ findBody(layoutDoc);
335
+
336
+ if (!bodyElement) {
337
+ // No body found, return layout HTML as-is
338
+ return { html: layout.html, scripts: [], styles: [] };
339
+ }
340
+
341
+ // Replace slots in body with page content
342
+ function replaceSlots(node: any): void {
343
+ if (!node.childNodes) return;
344
+
345
+ const newChildren: any[] = [];
346
+
347
+ for (const child of node.childNodes) {
348
+ if (child.tagName === "Slot" || child.tagName === "slot") {
349
+ const slotNameAttr = child.attrs?.find((a: any) => a.name === "name");
350
+ const slotName = slotNameAttr?.value || "default";
351
+
352
+ // Find content for this slot
353
+ const content = pageContent.filter(node => {
354
+ const slotAttr = node.attrs?.find((a: any) => a.name === "slot");
355
+ const nodeSlotName = slotAttr?.value || "default";
356
+ return nodeSlotName === slotName;
357
+ });
358
+
359
+ // Add slot content
360
+ for (const slotNode of content) {
361
+ newChildren.push(cloneNode(slotNode));
362
+ }
363
+ } else {
364
+ replaceSlots(child);
365
+ newChildren.push(child);
366
+ }
367
+ }
368
+
369
+ node.childNodes = newChildren;
370
+ }
371
+
372
+ replaceSlots(bodyElement);
373
+
374
+ // Serialize the layout document back to HTML
375
+ const html = parse5.serialize(layoutDoc);
376
+
377
+ // Process layout scripts with props
378
+ // NOTE: Layouts are document-level, so we DON'T transform state names (keep them as-is)
379
+ // This allows { title } in HTML to match state title in scripts
380
+ const processedScripts: ScriptBlock[] = [];
381
+ let scriptIndex = 0;
382
+
383
+ // Create props object for layout
384
+ const propsObjEntries: string[] = [];
385
+ // Extract props from layout usage
386
+ for (const [propName, defaultValue] of layout.props.entries()) {
387
+ const propValue = layoutUsageProps.get(propName);
388
+ if (propValue !== undefined) {
389
+ // Use provided value - quote if it's a string
390
+ propsObjEntries.push(` ${propName}: "${propValue}"`);
391
+ } else if (defaultValue === "?") {
392
+ // Optional prop from type Props - use undefined
393
+ propsObjEntries.push(` ${propName}: undefined`);
394
+ } else if (defaultValue) {
395
+ // Use default value (already an expression)
396
+ propsObjEntries.push(` ${propName}: ${defaultValue}`);
397
+ } else {
398
+ // No default, use undefined
399
+ propsObjEntries.push(` ${propName}: undefined`);
400
+ }
401
+ }
402
+ const propsObjCode = propsObjEntries.length > 0
403
+ ? `const props = {\n${propsObjEntries.join(",\n")}\n};`
404
+ : `const props = {};`;
405
+
406
+ for (const script of layout.scripts) {
407
+ let processedScript = script;
408
+
409
+ // Remove type Props definitions (TypeScript types are compile-time only, not needed in JavaScript)
410
+ processedScript = processedScript.replace(/type\s+Props\s*=\s*\{[^}]*(?:\{[^}]*\}[^}]*)*\}\s*/gs, '');
411
+
412
+ // Replace props references in state declarations with actual prop values
413
+ // e.g., "state title = props.title || 'My App'" -> "state title = 'Home Page' || 'My App'"
414
+ for (const [propName, propValue] of layoutUsageProps.entries()) {
415
+ // Replace props.propName with the actual value (quote if it's a string)
416
+ const quotedValue = propValue ? `"${propValue}"` : 'undefined';
417
+ const propsRefRegex = new RegExp(`props\\.${propName}\\b`, 'g');
418
+ processedScript = processedScript.replace(propsRefRegex, quotedValue);
419
+ }
420
+
421
+ // Layouts use state names as-is (no instance scoping)
422
+ // Wrap in IIFE to scope props (even though we've replaced props references above)
423
+ const wrappedScript = `(function() {\n // Props for layout\n ${propsObjCode}\n\n${processedScript}\n})();`;
424
+
425
+ processedScripts.push({ content: wrappedScript, index: scriptIndex++ });
426
+ }
427
+
428
+ // Process layout styles with scoping
429
+ const processedStyles: StyleBlock[] = [];
430
+ let styleIndex = 0;
431
+ for (const style of layout.styles) {
432
+ // Layout styles don't need scoping - they're at the document level
433
+ processedStyles.push({ content: style, index: styleIndex++ });
434
+ }
435
+
436
+ return {
437
+ html,
438
+ scripts: processedScripts,
439
+ styles: processedStyles
440
+ };
441
+ }
442
+
443
+ /**
444
+ * Check if a layout is used in HTML (looks for layout component as root element)
445
+ */
446
+ function findLayoutUsage(html: string, layouts: Map<string, ComponentMetadata>): { layoutName: string; node: any; props: Map<string, string> } | null {
447
+ const document = parse5.parse(html);
448
+
449
+ // Create lowercase map for case-insensitive matching
450
+ const layoutLowercaseMap = new Map<string, string>();
451
+ for (const name of layouts.keys()) {
452
+ layoutLowercaseMap.set(name.toLowerCase(), name);
453
+ }
454
+
455
+ function findLayout(node: any): { layoutName: string; node: any; props: Map<string, string> } | null {
456
+ if (node.tagName) {
457
+ const originalName = layoutLowercaseMap.get(node.tagName.toLowerCase());
458
+ if (originalName) {
459
+ // Extract props from attributes
460
+ const props = new Map<string, string>();
461
+ if (node.attrs) {
462
+ for (const attr of node.attrs) {
463
+ props.set(attr.name, attr.value || "");
464
+ }
465
+ }
466
+ return { layoutName: originalName, node, props };
467
+ }
468
+ }
469
+ if (node.childNodes) {
470
+ for (const child of node.childNodes) {
471
+ const result = findLayout(child);
472
+ if (result) return result;
473
+ }
474
+ }
475
+ return null;
476
+ }
477
+
478
+ return findLayout(document);
479
+ }
480
+
481
+ /**
482
+ * Extract page content from HTML (everything inside body, or root content if no body/html)
483
+ */
484
+ function extractPageContent(html: string): any[] {
485
+ const document = parse5.parse(html);
486
+
487
+ function findContent(node: any): any[] {
488
+ // If we find body, return its children
489
+ if (node.tagName === "body") {
490
+ return node.childNodes || [];
491
+ }
492
+ // If we find html, look for body inside it
493
+ if (node.tagName === "html") {
494
+ if (node.childNodes) {
495
+ for (const child of node.childNodes) {
496
+ if (child.tagName === "body") {
497
+ return child.childNodes || [];
498
+ }
499
+ }
500
+ }
501
+ // No body tag, return html's children (skip head)
502
+ return (node.childNodes || []).filter((n: any) => n.tagName !== "head");
503
+ }
504
+ // If this is the document root, look for the first element (skip #documentType, etc)
505
+ if (node.nodeName === "#document") {
506
+ if (node.childNodes) {
507
+ for (const child of node.childNodes) {
508
+ if (child.tagName === "html") {
509
+ return findContent(child);
510
+ }
511
+ // No html tag - page content is directly at root level
512
+ if (child.tagName && child.tagName !== "#documentType") {
513
+ return [child];
514
+ }
515
+ }
516
+ // No html tag found - return all non-document-type nodes
517
+ return (node.childNodes || []).filter((n: any) =>
518
+ n.tagName && n.tagName !== "#documentType"
519
+ );
520
+ }
521
+ }
522
+ return [];
523
+ }
524
+
525
+ return findContent(document);
526
+ }
527
+
528
+ /**
529
+ * Check if HTML contains any component usages
530
+ */
531
+ function hasComponentUsage(
532
+ html: string,
533
+ componentNamesLowercase: Set<string>,
534
+ componentNameMap: Map<string, string>
535
+ ): boolean {
536
+ const document = parse5.parse(html);
537
+ let found = false;
538
+
539
+ function walk(node: any): void {
540
+ const tagLower = node.tagName ? node.tagName.toLowerCase() : '';
541
+ if (node.tagName && !NATIVE_HTML_ELEMENTS.has(tagLower) && componentNamesLowercase.has(tagLower)) {
542
+ found = true;
543
+ return;
544
+ }
545
+ if (node.childNodes && !found) {
546
+ node.childNodes.forEach(walk);
547
+ }
548
+ }
549
+
550
+ walk(document);
551
+ return found;
552
+ }
553
+
554
+ /**
555
+ * Find the first component usage in HTML
556
+ */
557
+ function findFirstComponentUsage(
558
+ html: string,
559
+ componentNamesLowercase: Set<string>,
560
+ componentNameMap: Map<string, string>
561
+ ): { tagName: string; node: any; children: any[]; props: Map<string, string> } | null {
562
+ const document = parse5.parse(html);
563
+ let found: { tagName: string; node: any; children: any[]; props: Map<string, string> } | null = null;
564
+
565
+ function walk(node: any): void {
566
+ if (found) return;
567
+
568
+ const tagLower = node.tagName ? node.tagName.toLowerCase() : '';
569
+ if (node.tagName && !NATIVE_HTML_ELEMENTS.has(tagLower) && componentNamesLowercase.has(tagLower)) {
570
+ // Get the original component name (with correct case)
571
+ const originalName = componentNameMap.get(tagLower) || node.tagName;
572
+
573
+ const props = new Map<string, string>();
574
+ if (node.attrs) {
575
+ for (const attr of node.attrs) {
576
+ props.set(attr.name, attr.value || "");
577
+ }
578
+ }
579
+
580
+ found = {
581
+ tagName: originalName, // Use original case name
582
+ node,
583
+ children: node.childNodes || [],
584
+ props
585
+ };
586
+ return;
587
+ }
588
+
589
+ if (node.childNodes) {
590
+ node.childNodes.forEach(walk);
591
+ }
592
+ }
593
+
594
+ walk(document);
595
+ return found;
596
+ }
597
+
598
+ /**
599
+ * Inline a component into HTML, replacing the component tag with component HTML
600
+ * and handling slots, props, styles, and scripts
601
+ */
602
+ function inlineComponentIntoHtml(
603
+ html: string,
604
+ component: ComponentMetadata,
605
+ slotContent: any[],
606
+ instanceId: string,
607
+ props: Map<string, string> = new Map()
608
+ ): { html: string; scripts: ScriptBlock[]; styles: StyleBlock[] } {
609
+ const document = parse5.parse(html);
610
+
611
+ // Strip script and style tags from component HTML before parsing (they're handled separately)
612
+ let componentHtml = component.html;
613
+ componentHtml = componentHtml.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
614
+ componentHtml = componentHtml.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
615
+ componentHtml = componentHtml.trim();
616
+
617
+
618
+ // Use parseFragment instead of parse to avoid html/head/body wrapper
619
+ const componentFragment = parse5.parseFragment(componentHtml);
620
+
621
+ // Find component root - should be the first element in the fragment
622
+ let componentRoot: any = null;
623
+
624
+ function findFirstElement(node: any): void {
625
+ if (!node) return;
626
+
627
+ // Skip script/style/document nodes
628
+ if (node.tagName) {
629
+ const tagLower = node.tagName.toLowerCase();
630
+ if (tagLower === "script" || tagLower === "style") {
631
+ return;
632
+ }
633
+ // Found a valid component root element
634
+ componentRoot = node;
635
+ return;
636
+ }
637
+
638
+ // For non-element nodes, recurse into children
639
+ if (node.childNodes && Array.isArray(node.childNodes)) {
640
+ for (const child of node.childNodes) {
641
+ findFirstElement(child);
642
+ if (componentRoot) return;
643
+ }
644
+ }
645
+ }
646
+
647
+ findFirstElement(componentFragment);
648
+
649
+ if (!componentRoot) {
650
+ return { html, scripts: [], styles: [] };
651
+ }
652
+
653
+ // Transform state references in component HTML before cloning
654
+ const stateDecls = new Set<string>();
655
+ for (const script of component.scripts) {
656
+ const decls = extractStateDeclarations(script);
657
+ for (const [stateName] of decls.entries()) {
658
+ stateDecls.add(stateName);
659
+ }
660
+ }
661
+
662
+ // Note: We don't create clonedRoot here anymore - we create a fresh clone
663
+ // for each replacement in replaceComponentTag to avoid reusing nodes with parentNode set
664
+
665
+ // Track prop-based handlers that need wrapper functions (propName -> eventType)
666
+ // This will be populated during component inlining and used to generate wrappers
667
+ const propHandlers = new Map<string, string>(); // propName -> eventType (e.g., "onClick" -> "click")
668
+
669
+ // Replace slots with slot content (this function will be used in replaceComponentTag)
670
+ function replaceSlots(node: any): void {
671
+ if (!node.childNodes || node.childNodes.length === 0) return;
672
+
673
+ const newChildren: any[] = [];
674
+
675
+ for (const child of node.childNodes) {
676
+ if (child.tagName === "Slot" || child.tagName === "slot") {
677
+ const slotNameAttr = child.attrs?.find((a: any) => a.name === "name");
678
+ const slotName = slotNameAttr?.value || "default";
679
+
680
+ // Find content for this slot
681
+ const content = slotContent.filter(node => {
682
+ const slotAttr = node.attrs?.find((a: any) => a.name === "slot");
683
+ const nodeSlotName = slotAttr?.value || "default";
684
+ return nodeSlotName === slotName;
685
+ });
686
+
687
+ // Add slot content
688
+ for (const slotNode of content) {
689
+ newChildren.push(cloneNode(slotNode));
690
+ }
691
+ } else {
692
+ replaceSlots(child);
693
+ newChildren.push(child);
694
+ }
695
+ }
696
+
697
+ node.childNodes = newChildren;
698
+ }
699
+
700
+ // Replace component tag with cloned root in document
701
+ // Create a fresh clone for EACH replacement to avoid reusing nodes with parentNode set
702
+ // Note: parse5 lowercases tag names, so compare case-insensitively
703
+ let replaced = false;
704
+ function replaceComponentTag(node: any): boolean {
705
+ if (node.childNodes) {
706
+ for (let i = 0; i < node.childNodes.length; i++) {
707
+ const child = node.childNodes[i];
708
+ // Check if this is a component tag that needs replacement
709
+ // Must match component name (case-insensitive) and NOT have data-zen-replaced
710
+ const childTagLower = child.tagName ? child.tagName.toLowerCase() : '';
711
+ const componentNameLower = component.name.toLowerCase();
712
+ const isComponentTag = childTagLower === componentNameLower;
713
+ const alreadyReplaced = child.attrs && child.attrs.some((attr: any) => attr.name === 'data-zen-replaced' && attr.value === 'true');
714
+
715
+ if (isComponentTag && !alreadyReplaced) {
716
+ // Found the component tag - create a FRESH clone for this replacement
717
+ const freshClone = cloneNode(componentRoot);
718
+
719
+ // Replace hyphens in instanceId with underscores for valid JavaScript identifiers
720
+ const safeInstanceId = instanceId.replace(/-/g, '_');
721
+
722
+ // Transform state references in the fresh clone
723
+ function transformStateInHtml(node: any): void {
724
+ // Transform text bindings: { stateName } to { __zen_instanceId_stateName }
725
+ // Also inline prop values: { propName } -> actual prop value
726
+ if (node.nodeName === "#text" && node.value) {
727
+ // First, inline prop values (props are constants, not reactive)
728
+ for (const [propName, propValue] of props.entries()) {
729
+ // Check if propValue is a simple identifier (function/state reference) - don't inline those
730
+ let trimmedValue = propValue.trim();
731
+ if (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) {
732
+ trimmedValue = trimmedValue.slice(1, -1).trim();
733
+ }
734
+ const isFunctionOrVar = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(trimmedValue) &&
735
+ !trimmedValue.startsWith('"') &&
736
+ !trimmedValue.startsWith("'");
737
+
738
+ // Only inline if it's not a function/state reference (i.e., it's a literal value)
739
+ if (!isFunctionOrVar) {
740
+ // Inline the prop value directly
741
+ const regex = new RegExp(`\\{\\s*${propName}\\s*\\}`, 'g');
742
+ // Get the actual prop value - remove quotes for text content
743
+ let inlineValue = propValue.trim();
744
+ if ((inlineValue.startsWith('"') && inlineValue.endsWith('"')) ||
745
+ (inlineValue.startsWith("'") && inlineValue.endsWith("'"))) {
746
+ inlineValue = inlineValue.slice(1, -1); // Remove quotes
747
+ }
748
+ node.value = node.value.replace(regex, inlineValue);
749
+ }
750
+ }
751
+
752
+ // Then transform state references to instance-scoped names
753
+ for (const stateName of stateDecls) {
754
+ const instanceStateName = `__zen_${safeInstanceId}_${stateName}`;
755
+ const regex = new RegExp(`\\{\\s*${stateName}\\s*\\}`, 'g');
756
+ node.value = node.value.replace(regex, `{ ${instanceStateName} }`);
757
+ }
758
+ }
759
+
760
+ // Transform attribute bindings (:class, :value) to use instance-scoped state names
761
+ // Also inline prop values in attribute values and handle :attr bindings
762
+ if (node.attrs && Array.isArray(node.attrs)) {
763
+ for (const attr of node.attrs) {
764
+ let attrValue = attr.value || '';
765
+
766
+ // Handle :attr bindings (like :href, :src) - inline prop values or transform state references
767
+ if (attr.name && attr.name.startsWith(':') && attr.name !== ':class' && attr.name !== ':value') {
768
+ // This is a non-reactive attribute binding (e.g., :href)
769
+ // Inline prop values first
770
+ for (const [propName, propValue] of props.entries()) {
771
+ let trimmedValue = propValue.trim();
772
+ if (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) {
773
+ trimmedValue = trimmedValue.slice(1, -1).trim();
774
+ }
775
+ const isFunctionOrVar = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(trimmedValue) &&
776
+ !trimmedValue.startsWith('"') &&
777
+ !trimmedValue.startsWith("'");
778
+
779
+ if (!isFunctionOrVar) {
780
+ // Inline prop value for attribute
781
+ const propRegex = new RegExp(`\\b${propName}\\b`, 'g');
782
+ let inlineValue = propValue.trim();
783
+ // Remove quotes for attribute values (parse5 will handle quoting)
784
+ if ((inlineValue.startsWith('"') && inlineValue.endsWith('"')) ||
785
+ (inlineValue.startsWith("'") && inlineValue.endsWith("'"))) {
786
+ inlineValue = inlineValue.slice(1, -1); // Remove quotes
787
+ }
788
+ attrValue = attrValue.replace(propRegex, inlineValue);
789
+ }
790
+ }
791
+
792
+ // Transform state references
793
+ for (const stateName of stateDecls) {
794
+ const instanceStateName = `__zen_${safeInstanceId}_${stateName}`;
795
+ const stateRefRegex = new RegExp(`\\b${stateName}\\b`, 'g');
796
+ attrValue = attrValue.replace(stateRefRegex, instanceStateName);
797
+ }
798
+
799
+ // Replace :attr with regular attr (remove colon)
800
+ attr.name = attr.name.slice(1);
801
+ attr.value = attrValue;
802
+ }
803
+
804
+ // Handle reactive bindings (:class, :value)
805
+ const isBindingAttr = attr.name === ':class' || attr.name === ':value' ||
806
+ attr.name === 'data-zen-class' || attr.name === 'data-zen-value';
807
+ if (isBindingAttr) {
808
+ for (const stateName of stateDecls) {
809
+ const instanceStateName = `__zen_${safeInstanceId}_${stateName}`;
810
+ // Replace state references in attribute values using word boundaries
811
+ // This handles expressions like "{ 'btn': true, 'btn-clicked': clicks > 0 }"
812
+ const stateRefRegex = new RegExp(`\\b${stateName}\\b`, 'g');
813
+ attrValue = attrValue.replace(stateRefRegex, instanceStateName);
814
+ }
815
+ attr.value = attrValue;
816
+ }
817
+
818
+ // Handle regular attributes with {propName} syntax
819
+ if (attrValue && attrValue.includes('{')) {
820
+ // Inline prop values in attribute values
821
+ for (const [propName, propValue] of props.entries()) {
822
+ let trimmedValue = propValue.trim();
823
+ if (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) {
824
+ trimmedValue = trimmedValue.slice(1, -1).trim();
825
+ }
826
+ const isFunctionOrVar = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(trimmedValue) &&
827
+ !trimmedValue.startsWith('"') &&
828
+ !trimmedValue.startsWith("'");
829
+
830
+ if (!isFunctionOrVar) {
831
+ // Inline prop value
832
+ const propRegex = new RegExp(`\\{\\s*${propName}\\s*\\}`, 'g');
833
+ let inlineValue = propValue.trim();
834
+ if (inlineValue.startsWith('"') || inlineValue.startsWith("'")) {
835
+ inlineValue = inlineValue.slice(1, -1); // Remove quotes for attribute value
836
+ }
837
+ attrValue = attrValue.replace(propRegex, inlineValue);
838
+ }
839
+ }
840
+ attr.value = attrValue;
841
+ }
842
+ }
843
+ }
844
+
845
+ if (node.childNodes) {
846
+ node.childNodes.forEach(transformStateInHtml);
847
+ }
848
+ }
849
+ transformStateInHtml(freshClone);
850
+
851
+ // Add instance attributes
852
+ if (!freshClone.attrs) freshClone.attrs = [];
853
+ freshClone.attrs.push({ name: "data-zen-instance", value: instanceId });
854
+ freshClone.attrs.push({ name: "data-zen-component", value: component.name });
855
+ freshClone.attrs.push({ name: "data-zen-replaced", value: "true" });
856
+
857
+ // Transform event handler attributes to use instance-scoped function names
858
+ // This ensures each component instance has unique handler names
859
+ // Handle both onclick (before split.ts) and data-zen-click (after split.ts)
860
+ for (const attr of freshClone.attrs) {
861
+ const attrName = attr.name ? attr.name.toLowerCase() : '';
862
+ // Check for onclick, onchange, etc. (before split.ts transforms them)
863
+ if (attrName.startsWith('on') && attrName.length > 2) {
864
+ const handlerName = attr.value;
865
+ if (handlerName && /^\w+$/.test(handlerName)) {
866
+ // Check if this handler name is a prop (not a function)
867
+ // Convert camelCase prop name (onClick) to event type (click)
868
+ const eventType = attrName.slice(2); // Remove "on" prefix
869
+ if (component.props.has(handlerName)) {
870
+ // It's a prop - bind directly (no wrapper function)
871
+ // Store the mapping for later use (to register on window)
872
+ propHandlers.set(handlerName, eventType);
873
+ // Use instance-scoped name that will reference the prop directly
874
+ const handlerRefName = `__zen_${safeInstanceId}_${handlerName}`;
875
+ attr.value = handlerRefName;
876
+ } else {
877
+ // It's a regular function name, make it instance-scoped
878
+ attr.value = `__zen_${safeInstanceId}_${handlerName}`;
879
+ }
880
+ }
881
+ }
882
+ // Check for data-zen-click, data-zen-change, etc. (after split.ts transforms them)
883
+ else if (attr.name && attr.name.startsWith('data-zen-') &&
884
+ attr.name !== 'data-zen-instance' &&
885
+ attr.name !== 'data-zen-component' &&
886
+ attr.name !== 'data-zen-replaced' &&
887
+ attr.name !== 'data-zen-class' &&
888
+ attr.name !== 'data-zen-value' &&
889
+ attr.name !== 'data-zen-bind' &&
890
+ attr.name !== 'data-zen-bind-id') {
891
+ // This is likely an event handler (data-zen-click, etc.)
892
+ // Transform the handler name to instance-scoped version
893
+ const handlerName = attr.value;
894
+ if (handlerName && /^\w+$/.test(handlerName)) {
895
+ // It's a simple function name, make it instance-scoped
896
+ attr.value = `__zen_${safeInstanceId}_${handlerName}`;
897
+ }
898
+ }
899
+ }
900
+
901
+ // Replace slots if needed
902
+ if (component.hasSlots) {
903
+ replaceSlots(freshClone);
904
+ }
905
+
906
+ // Replace the component tag with the fresh clone
907
+ node.childNodes[i] = freshClone;
908
+ freshClone.parentNode = node;
909
+ replaced = true;
910
+ return true;
911
+ }
912
+ if (replaceComponentTag(child)) {
913
+ return true;
914
+ }
915
+ }
916
+ }
917
+ return false;
918
+ }
919
+
920
+ replaceComponentTag(document);
921
+
922
+ if (!replaced) {
923
+ return { html, scripts: [], styles: [] };
924
+ }
925
+
926
+ // Process component scripts with props and instance-scoped state
927
+ const processedScripts: ScriptBlock[] = [];
928
+ let scriptIndex = 0;
929
+
930
+ // First, extract state declarations to know what to transform
931
+ const allStateDecls = new Set<string>();
932
+ for (const script of component.scripts) {
933
+ const decls = extractStateDeclarations(script);
934
+ for (const [stateName] of decls.entries()) {
935
+ allStateDecls.add(stateName);
936
+ }
937
+ }
938
+
939
+ // Props as direct bindings (NOT a props object)
940
+ // Inline props directly into component scope as const bindings
941
+ const propBindings: string[] = []; // Array of "const propName = ..." statements
942
+ const stateProps = new Set<string>(); // Track which props are state variables (bind to parent state)
943
+ const functionProps = new Set<string>(); // Track which props are function references (bind to parent function)
944
+
945
+ // Create case-insensitive lookup map for props (parse5 lowercases attribute names)
946
+ // Also create a reverse map: lowercase -> original key for case-insensitive lookup
947
+ const propsLowercase = new Map<string, string>(); // lowercase key -> original value
948
+ const propsKeyMap = new Map<string, string>(); // lowercase key -> original key
949
+ for (const [key, value] of props.entries()) {
950
+ const keyLower = key.toLowerCase();
951
+ propsLowercase.set(keyLower, value);
952
+ propsKeyMap.set(keyLower, key);
953
+ }
954
+
955
+ for (const [propName, defaultValue] of component.props.entries()) {
956
+ // Look up prop value case-insensitively (parse5 lowercases attribute names)
957
+ // Try exact match first, then case-insensitive match
958
+ let propValue = props.get(propName);
959
+ if (propValue === undefined || propValue === "") {
960
+ propValue = propsLowercase.get(propName.toLowerCase());
961
+ }
962
+ if (propValue !== undefined && propValue !== "") {
963
+ // Handle both {variable} and variable syntax
964
+ let trimmedValue = propValue.trim();
965
+ // Strip braces if present: {increment} -> increment, {clicks} -> clicks
966
+ if (trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) {
967
+ trimmedValue = trimmedValue.slice(1, -1).trim();
968
+ }
969
+ // Check if propValue is a function reference or variable (not quoted, valid identifier)
970
+ const isFunctionOrVar = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(trimmedValue) &&
971
+ !trimmedValue.startsWith('"') &&
972
+ !trimmedValue.startsWith("'");
973
+
974
+ if (isFunctionOrVar) {
975
+ // It's a function reference or state variable - bind to parent via window
976
+ // We'll determine if it's a function or state when we generate the binding code
977
+ propBindings.push(`const ${propName} = window.${trimmedValue};`);
978
+ // Note: trimmedValue (e.g., "increment") needs to be added to top-level functionProps
979
+ // This is handled earlier in the component processing loop (line ~209)
980
+ // The local functionProps set here is not used for mutation validation
981
+ } else if (/^["']/.test(trimmedValue)) {
982
+ // Already quoted string - use as-is
983
+ propBindings.push(`const ${propName} = ${trimmedValue};`);
984
+ } else {
985
+ // String literal - quote it
986
+ propBindings.push(`const ${propName} = "${trimmedValue}";`);
987
+ }
988
+ } else if (defaultValue === "?") {
989
+ // Optional prop from type Props - use undefined
990
+ propBindings.push(`const ${propName} = undefined;`);
991
+ } else if (defaultValue) {
992
+ // Use default value (already an expression)
993
+ propBindings.push(`const ${propName} = ${defaultValue};`);
994
+ } else {
995
+ // No default, use undefined
996
+ propBindings.push(`const ${propName} = undefined;`);
997
+ }
998
+ }
999
+
1000
+ const propsBindingsCode = propBindings.length > 0
1001
+ ? propBindings.join("\n ") + "\n"
1002
+ : "";
1003
+
1004
+ // Replace hyphens in instanceId with underscores for valid JavaScript identifiers
1005
+ const safeInstanceId = instanceId.replace(/-/g, '_');
1006
+
1007
+ for (const script of component.scripts) {
1008
+ let processedScript = script;
1009
+
1010
+ // Remove type Props definitions (TypeScript types are compile-time only, not needed in JavaScript)
1011
+ // This keeps the compiled output clean and prevents confusion
1012
+ // Match: type Props = { ... } with multiline support and nested braces
1013
+ processedScript = processedScript.replace(/type\s+Props\s*=\s*\{[^}]*(?:\{[^}]*\}[^}]*)*\}\s*/gs, '');
1014
+
1015
+ // Transform state declarations to be instance-scoped
1016
+ for (const stateName of allStateDecls) {
1017
+ const instanceStateName = `__zen_${safeInstanceId}_${stateName}`;
1018
+ // Replace state declarations first
1019
+ processedScript = processedScript.replace(
1020
+ new RegExp(`state\\s+${stateName}\\s*=`, 'g'),
1021
+ `state ${instanceStateName} =`
1022
+ );
1023
+ }
1024
+
1025
+ // Now replace state references (but not in state declarations, which we already transformed)
1026
+ // Replace ALL occurrences of state name with instance-scoped name
1027
+ // Important: Use window.property to ensure setter is triggered for mutations
1028
+ for (const stateName of allStateDecls) {
1029
+ const instanceStateName = `__zen_${safeInstanceId}_${stateName}`;
1030
+ // Replace state references using word boundaries
1031
+ // For assignments (including +=, -=, etc.), use window property to trigger setters
1032
+ // This handles: clicks, clicks += 1, clicks > 0, console.log("clicked", clicks)
1033
+ // But NOT: state clicks = 0 (already transformed above)
1034
+
1035
+ // Replace assignment operations first (including +=, -=, *=, /=, etc.)
1036
+ const assignmentOps = ['\\+=', '-=', '\\*=', '/=', '%=', '\\*\\*='];
1037
+ for (const op of assignmentOps) {
1038
+ const assignmentRegex = new RegExp(`\\b${stateName}\\s*${op}`, 'g');
1039
+ processedScript = processedScript.replace(assignmentRegex, `window.${instanceStateName} ${op.replace('\\', '')}`);
1040
+ }
1041
+
1042
+ // Then replace regular assignments (stateName = ...)
1043
+ const simpleAssignRegex = new RegExp(`\\b${stateName}\\s*=\\s*(?!\\s*[=!<>])`, 'g');
1044
+ processedScript = processedScript.replace(simpleAssignRegex, `window.${instanceStateName} =`);
1045
+
1046
+ // Finally, replace all other references (reads) with instance-scoped name
1047
+ // For reads, we can use the variable directly since the getter will be called
1048
+ const stateRefRegex = new RegExp(`\\b${stateName}\\b`, 'g');
1049
+ processedScript = processedScript.replace(stateRefRegex, instanceStateName);
1050
+ }
1051
+
1052
+ // Extract function declarations and register them on window for event delegation
1053
+ // Match: function functionName(...) { ... }
1054
+ const functionRegex = /function\s+(\w+)\s*\([^)]*\)\s*\{/g;
1055
+ const functionNames: string[] = [];
1056
+ let match;
1057
+ while ((match = functionRegex.exec(processedScript)) !== null) {
1058
+ if (match[1]) {
1059
+ functionNames.push(match[1]);
1060
+ }
1061
+ }
1062
+
1063
+ // Replace props.propName references with direct propName (props are now direct bindings)
1064
+ // This handles cases where component code might still reference props.propName
1065
+ for (const propName of component.props.keys()) {
1066
+ const propsRefRegex = new RegExp(`props\\.${propName}\\b`, 'g');
1067
+ processedScript = processedScript.replace(propsRefRegex, propName);
1068
+ }
1069
+
1070
+ // Wrap script in IIFE to scope props and register functions on window
1071
+ // Props are inlined as direct bindings (NOT a props object)
1072
+ let wrappedScript = `(function() {\n // Props as direct bindings (inlined from parent)\n ${propsBindingsCode}`;
1073
+
1074
+ // Add the processed script content
1075
+ wrappedScript += processedScript;
1076
+
1077
+ // Register functions on window for event delegation with instance-scoped names
1078
+ // This ensures each component instance has unique function names
1079
+ if (functionNames.length > 0) {
1080
+ wrappedScript += `\n\n // Register functions on window for event delegation (instance-scoped)\n`;
1081
+ for (const funcName of functionNames) {
1082
+ const instanceFuncName = `__zen_${safeInstanceId}_${funcName}`;
1083
+ wrappedScript += ` window.${instanceFuncName} = ${funcName};\n`;
1084
+ }
1085
+ }
1086
+
1087
+ // Register prop-based event handlers directly (no wrapper functions)
1088
+ // onclick="onClick" where onClick is a prop -> directly reference the bound function
1089
+ if (propHandlers.size > 0) {
1090
+ wrappedScript += `\n\n // Register prop-based event handlers directly (no wrappers)\n`;
1091
+ for (const [propName, eventType] of propHandlers.entries()) {
1092
+ // Create an instance-scoped reference to the prop function
1093
+ const handlerName = `__zen_${safeInstanceId}_${propName}`;
1094
+ wrappedScript += ` // Bind ${propName} prop to handler name for event delegation\n`;
1095
+ wrappedScript += ` const ${handlerName} = ${propName};\n`;
1096
+ wrappedScript += ` if (typeof ${handlerName} === 'function') {\n`;
1097
+ wrappedScript += ` window.${handlerName} = ${handlerName};\n`;
1098
+ wrappedScript += ` }\n\n`;
1099
+ }
1100
+ }
1101
+
1102
+ wrappedScript += `})();`;
1103
+
1104
+ processedScripts.push({ content: wrappedScript, index: scriptIndex++ });
1105
+ }
1106
+
1107
+ // Process component styles with scoping
1108
+ const processedStyles: StyleBlock[] = [];
1109
+ let styleIndex = 0;
1110
+ for (const style of component.styles) {
1111
+ // Scope styles to component instance
1112
+ const scopedStyle = scopeStyle(style, component.name);
1113
+ processedStyles.push({ content: scopedStyle, index: styleIndex++ });
1114
+ }
1115
+
1116
+ const serializedHtml = parse5.serialize(document);
1117
+
1118
+ return {
1119
+ html: serializedHtml,
1120
+ scripts: processedScripts,
1121
+ styles: processedStyles
1122
+ };
1123
+ }
1124
+
1125
+ /**
1126
+ * Clone a parse5 node
1127
+ */
1128
+ function cloneNode(node: any): any {
1129
+ // For text nodes, just copy the value
1130
+ if (node.nodeName === "#text") {
1131
+ return {
1132
+ nodeName: node.nodeName,
1133
+ value: node.value,
1134
+ parentNode: undefined
1135
+ };
1136
+ }
1137
+
1138
+ // For comment nodes, copy the data
1139
+ if (node.nodeName === "#comment") {
1140
+ return {
1141
+ nodeName: node.nodeName,
1142
+ data: node.data,
1143
+ parentNode: undefined
1144
+ };
1145
+ }
1146
+
1147
+ // For document fragments
1148
+ if (node.nodeName === "#document-fragment") {
1149
+ const cloned: any = {
1150
+ nodeName: node.nodeName,
1151
+ childNodes: [],
1152
+ parentNode: undefined
1153
+ };
1154
+
1155
+ if (node.childNodes && node.childNodes.length > 0) {
1156
+ cloned.childNodes = node.childNodes.map((child: any) => {
1157
+ const clonedChild = cloneNode(child);
1158
+ clonedChild.parentNode = cloned;
1159
+ return clonedChild;
1160
+ });
1161
+ }
1162
+
1163
+ return cloned;
1164
+ }
1165
+
1166
+ // For element nodes, clone with proper structure
1167
+ const cloned: any = {
1168
+ nodeName: node.nodeName,
1169
+ tagName: node.tagName,
1170
+ attrs: node.attrs ? node.attrs.map((a: any) => ({ ...a })) : [],
1171
+ childNodes: [],
1172
+ parentNode: undefined
1173
+ };
1174
+
1175
+ // Copy namespace URI if present (for SVG elements, etc.)
1176
+ if (node.namespaceURI) {
1177
+ cloned.namespaceURI = node.namespaceURI;
1178
+ }
1179
+
1180
+ if (node.childNodes && node.childNodes.length > 0) {
1181
+ cloned.childNodes = node.childNodes.map((child: any) => {
1182
+ const clonedChild = cloneNode(child);
1183
+ clonedChild.parentNode = cloned;
1184
+ return clonedChild;
1185
+ });
1186
+ }
1187
+
1188
+ return cloned;
1189
+ }
1190
+
1191
+ /**
1192
+ * Scope CSS styles to component instances
1193
+ */
1194
+ function scopeStyle(style: string, componentName: string): string {
1195
+ const scopeSelector = `[data-zen-component="${componentName}"]`;
1196
+
1197
+ // Simple CSS scoping - prepend scope selector to each rule
1198
+ // This is a simplified approach
1199
+ return style.replace(/([^{]+)\{/g, (match, selector) => {
1200
+ const trimmed = selector.trim();
1201
+ if (trimmed.startsWith("@")) {
1202
+ // Media queries, keyframes, etc.
1203
+ return match;
1204
+ }
1205
+ return `${scopeSelector} ${trimmed} {`;
1206
+ });
1207
+ }
1208
+