meno-core 1.0.1 → 1.0.3

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 (33) hide show
  1. package/lib/client/core/ComponentBuilder.ts +347 -769
  2. package/lib/client/core/builders/cmsListBuilder.ts +193 -0
  3. package/lib/client/core/builders/embedBuilder.ts +92 -0
  4. package/lib/client/core/builders/index.ts +26 -0
  5. package/lib/client/core/builders/linkBuilder.ts +118 -0
  6. package/lib/client/core/builders/localeListBuilder.ts +145 -0
  7. package/lib/client/core/builders/objectLinkBuilder.ts +90 -0
  8. package/lib/client/core/builders/types.ts +89 -0
  9. package/lib/client/hmrWebSocket.ts +6 -2
  10. package/lib/client/routing/RouteLoader.ts +4 -2
  11. package/lib/client/scripts/ScriptExecutor.ts +9 -4
  12. package/lib/client/styles/StyleInjector.ts +7 -3
  13. package/lib/server/errors.ts +65 -0
  14. package/lib/server/ssr/attributeBuilder.ts +78 -0
  15. package/lib/server/ssr/cmsSSRProcessor.ts +100 -0
  16. package/lib/server/ssr/cssCollector.ts +33 -0
  17. package/lib/server/ssr/htmlGenerator.ts +147 -0
  18. package/lib/server/ssr/imageMetadata.ts +117 -0
  19. package/lib/server/ssr/index.ts +33 -0
  20. package/lib/server/ssr/jsCollector.ts +89 -0
  21. package/lib/server/ssr/metaTagGenerator.ts +106 -0
  22. package/lib/server/ssr/ssrRenderer.ts +991 -0
  23. package/lib/server/ssrRenderer.ts +7 -1491
  24. package/lib/shared/errorLogger.test.ts +128 -0
  25. package/lib/shared/errorLogger.ts +87 -0
  26. package/lib/shared/errors.test.ts +99 -0
  27. package/lib/shared/errors.ts +50 -0
  28. package/lib/shared/index.ts +4 -0
  29. package/lib/shared/propResolver.test.ts +108 -3
  30. package/lib/shared/propResolver.ts +8 -6
  31. package/lib/shared/validation/propValidator.test.ts +40 -0
  32. package/lib/shared/validation/propValidator.ts +6 -1
  33. package/package.json +1 -1
@@ -14,28 +14,37 @@ import { NODE_TYPE } from "../../shared/constants";
14
14
  import { ErrorBoundary } from "../ErrorBoundary";
15
15
  import type { ComponentNode, StyleValue } from "../../shared/types";
16
16
  import { isComponentNode, extractNodeProperties, isSlotMarker, isEmbedNode, isObjectLinkNode, isLocaleListNode, isCMSListNode, markAsSlotContent } from "../../shared/nodeUtils";
17
- import DOMPurify from "isomorphic-dompurify";
18
17
  import { mergeNodeStyles } from "../../shared/styleNodeUtils";
19
18
  import { extractAttributesFromNode } from "../../shared/attributeNodeUtils";
20
- import { resolvePropsFromDefinition, convertRichTextInProps } from "../../shared/propResolver";
19
+ import { resolvePropsFromDefinition } from "../../shared/propResolver";
21
20
  import { ElementRegistry } from "../elementRegistry";
22
21
  import { InteractiveStylesRegistry } from "../InteractiveStylesRegistry";
23
22
  import type { Path } from "../../shared/pathArrayUtils";
24
23
  import type { I18nConfig } from "../../shared/types/components";
25
- import type { CMSItem, ItemContext, TemplateContext } from "../../shared/types/cms";
26
- import { singularize } from "../../shared/types/cms";
27
- import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, buildTemplateContext, resolveItemsTemplate, type ValueResolver } from "../../shared/itemTemplateUtils";
24
+ import type { ItemContext, TemplateContext } from "../../shared/types/cms";
25
+ import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, type ValueResolver } from "../../shared/itemTemplateUtils";
28
26
  import { DEFAULT_I18N_CONFIG, resolveI18nValue } from "../../shared/i18n";
29
27
  import { getChildPath, pathToString } from "../../shared/pathArrayUtils";
30
- import { navigateTo } from "../navigation";
31
28
  import { responsiveStylesToClasses } from "../../shared/utilityClassMapper";
32
29
  import { processCMSTemplate, processCMSPropsTemplate } from "./cmsTemplateProcessor";
33
30
  import type { PrefetchService } from "../services/PrefetchService";
34
- import type { PrefetchStrategy } from "../../shared/types/prefetch";
35
31
  import { generateElementClassName, type ElementClassContext } from "../../shared/elementClassName";
36
32
  import type { InteractiveStyles } from "../../shared/types/styles";
37
33
  import { extractInteractiveStyleMappings, resolveExtractedMappings, hasInteractiveStyleMappings } from "../../shared/interactiveStyleMappings";
38
34
 
35
+ // Extracted builders
36
+ import {
37
+ type BuildChildrenContext,
38
+ type BuildComponentOptions,
39
+ type BuildResult,
40
+ type BuilderContext,
41
+ buildEmbed,
42
+ buildObjectLink,
43
+ buildLocaleList,
44
+ buildCMSList,
45
+ buildLink,
46
+ } from "./builders";
47
+
39
48
  type ComponentProps = Record<string, unknown>;
40
49
 
41
50
  export interface ComponentBuilderConfig {
@@ -49,54 +58,8 @@ export interface ComponentBuilderConfig {
49
58
  getCurrentFileType?: () => 'page' | 'component';
50
59
  }
51
60
 
52
- /**
53
- * Context object for buildChildren - encapsulates all rendering context
54
- */
55
- export interface BuildChildrenContext {
56
- elementPath: Path;
57
- parentComponentName: string | null;
58
- viewportWidth: number;
59
- componentContext: string | null;
60
- /** Path where the current component started (for calculating component-relative paths) */
61
- componentRootPath?: Path;
62
- locale?: string;
63
- i18nConfig?: I18nConfig;
64
- cmsContext?: Record<string, unknown> | null;
65
- cmsLocale?: string | null;
66
- collectionItemsMap?: Record<string, CMSItem[]>;
67
- itemContext?: ItemContext | null;
68
- cmsItemIndexPath?: number[] | null;
69
- templateContext?: TemplateContext | null;
70
- /** Resolved component props for interactive style mapping resolution */
71
- componentResolvedProps?: Record<string, unknown> | null;
72
- }
73
-
74
- export interface BuildComponentOptions {
75
- node: ComponentNode | ComponentNode[] | string | number | null | undefined;
76
- key?: number;
77
- customProps?: ComponentProps;
78
- elementPath?: Path;
79
- parentComponentName?: string | null;
80
- viewportWidth?: number;
81
- componentContext?: string | null;
82
- /** Path where the current component started (for calculating component-relative paths) */
83
- componentRootPath?: Path;
84
- locale?: string;
85
- i18nConfig?: I18nConfig;
86
- cmsContext?: Record<string, unknown> | null;
87
- /** CMS locale for i18n field resolution */
88
- cmsLocale?: string | null;
89
- /** Map of collection name to items for CMS List rendering */
90
- collectionItemsMap?: Record<string, CMSItem[]>;
91
- /** Item context for rendering inside CMS List (contains current item data) */
92
- itemContext?: ItemContext | null;
93
- /** Template context with named variables for nested CMS lists */
94
- templateContext?: TemplateContext | null;
95
- /** CMS item index path for elements inside CMS List(s) - supports nested lists (e.g., [0, 2] for outer item 0, inner item 2) */
96
- cmsItemIndexPath?: number[] | null;
97
- /** Resolved component props for interactive style mapping resolution */
98
- componentResolvedProps?: Record<string, unknown> | null;
99
- }
61
+ // Re-export types for backward compatibility
62
+ export type { BuildChildrenContext, BuildComponentOptions };
100
63
 
101
64
  /**
102
65
  * ComponentBuilder class for building React elements from component tree nodes
@@ -141,7 +104,6 @@ export class ComponentBuilder {
141
104
 
142
105
  /**
143
106
  * Determines the parent component name for nested component instances.
144
- * Priority: componentContext (if inside component definition) > parentComponentName (if inside another instance) > componentName (fallback for page-level components)
145
107
  */
146
108
  private getParentComponentNameForNestedComponent(
147
109
  componentContext: string | null,
@@ -153,7 +115,6 @@ export class ComponentBuilder {
153
115
 
154
116
  /**
155
117
  * Determines the effective parent component name when building children.
156
- * Uses componentContext if available, otherwise falls back to parentComponentName.
157
118
  */
158
119
  private getEffectiveParentComponentName(
159
120
  componentContext: string | null,
@@ -164,12 +125,9 @@ export class ComponentBuilder {
164
125
 
165
126
  /**
166
127
  * Find the path to the slot marker in a component structure
167
- * This helps determine where children should be placed and what paths they should have
168
128
  */
169
129
  private findSlotMarkerPath(structure: ComponentNode | undefined, currentPath: Path = [0]): Path | null {
170
130
  if (!structure) return null;
171
-
172
- // Check if this node has children (SlotMarker doesn't have children)
173
131
  if (isSlotMarker(structure)) return null;
174
132
 
175
133
  const nodeChildren = (structure as any).children;
@@ -177,13 +135,9 @@ export class ComponentBuilder {
177
135
 
178
136
  for (let i = 0; i < nodeChildren.length; i++) {
179
137
  const child = nodeChildren[i];
180
-
181
- // Check if this is a slot marker
182
138
  if (isSlotMarker(child)) {
183
139
  return getChildPath(currentPath, i);
184
140
  }
185
-
186
- // Recursively search in nested structures
187
141
  if (typeof child === 'object' && child !== null && !Array.isArray(child) && 'type' in child) {
188
142
  const nestedPath = this.findSlotMarkerPath(child as ComponentNode, getChildPath(currentPath, i));
189
143
  if (nestedPath) return nestedPath;
@@ -254,9 +208,8 @@ export class ComponentBuilder {
254
208
 
255
209
  /**
256
210
  * Component builder from JSON tree with support for custom components
257
- * viewportWidth is passed down to resolve responsive styles based on actual viewport
258
211
  */
259
- buildComponent(options: BuildComponentOptions): ReactElement | ReactElement[] | string | number | null {
212
+ buildComponent(options: BuildComponentOptions): BuildResult {
260
213
  const {
261
214
  node,
262
215
  key = 0,
@@ -279,797 +232,436 @@ export class ComponentBuilder {
279
232
 
280
233
  if (!node) return null;
281
234
 
282
- // Handle text nodes - process CMS templates and item templates if context is available
283
- if (typeof node === 'string') {
284
- let result = node;
285
-
286
- // Process CMS templates (e.g., {{cms.title}})
287
- if (cmsContext && result.includes('{{cms.')) {
288
- result = processCMSTemplate(result, cmsContext, cmsLocale || locale);
289
- }
235
+ // Build context for specialized builders
236
+ const ctx: BuilderContext = {
237
+ key, elementPath, parentComponentName, viewportWidth, componentContext, componentRootPath,
238
+ locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap,
239
+ itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
240
+ };
290
241
 
291
- // Process item templates (e.g., {{item.title}}, {{itemIndex}}, {{post.title}}, {{postIndex}})
292
- // Use templateContext if available (supports named contexts), fall back to itemContext
293
- const effectiveTemplateContext = templateContext || itemContext;
294
- if (effectiveTemplateContext && hasItemTemplates(result)) {
295
- // Create i18n resolver if locale and config are available
296
- const effectiveLocale = cmsLocale || locale;
297
- const config = i18nConfig || DEFAULT_I18N_CONFIG;
298
- const i18nResolver: ValueResolver | undefined = effectiveLocale
299
- ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
300
- : undefined;
301
- result = processItemTemplate(result, effectiveTemplateContext, i18nResolver);
302
- }
242
+ // Dependencies for specialized builders
243
+ const builderDeps = {
244
+ elementRegistry: this.elementRegistry,
245
+ prefetchService: this.prefetchService,
246
+ getCachedStyleClasses: this.getCachedStyleClasses.bind(this),
247
+ buildChildren: this.buildChildren.bind(this),
248
+ getEffectiveParentComponentName: this.getEffectiveParentComponentName.bind(this),
249
+ };
303
250
 
304
- return result;
251
+ // Handle text nodes - process CMS templates and item templates
252
+ if (typeof node === 'string') {
253
+ return this.processTextNode(node, ctx);
305
254
  }
306
255
 
307
256
  if (typeof node === 'number') {
308
257
  return node;
309
258
  }
310
-
259
+
311
260
  if (Array.isArray(node)) {
312
- const result = node.map((child, index) => {
313
- const childPath = getChildPath(elementPath, index);
314
- return this.buildComponent({
315
- node: child,
316
- key: index,
317
- customProps,
318
- elementPath: childPath,
319
- parentComponentName,
320
- viewportWidth,
321
- componentContext,
322
- locale,
323
- i18nConfig,
324
- cmsContext,
325
- cmsLocale,
326
- collectionItemsMap,
327
- itemContext,
328
- cmsItemIndexPath,
329
- templateContext,
330
- componentResolvedProps
331
- });
332
- }).filter(
333
- (item): item is ReactElement | string | number => item !== null
334
- );
335
- return result as ReactElement[];
261
+ return this.buildNodeArray(node, options);
336
262
  }
337
-
338
- // Handle new format: style at top level (not props.style)
263
+
264
+ // Get node type
339
265
  const nodeType = typeof node === 'object' && node !== null && 'type' in node ? node.type : undefined;
340
266
  const children = typeof node === 'object' && node !== null && 'children' in node ? (node.children || []) : [];
341
267
 
342
- // Handle embed nodes early (before property extraction)
268
+ // Handle specialized node types using extracted builders
343
269
  if (nodeType === NODE_TYPE.EMBED && isEmbedNode(node)) {
344
- // Sanitize HTML with allowlist for SVG, rich-text formatting, and common elements
345
- // Script tags and event handlers are still removed for security
346
- const sanitizedHtml = DOMPurify.sanitize(node.html, {
347
- ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
348
- ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity'],
349
- KEEP_CONTENT: true
350
- });
351
- const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
352
-
353
- // Extract attributes from node
354
- const extractedAttributes = extractAttributesFromNode(node);
355
-
356
- // Build embed node props with all features
357
- const embedProps: Record<string, any> = {
358
- key,
359
- 'data-element-path': pathToString(elementPath),
360
- 'data-embed-node': 'true',
361
- ref: (el: HTMLElement | null) => this.elementRegistry.register(elementPath, el, effectiveParentComponentName, false),
362
- dangerouslySetInnerHTML: { __html: sanitizedHtml }
363
- };
364
-
365
- // Add CMS item index path for elements inside CMS lists
366
- if (cmsItemIndexPath && cmsItemIndexPath.length > 0) {
367
- embedProps['data-cms-item-index'] = cmsItemIndexPath.join('.');
368
- }
369
-
370
- // Convert embed styles to utility classes
371
- if (node.style) {
372
- const utilityClasses = responsiveStylesToClasses(node.style);
373
- if (utilityClasses.length > 0) {
374
- const existingClassName = (extractedAttributes.className || '') as string;
375
- const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
376
- embedProps.className = [...classArray, ...utilityClasses].filter(Boolean).join(' ');
377
- }
378
- }
379
-
380
- // Add extracted attributes (like class, id, data-*, aria-*, etc.)
381
- if (Object.keys(extractedAttributes).length > 0) {
382
- Object.assign(embedProps, extractedAttributes);
383
- }
384
-
385
- // Add parent component context
386
- if (effectiveParentComponentName) {
387
- embedProps['data-parent-component'] = effectiveParentComponentName;
388
- }
389
- if (componentContext) {
390
- embedProps['data-component-context'] = componentContext;
391
- }
392
-
393
- // Use span for inline embeds (rich-text content), div for block embeds (SVG, etc.)
394
- const embedTag = (node as any).inline ? 'span' : 'div';
395
- return h(embedTag, embedProps);
270
+ return buildEmbed(node, ctx, builderDeps);
396
271
  }
397
272
 
398
- // Handle object link nodes (render as div in editor)
399
273
  if (nodeType === NODE_TYPE.OBJECT_LINK && isObjectLinkNode(node)) {
400
- const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
401
-
402
- // Extract attributes
403
- const extractedAttributes = extractAttributesFromNode(node);
404
-
405
- // Build object link props (renders as div in editor)
406
- const objectLinkProps: Record<string, any> = {
407
- key,
408
- 'data-element-path': pathToString(elementPath),
409
- 'data-object-link-node': 'true',
410
- ref: (el: HTMLElement | null) => this.elementRegistry.register(elementPath, el, effectiveParentComponentName, false)
411
- };
412
-
413
- // Add CMS item index path for elements inside CMS lists
414
- if (cmsItemIndexPath && cmsItemIndexPath.length > 0) {
415
- objectLinkProps['data-cms-item-index'] = cmsItemIndexPath.join('.');
416
- }
417
-
418
- // Convert object link styles to utility classes
419
- if (node.style) {
420
- const utilityClasses = responsiveStylesToClasses(node.style);
421
- if (utilityClasses.length > 0) {
422
- const existingClassName = (extractedAttributes.className || '') as string;
423
- const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
424
- objectLinkProps.className = [...classArray, ...utilityClasses].filter(Boolean).join(' ');
425
- }
426
- }
427
-
428
- // Add extracted attributes (like class, id, data-*, aria-*, etc.)
429
- if (Object.keys(extractedAttributes).length > 0) {
430
- Object.assign(objectLinkProps, extractedAttributes);
431
- }
432
-
433
- // Add parent component context
434
- if (effectiveParentComponentName) {
435
- objectLinkProps['data-parent-component'] = effectiveParentComponentName;
436
- }
437
- if (componentContext) {
438
- objectLinkProps['data-component-context'] = componentContext;
439
- }
440
-
441
- // Build children recursively
442
- const objectLinkChildren = this.buildChildren(children, {
443
- elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
444
- locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
445
- });
446
-
447
- return h('div', objectLinkProps, objectLinkChildren);
274
+ return buildObjectLink(node, children, ctx, builderDeps);
448
275
  }
449
276
 
450
- // Handle locale-list nodes (render placeholder in editor)
451
277
  if (nodeType === NODE_TYPE.LOCALE_LIST && isLocaleListNode(node)) {
452
- const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
453
-
454
- // Extract attributes
455
- const extractedAttributes = extractAttributesFromNode(node);
456
-
457
- // Build locale list props
458
- const localeListProps: Record<string, any> = {
459
- key,
460
- 'data-element-path': pathToString(elementPath),
461
- 'data-locale-list': 'true',
462
- ref: (el: HTMLElement | null) => this.elementRegistry.register(elementPath, el, effectiveParentComponentName, false)
463
- };
464
-
465
- // Add CMS item index path for elements inside CMS lists
466
- if (cmsItemIndexPath && cmsItemIndexPath.length > 0) {
467
- localeListProps['data-cms-item-index'] = cmsItemIndexPath.join('.');
468
- }
469
-
470
- // Convert container styles to utility classes
471
- if (node.style) {
472
- const utilityClasses = responsiveStylesToClasses(node.style);
473
- if (utilityClasses.length > 0) {
474
- const existingClassName = (extractedAttributes.className || '') as string;
475
- const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
476
- localeListProps.className = [...classArray, ...utilityClasses].filter(Boolean).join(' ');
477
- }
478
- }
479
-
480
- // Add extracted attributes
481
- if (Object.keys(extractedAttributes).length > 0) {
482
- Object.assign(localeListProps, extractedAttributes);
483
- }
484
-
485
- // Add parent component context
486
- if (effectiveParentComponentName) {
487
- localeListProps['data-parent-component'] = effectiveParentComponentName;
488
- }
489
- if (componentContext) {
490
- localeListProps['data-component-context'] = componentContext;
491
- }
492
-
493
- // Build locale links for editor preview using actual i18n config
494
- const showCurrent = node.showCurrent !== false;
495
- const showSeparator = node.showSeparator !== false;
496
- const showFlag = node.showFlag !== false;
497
- // Use actual locales from i18nConfig (LocaleConfig[])
498
- const configLocales = i18nConfig?.locales || [];
499
- const currentLocaleCode = locale || i18nConfig?.defaultLocale || 'en';
500
-
501
- // Convert item styles, active item styles, separator styles, and flag styles to utility classes
502
- const itemClasses = node.itemStyle ? responsiveStylesToClasses(node.itemStyle) : [];
503
- const activeItemClasses = node.activeItemStyle ? responsiveStylesToClasses(node.activeItemStyle) : [];
504
- const separatorClasses = node.separatorStyle ? responsiveStylesToClasses(node.separatorStyle) : [];
505
- const flagClasses = node.flagStyle ? responsiveStylesToClasses(node.flagStyle) : [];
506
-
507
- // Build locale links from config
508
- const linkElements: ReactElement[] = [];
509
- for (let i = 0; i < configLocales.length; i++) {
510
- const localeConfig = configLocales[i];
511
- const isCurrent = localeConfig.code === currentLocaleCode;
512
- if (!showCurrent && isCurrent) continue;
513
-
514
- // Add separator between links (empty span styled via separatorStyle)
515
- if (showSeparator && linkElements.length > 0) {
516
- linkElements.push(h('span', {
517
- key: `sep-${i}`,
518
- className: separatorClasses.length > 0 ? separatorClasses.join(' ') : undefined
519
- }));
520
- }
521
-
522
- // Build link element content - flag icon (if enabled and exists) + nativeName in span
523
- const linkContent: (ReactElement | string)[] = [];
524
- if (showFlag && localeConfig.icon) {
525
- linkContent.push(h('img', {
526
- key: 'flag',
527
- src: localeConfig.icon,
528
- alt: `${localeConfig.name} flag`,
529
- className: flagClasses.length > 0 ? flagClasses.join(' ') : undefined
530
- }));
531
- }
532
- // Get display text based on displayType (mirrors SSR logic)
533
- const displayType = node.displayType || 'nativeName';
534
- let displayText: string;
535
- switch (displayType) {
536
- case 'code':
537
- displayText = localeConfig.code.toUpperCase();
538
- break;
539
- case 'name':
540
- displayText = localeConfig.name;
541
- break;
542
- case 'nativeName':
543
- default:
544
- displayText = localeConfig.nativeName;
545
- break;
546
- }
547
- linkContent.push(h('div', { key: 'text' }, displayText));
548
-
549
- // Current item gets both itemClasses + activeItemClasses (additive/override)
550
- const linkClasses = isCurrent ? [...itemClasses, ...activeItemClasses] : itemClasses;
551
- linkElements.push(h('div', {
552
- key: `locale-${localeConfig.code}`,
553
- 'data-current': isCurrent ? 'true' : 'false',
554
- 'data-locale': localeConfig.code,
555
- className: linkClasses.length > 0 ? linkClasses.join(' ') : undefined,
556
- style: { cursor: 'pointer' }
557
- }, ...linkContent));
558
- }
559
-
560
- return h('div', localeListProps, linkElements);
278
+ return buildLocaleList(node, ctx, builderDeps);
561
279
  }
562
280
 
563
- // Handle cms-list nodes (render children for each CMS item)
564
281
  if (nodeType === NODE_TYPE.CMS_LIST && isCMSListNode(node)) {
565
- const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
566
-
567
- // Extract attributes
568
- const extractedAttributes = extractAttributesFromNode(node);
282
+ return buildCMSList(node, children, ctx, builderDeps);
283
+ }
569
284
 
570
- // Build CMS list container props
571
- const cmsListProps: Record<string, any> = {
572
- key,
573
- 'data-element-path': pathToString(elementPath),
574
- 'data-cms-list': 'true',
575
- 'data-collection': node.collection || '',
576
- ref: (el: HTMLElement | null) => this.elementRegistry.register(elementPath, el, effectiveParentComponentName, false)
577
- };
285
+ // Extract node properties
286
+ const { tag, componentName, props: nodeProps } = extractNodeProperties(node);
578
287
 
579
- // Convert container styles to utility classes (using cache for performance)
580
- if (node.style) {
581
- const utilityClasses = this.getCachedStyleClasses(node.style);
582
- if (utilityClasses.length > 0) {
583
- const existingClassName = (extractedAttributes.className || '') as string;
584
- const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
585
- cmsListProps.className = [...classArray, ...utilityClasses].filter(Boolean).join(' ');
586
- }
587
- }
288
+ // Filter internal props
289
+ const props = this.filterInternalProps(nodeProps, tag);
588
290
 
589
- // Add extracted attributes
590
- if (Object.keys(extractedAttributes).length > 0) {
591
- Object.assign(cmsListProps, extractedAttributes);
592
- }
291
+ // Process templates in props
292
+ const processedProps = this.processPropsTemplates(props, ctx);
593
293
 
594
- // Add parent component context
595
- if (effectiveParentComponentName) {
596
- cmsListProps['data-parent-component'] = effectiveParentComponentName;
597
- }
598
- if (componentContext) {
599
- cmsListProps['data-component-context'] = componentContext;
600
- }
294
+ // Convert styles to utility classes
295
+ const propsWithClasses = this.applyStyleClasses(processedProps, node);
601
296
 
602
- // Determine variable name for this list's items (use itemAs or singular of collection)
603
- const variableName = node.itemAs || singularize(node.collection);
297
+ // Apply interactive styles
298
+ const propsWithInteractive = this.applyInteractiveStyles(propsWithClasses, node, ctx);
604
299
 
605
- // Get items for this collection
606
- let itemsToRender: CMSItem[] = [];
300
+ // Extract and merge attributes
301
+ const finalProps = this.mergeAttributes(propsWithInteractive, node, ctx);
607
302
 
608
- // Helper to lookup items by ID or filename
609
- const lookupItemsByIds = (ids: string[], collectionItems: CMSItem[]): CMSItem[] => {
610
- const itemMap = new Map(collectionItems.map(item => [item._id, item]));
611
- const filenameMap = new Map(collectionItems.map(item => [item._filename, item]));
612
- return ids
613
- .filter(Boolean)
614
- .map(id => itemMap.get(id) || filenameMap.get(id))
615
- .filter((item): item is CMSItem => item !== undefined);
616
- };
303
+ // Handle custom component
304
+ if (nodeType === NODE_TYPE.COMPONENT && componentName && this.componentRegistry.has(componentName)) {
305
+ return this.buildCustomComponent(componentName, nodeProps, children, finalProps, options);
306
+ }
617
307
 
618
- // Check if items are specified directly (for nested reference lists)
619
- if (node.items) {
620
- const collectionItems = node.collection ? (collectionItemsMap[node.collection] || []) : [];
621
-
622
- // If items is a template expression, resolve it from current context
623
- if (typeof node.items === 'string' && node.items.startsWith('{{')) {
624
- const effectiveTemplateContext = templateContext || {};
625
- const resolvedIds = resolveItemsTemplate(node.items, effectiveTemplateContext);
626
- if (resolvedIds) {
627
- const ids = Array.isArray(resolvedIds) ? resolvedIds : [resolvedIds];
628
- itemsToRender = lookupItemsByIds(ids, collectionItems);
629
- }
630
- } else {
631
- // Direct IDs (string or array)
632
- const ids = Array.isArray(node.items) ? node.items : [node.items];
633
- itemsToRender = lookupItemsByIds(ids, collectionItems);
634
- }
635
- } else {
636
- // Get items for this collection from the map
637
- const collectionItems = node.collection ? (collectionItemsMap[node.collection] || []) : [];
308
+ // Handle Link component
309
+ if (tag === 'Link') {
310
+ return buildLink(finalProps, children, ctx, builderDeps);
311
+ }
638
312
 
639
- // Apply offset and limit
640
- itemsToRender = collectionItems;
641
- if (node.offset) {
642
- itemsToRender = itemsToRender.slice(node.offset);
643
- }
644
- if (node.limit) {
645
- itemsToRender = itemsToRender.slice(0, node.limit);
646
- }
647
- }
313
+ // Build regular HTML element
314
+ return this.buildHtmlElement(tag, finalProps, children, customProps, options);
315
+ }
648
316
 
649
- // If no items, show empty state in editor
650
- if (itemsToRender.length === 0) {
651
- const emptyState = h('div', {
652
- key: 'cms-list-empty',
653
- style: {
654
- padding: '12px',
655
- background: 'rgba(139, 92, 246, 0.05)',
656
- border: '1px dashed rgba(139, 92, 246, 0.3)',
657
- borderRadius: '4px',
658
- color: '#8b5cf6',
659
- fontSize: '12px',
660
- fontFamily: 'system-ui, sans-serif',
661
- textAlign: 'center'
662
- }
663
- }, `CMS List: ${node.collection || 'No collection'} - No items`);
664
- return h('div', cmsListProps, emptyState);
665
- }
317
+ /**
318
+ * Process text node with CMS and item templates
319
+ */
320
+ private processTextNode(text: string, ctx: BuilderContext): string {
321
+ let result = text;
666
322
 
667
- // Render children for each item - no wrapper div, cmsItemIndexPath flows to all descendants
668
- const renderedItems: (ReactElement | string | number)[] = [];
669
- for (let index = 0; index < itemsToRender.length; index++) {
670
- const item = itemsToRender[index];
671
- // Build template context with named variable (preserves parent context for nested lists)
672
- const newTemplateContext = buildTemplateContext(
673
- variableName,
674
- item,
675
- index,
676
- itemsToRender.length,
677
- templateContext || undefined
678
- );
679
-
680
- // Build children with templateContext (contains both named and legacy context)
681
- // Append current index to existing path to support nested CMS lists
682
- const newIndexPath = [...(cmsItemIndexPath || []), index];
683
- const itemChildren = this.buildChildren(children, {
684
- elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
685
- locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap,
686
- itemContext: null, // itemContext no longer needed - templateContext has all data
687
- cmsItemIndexPath: newIndexPath, // Pass updated path - will be applied to all descendants via ref callback
688
- templateContext: newTemplateContext, // Pass named template context for nested lists
689
- componentResolvedProps
690
- });
691
-
692
- // Add children directly (no wrapper div)
693
- // Each child element will have data-cms-item-index attribute via ref callback
694
- if (itemChildren === null) continue;
695
- if (Array.isArray(itemChildren)) {
696
- // Add unique keys by combining original key with item index
697
- for (const child of itemChildren) {
698
- if (typeof child === 'object' && child !== null && 'key' in child) {
699
- renderedItems.push({ ...child, key: `${child.key}-item-${index}` } as ReactElement);
700
- } else {
701
- renderedItems.push(child);
702
- }
703
- }
704
- } else if (typeof itemChildren === 'object' && itemChildren !== null && 'key' in itemChildren) {
705
- // Single child - ensure unique key
706
- renderedItems.push({ ...itemChildren, key: `${(itemChildren as ReactElement).key}-item-${index}` } as ReactElement);
707
- } else {
708
- renderedItems.push(itemChildren);
709
- }
710
- }
323
+ // Process CMS templates
324
+ if (ctx.cmsContext && result.includes('{{cms.')) {
325
+ result = processCMSTemplate(result, ctx.cmsContext, ctx.cmsLocale || ctx.locale);
326
+ }
711
327
 
712
- return h('div', cmsListProps, renderedItems);
328
+ // Process item templates
329
+ const effectiveTemplateContext = ctx.templateContext || ctx.itemContext;
330
+ if (effectiveTemplateContext && hasItemTemplates(result)) {
331
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
332
+ const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
333
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
334
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
335
+ : undefined;
336
+ result = processItemTemplate(result, effectiveTemplateContext, i18nResolver);
713
337
  }
714
338
 
715
- // Extract node properties based on type
716
- const { tag, componentName, props: nodeProps } = extractNodeProperties(node);
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * Build array of nodes
344
+ */
345
+ private buildNodeArray(nodes: ComponentNode[], options: BuildComponentOptions): ReactElement[] {
346
+ const {
347
+ customProps, elementPath = [0], parentComponentName = null, viewportWidth = 1920,
348
+ componentContext = null, locale, i18nConfig, cmsContext = null, cmsLocale = null,
349
+ collectionItemsMap = {}, itemContext = null, cmsItemIndexPath = null,
350
+ templateContext = null, componentResolvedProps = null
351
+ } = options;
717
352
 
718
- // Filter internal props from nodeProps before building props object
719
- // This prevents node.type (e.g., "node") from being passed to DOM while allowing
720
- // attributes.type (e.g., "text" for inputs) to be preserved when merged later
353
+ return nodes.map((child, index) => {
354
+ const childPath = getChildPath(elementPath, index);
355
+ return this.buildComponent({
356
+ node: child,
357
+ key: index,
358
+ customProps,
359
+ elementPath: childPath,
360
+ parentComponentName,
361
+ viewportWidth,
362
+ componentContext,
363
+ locale,
364
+ i18nConfig,
365
+ cmsContext,
366
+ cmsLocale,
367
+ collectionItemsMap,
368
+ itemContext,
369
+ cmsItemIndexPath,
370
+ templateContext,
371
+ componentResolvedProps
372
+ });
373
+ }).filter((item): item is ReactElement => item !== null && typeof item === 'object');
374
+ }
375
+
376
+ /**
377
+ * Filter internal props from node props
378
+ */
379
+ private filterInternalProps(nodeProps: Record<string, unknown>, tag: string | undefined): Record<string, unknown> {
721
380
  const imageOnlyProps = ['src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset'];
722
381
  const internalProps = ['type', 'tag', 'component', 'props', 'children', 'html', 'style', ...imageOnlyProps];
723
- let props: Record<string, unknown> = {};
382
+ const props: Record<string, unknown> = {};
383
+
724
384
  for (const [key, value] of Object.entries(nodeProps)) {
725
385
  if (internalProps.includes(key)) {
726
- // Keep image properties if this is an img tag
727
386
  if (tag === 'img' && imageOnlyProps.includes(key)) {
728
387
  props[key] = value;
729
388
  }
730
- // else skip - it's an internal prop
731
389
  } else {
732
390
  props[key] = value;
733
391
  }
734
392
  }
735
393
 
736
- // Process CMS templates in props (e.g., rich-text fields with {{cms.field}} patterns)
737
- if (cmsContext && Object.keys(props).length > 0) {
738
- props = processCMSPropsTemplate(props, cmsContext, cmsLocale || locale);
394
+ return props;
395
+ }
396
+
397
+ /**
398
+ * Process CMS and item templates in props
399
+ */
400
+ private processPropsTemplates(props: Record<string, unknown>, ctx: BuilderContext): Record<string, unknown> {
401
+ let result = props;
402
+
403
+ // Process CMS templates
404
+ if (ctx.cmsContext && Object.keys(result).length > 0) {
405
+ result = processCMSPropsTemplate(result, ctx.cmsContext, ctx.cmsLocale || ctx.locale);
739
406
  }
740
407
 
741
- // Process item templates in props (e.g., rich-text fields with {{item.field}} patterns)
742
- // Use templateContext if available (for named contexts), fall back to itemContext for legacy support
743
- const effectiveItemContext = templateContext || itemContext;
744
- if (effectiveItemContext && Object.keys(props).length > 0) {
745
- const effectiveLocale = cmsLocale || locale;
746
- const config = i18nConfig || DEFAULT_I18N_CONFIG;
408
+ // Process item templates
409
+ const effectiveItemContext = ctx.templateContext || ctx.itemContext;
410
+ if (effectiveItemContext && Object.keys(result).length > 0) {
411
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
412
+ const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
747
413
  const i18nResolver: ValueResolver | undefined = effectiveLocale
748
414
  ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
749
415
  : undefined;
750
- props = processItemPropsTemplate(props, effectiveItemContext, i18nResolver);
416
+ result = processItemPropsTemplate(result, effectiveItemContext, i18nResolver);
751
417
  }
752
418
 
753
- // Extract styles from node and convert to utility classes (using cache for performance)
419
+ return result;
420
+ }
421
+
422
+ /**
423
+ * Apply style classes to props
424
+ */
425
+ private applyStyleClasses(props: Record<string, unknown>, node: ComponentNode): Record<string, unknown> {
754
426
  const nodeStyle = node.style || (isComponentNode(node) ? node.props?.style : undefined);
755
427
 
756
428
  if (nodeStyle && typeof nodeStyle === 'object') {
757
- // Use cached utility classes when possible
758
429
  const utilityClasses = this.getCachedStyleClasses(nodeStyle);
759
430
  if (utilityClasses.length > 0) {
760
431
  const existingClassName = (props.className || '') as string;
761
432
  const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
762
- props.className = [...classArray, ...utilityClasses].filter(Boolean).join(' ');
433
+ return { ...props, className: [...classArray, ...utilityClasses].filter(Boolean).join(' ') };
763
434
  }
764
435
  }
765
436
 
766
- // Generate element-specific class if node has interactive styles or generateElementClass flag
437
+ return props;
438
+ }
439
+
440
+ /**
441
+ * Apply interactive styles and generate element class
442
+ */
443
+ private applyInteractiveStyles(props: Record<string, unknown>, node: ComponentNode, ctx: BuilderContext): Record<string, unknown> {
767
444
  const nodeInteractiveStyles = (node as any).interactiveStyles as InteractiveStyles | undefined;
768
445
  const nodeGenerateElementClass = (node as any).generateElementClass as boolean | undefined;
769
446
  const nodeTreeNodeName = (node as any).treeNodeName as string | undefined;
770
447
 
771
- if ((nodeInteractiveStyles && nodeInteractiveStyles.length > 0) || nodeGenerateElementClass) {
772
- // Check if node is slot content (page content passed to component slot)
773
- // Slot content should use page context for element class, not component context
774
- const isSlotContent = (node as any)._isSlotContent === true;
775
-
776
- // When inside a component (componentContext is set) and NOT slot content, use component-based class names
777
- // Slot content uses page context since it's defined in the page, not the component
778
- const useComponentContext = componentContext && !isSlotContent;
779
- const fileType = useComponentContext ? 'component' : (this.getCurrentFileType?.() || 'page');
780
- const fileName = useComponentContext ? componentContext : (this.getCurrentPageName ? this.getCurrentPageName() : 'page');
781
- // For component elements, use relative path (from component root) for stable class names
782
- // For slot content and page elements, use full path
783
- const pathForClass = useComponentContext && componentRootPath
784
- ? elementPath.slice(componentRootPath.length)
785
- : elementPath;
786
- const elementClassCtx: ElementClassContext = {
787
- fileType,
788
- fileName,
789
- treeNodeName: nodeTreeNodeName,
790
- path: pathForClass,
791
- };
792
- const elementClass = generateElementClassName(elementClassCtx);
793
-
794
- // Prepend element class to existing classes for proper CSS specificity
795
- const existingClassName = (props.className || '') as string;
796
- props.className = [elementClass, existingClassName].filter(Boolean).join(' ');
797
-
798
- // Register interactive styles for CSS generation
799
- // StyleInjector will read from the registry to generate interactive CSS
800
- if (nodeInteractiveStyles && nodeInteractiveStyles.length > 0) {
801
- // Check if interactive styles contain mappings that need resolution
802
- if (componentResolvedProps && hasInteractiveStyleMappings(nodeInteractiveStyles)) {
803
- // Extract mappings and replace with CSS variable references
804
- const { resolvedStyles, mappings } = extractInteractiveStyleMappings(nodeInteractiveStyles);
805
-
806
- // Resolve CSS variable values from component props
807
- const cssVariables = resolveExtractedMappings(mappings, componentResolvedProps);
808
-
809
- // Store resolved styles (with var() references) for CSS generation
810
- InteractiveStylesRegistry.set(elementClass, resolvedStyles);
448
+ if (!((nodeInteractiveStyles && nodeInteractiveStyles.length > 0) || nodeGenerateElementClass)) {
449
+ return props;
450
+ }
811
451
 
812
- // Store CSS variables for this element (to be applied via ref callback)
813
- if (Object.keys(cssVariables).length > 0) {
814
- InteractiveStylesRegistry.setVariables(elementPath, cssVariables);
815
- }
816
- } else {
817
- // No mappings or no props available - register styles as-is
818
- InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
452
+ // Check if slot content
453
+ const isSlotContent = (node as any)._isSlotContent === true;
454
+ const useComponentContext = ctx.componentContext && !isSlotContent;
455
+ const fileType = useComponentContext ? 'component' : (this.getCurrentFileType?.() || 'page');
456
+ const fileName = useComponentContext ? ctx.componentContext! : (this.getCurrentPageName ? this.getCurrentPageName() : 'page');
457
+ const pathForClass = useComponentContext && ctx.componentRootPath
458
+ ? ctx.elementPath.slice(ctx.componentRootPath.length)
459
+ : ctx.elementPath;
460
+
461
+ const elementClassCtx: ElementClassContext = {
462
+ fileType,
463
+ fileName,
464
+ treeNodeName: nodeTreeNodeName,
465
+ path: pathForClass,
466
+ };
467
+ const elementClass = generateElementClassName(elementClassCtx);
468
+
469
+ // Prepend element class
470
+ const existingClassName = (props.className || '') as string;
471
+ const result = { ...props, className: [elementClass, existingClassName].filter(Boolean).join(' ') };
472
+
473
+ // Register interactive styles
474
+ if (nodeInteractiveStyles && nodeInteractiveStyles.length > 0) {
475
+ if (ctx.componentResolvedProps && hasInteractiveStyleMappings(nodeInteractiveStyles)) {
476
+ const { resolvedStyles, mappings } = extractInteractiveStyleMappings(nodeInteractiveStyles);
477
+ const cssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
478
+ InteractiveStylesRegistry.set(elementClass, resolvedStyles);
479
+ if (Object.keys(cssVariables).length > 0) {
480
+ InteractiveStylesRegistry.setVariables(ctx.elementPath, cssVariables);
819
481
  }
482
+ } else {
483
+ InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
820
484
  }
821
485
  }
822
486
 
823
- // Extract attributes from node and merge into props
824
- // Attributes override props with the same key
825
- // Note: Internal props were already filtered above, so attributes like type="text" are preserved
487
+ return result;
488
+ }
489
+
490
+ /**
491
+ * Extract and merge attributes from node
492
+ */
493
+ private mergeAttributes(props: Record<string, unknown>, node: ComponentNode, ctx: BuilderContext): Record<string, unknown> {
826
494
  let extractedAttributes = extractAttributesFromNode(node);
827
495
 
828
- // Process item templates in attributes (for CMSList context)
829
- // Use templateContext if available (for named contexts), fall back to itemContext for legacy support
496
+ // Process item templates in attributes
497
+ const effectiveItemContext = ctx.templateContext || ctx.itemContext;
830
498
  if (effectiveItemContext && Object.keys(extractedAttributes).length > 0) {
831
- const effectiveLocale = cmsLocale || locale;
832
- const config = i18nConfig || DEFAULT_I18N_CONFIG;
499
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
500
+ const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
833
501
  const i18nResolver: ValueResolver | undefined = effectiveLocale
834
502
  ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
835
503
  : undefined;
836
504
  extractedAttributes = processItemPropsTemplate(extractedAttributes, effectiveItemContext, i18nResolver);
837
505
  }
838
506
 
839
- if (Object.keys(extractedAttributes).length > 0) {
840
- // Handle class/className specially - merge with utility classes instead of replacing
841
- if (extractedAttributes.class || extractedAttributes.className) {
842
- const attrClass = (extractedAttributes.class || extractedAttributes.className || '') as string;
843
- const existingClassName = (props.className || '') as string;
844
-
845
- // Merge: existing utility classes + attribute classes
846
- props.className = [existingClassName, attrClass].filter(Boolean).join(' ');
507
+ if (Object.keys(extractedAttributes).length === 0) {
508
+ return props;
509
+ }
847
510
 
848
- // Remove from attributes to prevent overwrite
849
- delete extractedAttributes.class;
850
- delete extractedAttributes.className;
851
- }
511
+ let result = { ...props };
852
512
 
853
- props = { ...props, ...extractedAttributes };
513
+ // Handle class/className specially
514
+ if (extractedAttributes.class || extractedAttributes.className) {
515
+ const attrClass = (extractedAttributes.class || extractedAttributes.className || '') as string;
516
+ const existingClassName = (result.className || '') as string;
517
+ result.className = [existingClassName, attrClass].filter(Boolean).join(' ');
518
+ delete extractedAttributes.class;
519
+ delete extractedAttributes.className;
854
520
  }
855
521
 
856
- // Check if this is a custom component reference
857
- if (nodeType === NODE_TYPE.COMPONENT && componentName && this.componentRegistry.has(componentName)) {
858
- const componentDef = this.componentRegistry.get(componentName);
859
-
860
- if (!componentDef) return null;
861
-
862
- try {
863
- // Extract structured component definition
864
- const structuredComponentDef = componentDef.component;
865
- if (!structuredComponentDef) {
866
- return h(ErrorBoundary, {
867
- key,
868
- componentName: componentName,
869
- level: 'component',
870
- }, null);
871
- }
872
-
873
- // Resolve props with defaults from interface definition (with i18n support)
874
- // Use nodeProps (original extracted props) instead of filtered props
875
- // Filtering is for HTML DOM attributes, not component prop templates
876
- let resolvedProps = resolvePropsFromDefinition(
877
- structuredComponentDef,
878
- nodeProps,
879
- children,
880
- locale,
881
- i18nConfig
882
- );
883
-
884
- // Process CMS templates in resolved component props
885
- if (cmsContext && Object.keys(resolvedProps).length > 0) {
886
- resolvedProps = processCMSPropsTemplate(resolvedProps, cmsContext, cmsLocale || locale);
887
- }
522
+ return { ...result, ...extractedAttributes };
523
+ }
888
524
 
889
- // Process item templates in resolved component props (for CMSList context)
890
- // Use templateContext if available (for named contexts), fall back to itemContext for legacy support
891
- if (effectiveItemContext && Object.keys(resolvedProps).length > 0) {
892
- const effectiveLocale = cmsLocale || locale;
893
- const config = i18nConfig || DEFAULT_I18N_CONFIG;
894
- const i18nResolver: ValueResolver | undefined = effectiveLocale
895
- ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
896
- : undefined;
897
- resolvedProps = processItemPropsTemplate(resolvedProps, effectiveItemContext, i18nResolver);
898
- }
525
+ /**
526
+ * Build a custom component
527
+ */
528
+ private buildCustomComponent(
529
+ componentName: string,
530
+ nodeProps: Record<string, unknown>,
531
+ children: unknown,
532
+ finalProps: Record<string, unknown>,
533
+ options: BuildComponentOptions
534
+ ): ReactElement | null {
535
+ const {
536
+ key = 0, elementPath = [0], parentComponentName = null, viewportWidth = 1920,
537
+ componentContext = null, locale, i18nConfig, cmsContext = null, cmsLocale = null,
538
+ collectionItemsMap = {}, itemContext = null, cmsItemIndexPath = null,
539
+ templateContext = null
540
+ } = options;
899
541
 
900
- // Process the structure with resolved props
901
- // processStructure now handles structure.style and merges into props.style
902
- // Pass viewportWidth to respect responsive breakpoints in preview
903
- // Pass instance children to replace { type: "children" } markers
904
- // Mark children as slot content so they use page context for element class generation
905
- const markedChildren = children ? markAsSlotContent(children) : undefined;
906
- const processedStructure = processStructure(
907
- structuredComponentDef.structure,
908
- { props: resolvedProps, componentDef: structuredComponentDef },
909
- viewportWidth,
910
- markedChildren
911
- );
912
-
913
- // Type guard: ensure processedStructure is a ComponentNode
914
- if (!processedStructure || typeof processedStructure === 'string' || typeof processedStructure === 'number' || Array.isArray(processedStructure)) {
915
- return null;
916
- }
917
-
918
- // Merge instance style overrides (from component instance props) with structure styles
919
- if (props.style && typeof props.style === 'object') {
920
- mergeNodeStyles(processedStructure, props.style as StyleValue, viewportWidth);
921
- }
922
-
923
- // Add event handlers to props (only for component instances)
924
- if (props.onClick && isComponentNode(processedStructure)) {
925
- if (!processedStructure.props) {
926
- processedStructure.props = {};
927
- }
928
- processedStructure.props.onClick = this.createOnClickHandler(props.onClick);
929
- }
930
-
931
- // Build the component instance's structure with proper component context tracking
932
- // Elements inside this component will have parentComponentName set via closure variables
933
- // Component root will have props stored in registry (no DOM attributes needed)
934
- const nestedParentComponentName = this.getParentComponentNameForNestedComponent(
935
- componentContext,
936
- parentComponentName,
937
- componentName
938
- );
939
-
940
- const componentElement = this.buildComponent({
941
- node: processedStructure,
942
- key,
943
- customProps: {
944
- // Store resolved props in a special key to pass to registry (not added to DOM)
945
- '__componentProps': resolvedProps
946
- },
947
- elementPath,
948
- parentComponentName: nestedParentComponentName,
949
- viewportWidth,
950
- componentContext: componentName,
951
- // Set componentRootPath to track where this component starts for relative path calculation
952
- componentRootPath: elementPath,
953
- locale,
954
- i18nConfig,
955
- cmsContext,
956
- cmsLocale,
957
- collectionItemsMap,
958
- itemContext,
959
- cmsItemIndexPath,
960
- // Pass resolved props for interactive style mapping resolution
961
- componentResolvedProps: resolvedProps
962
- });
963
- return h(ErrorBoundary, {
964
- key,
965
- componentName: componentName,
966
- level: 'component',
967
- }, componentElement);
968
-
969
- } catch (error) {
970
- return h(ErrorBoundary, {
971
- key,
972
- componentName: tag,
973
- level: 'component',
974
- }, null);
542
+ const componentDef = this.componentRegistry.get(componentName);
543
+ if (!componentDef) return null;
544
+
545
+ try {
546
+ const structuredComponentDef = componentDef.component;
547
+ if (!structuredComponentDef) {
548
+ return h(ErrorBoundary, { key, componentName, level: 'component' }, null);
975
549
  }
976
- }
977
550
 
978
- // Handle Link component for navigation (only for HTML nodes)
979
- if (tag === 'Link') {
980
- const { to, prefetch: prefetchAttr, ...restProps } = props;
981
- const href = typeof to === 'string' ? to : '#';
551
+ // Resolve props
552
+ let resolvedProps = resolvePropsFromDefinition(
553
+ structuredComponentDef,
554
+ nodeProps,
555
+ children,
556
+ locale,
557
+ i18nConfig
558
+ );
982
559
 
983
- // Use effective parent component name for consistency with regular HTML elements
984
- const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
560
+ // Process templates
561
+ if (cmsContext) {
562
+ resolvedProps = processCMSPropsTemplate(resolvedProps, cmsContext, cmsLocale || locale);
563
+ }
985
564
 
986
- // Navigation click handler
987
- const navigationOnClick = (e: any) => {
988
- e.preventDefault();
989
- if (typeof to === 'string' && to) {
990
- navigateTo(to);
991
- }
992
- };
565
+ const effectiveItemContext = templateContext || itemContext;
566
+ if (effectiveItemContext) {
567
+ const effectiveLocale = cmsLocale || locale;
568
+ const config = i18nConfig || DEFAULT_I18N_CONFIG;
569
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
570
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
571
+ : undefined;
572
+ resolvedProps = processItemPropsTemplate(resolvedProps, effectiveItemContext, i18nResolver);
573
+ }
993
574
 
994
- // Prefetch handlers (only if prefetchService is available and enabled)
995
- const prefetchHandlers: Record<string, unknown> = {};
996
- const isInternalLink = href.startsWith('/');
997
-
998
- if (this.prefetchService?.isEnabled() && isInternalLink) {
999
- const strategy = (prefetchAttr as PrefetchStrategy) || this.prefetchService.getDefaultStrategy();
1000
-
1001
- if (strategy === 'hover') {
1002
- prefetchHandlers.onMouseEnter = () => this.prefetchService?.handleHover(href);
1003
- prefetchHandlers.onMouseLeave = () => this.prefetchService?.handleHoverLeave(href);
1004
- prefetchHandlers.onFocus = () => this.prefetchService?.handleHover(href);
1005
- prefetchHandlers.onBlur = () => this.prefetchService?.handleHoverLeave(href);
1006
- } else if (strategy === 'tap') {
1007
- prefetchHandlers.onTouchStart = () => this.prefetchService?.handleTap(href);
1008
- prefetchHandlers.onMouseDown = () => this.prefetchService?.handleTap(href);
1009
- }
1010
- // viewport and load strategies are handled via ref callback below
575
+ // Process structure
576
+ const markedChildren = children ? markAsSlotContent(children) : undefined;
577
+ const processedStructure = processStructure(
578
+ structuredComponentDef.structure,
579
+ { props: resolvedProps, componentDef: structuredComponentDef },
580
+ viewportWidth,
581
+ markedChildren
582
+ );
583
+
584
+ if (!processedStructure || typeof processedStructure === 'string' || typeof processedStructure === 'number' || Array.isArray(processedStructure)) {
585
+ return null;
586
+ }
587
+
588
+ // Merge styles
589
+ if (finalProps.style && typeof finalProps.style === 'object') {
590
+ mergeNodeStyles(processedStructure, finalProps.style as StyleValue, viewportWidth);
1011
591
  }
1012
592
 
1013
- return h('a', {
1014
- ...restProps,
593
+ // Add onClick
594
+ if (finalProps.onClick && isComponentNode(processedStructure)) {
595
+ if (!processedStructure.props) processedStructure.props = {};
596
+ processedStructure.props.onClick = this.createOnClickHandler(finalProps.onClick);
597
+ }
598
+
599
+ const nestedParentComponentName = this.getParentComponentNameForNestedComponent(
600
+ componentContext,
601
+ parentComponentName,
602
+ componentName
603
+ );
604
+
605
+ const componentElement = this.buildComponent({
606
+ node: processedStructure,
1015
607
  key,
1016
- href,
1017
- // Add data attributes for selection overlay
1018
- 'data-element-path': pathToString(elementPath),
1019
- // Add prefetch strategy attribute if specified
1020
- ...(prefetchAttr && { 'data-prefetch': prefetchAttr }),
1021
- // Add parent component context
1022
- ...(effectiveParentComponentName && { 'data-parent-component': effectiveParentComponentName }),
1023
- // Add current component context (when inside component definition)
1024
- ...(componentContext && { 'data-component-context': componentContext }),
1025
- ref: (el: HTMLElement | null) => {
1026
- const pathKey = pathToString(elementPath);
1027
-
1028
- // Cleanup when element is removed from DOM
1029
- if (!el) {
1030
- this.prefetchService?.unobserveLinkByPath(pathKey);
1031
- }
608
+ customProps: { '__componentProps': resolvedProps },
609
+ elementPath,
610
+ parentComponentName: nestedParentComponentName,
611
+ viewportWidth,
612
+ componentContext: componentName,
613
+ componentRootPath: elementPath,
614
+ locale,
615
+ i18nConfig,
616
+ cmsContext,
617
+ cmsLocale,
618
+ collectionItemsMap,
619
+ itemContext,
620
+ cmsItemIndexPath,
621
+ componentResolvedProps: resolvedProps
622
+ });
1032
623
 
1033
- this.elementRegistry.register(elementPath, el, effectiveParentComponentName, false);
624
+ return h(ErrorBoundary, { key, componentName, level: 'component' }, componentElement);
1034
625
 
1035
- // Handle viewport strategy - observe link when it enters DOM
1036
- if (el && this.prefetchService?.isEnabled() && isInternalLink) {
1037
- const strategy = (prefetchAttr as PrefetchStrategy) || this.prefetchService.getDefaultStrategy();
1038
- if (strategy === 'viewport') {
1039
- this.prefetchService.observeLink(el as HTMLAnchorElement, pathKey);
1040
- }
1041
- }
1042
- },
1043
- onClick: navigationOnClick,
1044
- ...prefetchHandlers,
1045
- style: {
1046
- textDecoration: 'none',
1047
- color: '#0070f3',
1048
- cursor: 'pointer',
1049
- ...(props.style && typeof props.style === 'object' ? props.style : {}),
1050
- }
1051
- }, this.buildChildren(children, {
1052
- elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
1053
- locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
1054
- }));
626
+ } catch (error) {
627
+ return h(ErrorBoundary, { key, componentName, level: 'component' }, null);
1055
628
  }
629
+ }
630
+
631
+ /**
632
+ * Build a regular HTML element
633
+ */
634
+ private buildHtmlElement(
635
+ tag: string | undefined,
636
+ props: Record<string, unknown>,
637
+ children: unknown,
638
+ customProps: ComponentProps,
639
+ options: BuildComponentOptions
640
+ ): ReactElement | null {
641
+ const {
642
+ key = 0, elementPath = [0], parentComponentName = null, viewportWidth = 1920,
643
+ componentContext = null, componentRootPath, locale, i18nConfig,
644
+ cmsContext = null, cmsLocale = null, collectionItemsMap = {},
645
+ itemContext = null, cmsItemIndexPath = null, templateContext = null,
646
+ componentResolvedProps = null
647
+ } = options;
1056
648
 
1057
- // Prepare element props
1058
- // Remove __componentProps from customProps before spreading (it's only for registry, not DOM)
649
+ const finalTag = tag || 'div';
650
+ const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
651
+
652
+ // Remove __componentProps from customProps before spreading
1059
653
  const { __componentProps, ...customPropsForDOM } = customProps || {};
1060
654
  const isComponentRoot = !!(customProps && '__componentProps' in customProps);
655
+
1061
656
  const processedProps: Record<string, unknown> = {
1062
657
  ...props,
1063
658
  ...customPropsForDOM,
1064
659
  key
1065
- // Note: Data attributes moved to ref callback to avoid rerenders on path changes
1066
660
  };
1067
661
 
1068
- // Register element in registry and set data attributes via DOM (not React props)
1069
- // Setting attributes in ref callback avoids React rerenders when paths change during drag/reorder
662
+ // Register element via ref callback
1070
663
  processedProps.ref = (el: HTMLElement | null) => {
1071
664
  if (el) {
1072
- // Set data attributes via DOM (not React props) to avoid rerenders on path changes
1073
665
  el.setAttribute('data-element-path', pathToString(elementPath));
1074
666
  if (cmsItemIndexPath && cmsItemIndexPath.length > 0) {
1075
667
  el.setAttribute('data-cms-item-index', cmsItemIndexPath.join('.'));
@@ -1078,7 +670,7 @@ export class ComponentBuilder {
1078
670
  if (parentComponentName) el.setAttribute('data-parent-component', parentComponentName);
1079
671
  if (componentContext) el.setAttribute('data-component-context', componentContext);
1080
672
 
1081
- // Add defineVars data attributes for JS prop injection (mirrors SSR behavior)
673
+ // Add defineVars data attributes
1082
674
  if (isComponentRoot && componentContext) {
1083
675
  const componentDef = this.componentRegistry.get(componentContext);
1084
676
  const defineVars = componentDef?.component?.defineVars;
@@ -1100,8 +692,7 @@ export class ComponentBuilder {
1100
692
  }
1101
693
  }
1102
694
 
1103
- // Apply CSS variables for interactive style mappings
1104
- // These are resolved per-instance based on component props
695
+ // Apply CSS variables
1105
696
  const cssVariables = InteractiveStylesRegistry.getVariables(elementPath);
1106
697
  if (cssVariables && Object.keys(cssVariables).length > 0) {
1107
698
  for (const [varName, value] of Object.entries(cssVariables)) {
@@ -1110,14 +701,11 @@ export class ComponentBuilder {
1110
701
  }
1111
702
  }
1112
703
 
1113
- // Extract props if this is a component root
1114
704
  const componentProps = isComponentRoot && customProps.__componentProps
1115
705
  ? customProps.__componentProps as Record<string, unknown>
1116
706
  : undefined;
1117
- // Use componentContext as component name for component roots
1118
707
  const componentNameForRegistry = isComponentRoot ? componentContext : parentComponentName;
1119
708
 
1120
- // Build full metadata for registry
1121
709
  const metadata = {
1122
710
  parentComponentName: parentComponentName ?? null,
1123
711
  componentContext: componentContext ?? null
@@ -1125,35 +713,25 @@ export class ComponentBuilder {
1125
713
 
1126
714
  this.elementRegistry.register(elementPath, el, componentNameForRegistry, isComponentRoot, componentProps, metadata);
1127
715
  };
1128
-
1129
- // Add onClick handler - either custom or selection handler
1130
- const originalOnClick = props.onClick ? this.createOnClickHandler(props.onClick) : undefined;
1131
716
 
1132
- // Calculate effective parent component name before using it in onClick handler
1133
- const effectiveParentComponentName = this.getEffectiveParentComponentName(componentContext, parentComponentName);
1134
-
1135
- // Add onClick handler from props if exists
1136
- if (originalOnClick) {
1137
- processedProps.onClick = originalOnClick;
717
+ // Add onClick handler
718
+ if (props.onClick) {
719
+ processedProps.onClick = this.createOnClickHandler(props.onClick);
1138
720
  }
1139
721
 
1140
- // For HTML elements, use tag; components and embed nodes should not reach here (they're handled above)
1141
- const finalTag = tag || 'div';
1142
-
1143
- // HTML void elements cannot have children
722
+ // HTML void elements
1144
723
  const VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'];
1145
724
  if (VOID_ELEMENTS.includes(finalTag.toLowerCase())) {
1146
- // Ensure no children prop is passed to void elements
1147
- const { children: _ignoredChildren, ...voidElementProps } = processedProps;
725
+ const { children: _ignored, ...voidElementProps } = processedProps;
1148
726
  return h(finalTag, voidElementProps);
1149
727
  }
1150
728
 
1151
- // Build children recursively with proper component context
1152
- const childElements = this.buildChildren(children, {
729
+ // Build children
730
+ const childElements = this.buildChildren(children as any, {
1153
731
  elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
1154
732
  locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
1155
733
  });
734
+
1156
735
  return h(finalTag, processedProps, childElements);
1157
736
  }
1158
737
  }
1159
-