meno-core 1.0.21 → 1.0.23

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 (59) hide show
  1. package/build-static.test.ts +424 -0
  2. package/build-static.ts +100 -13
  3. package/lib/client/ClientInitializer.ts +4 -0
  4. package/lib/client/core/ComponentBuilder.ts +155 -16
  5. package/lib/client/core/builders/embedBuilder.ts +48 -6
  6. package/lib/client/core/builders/linkBuilder.ts +2 -2
  7. package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
  8. package/lib/client/core/builders/listBuilder.ts +12 -3
  9. package/lib/client/routing/Router.tsx +8 -1
  10. package/lib/client/templateEngine.ts +89 -98
  11. package/lib/server/__integration__/api-routes.test.ts +148 -0
  12. package/lib/server/__integration__/cms-integration.test.ts +161 -0
  13. package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
  14. package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
  15. package/lib/server/__integration__/static-assets.test.ts +80 -0
  16. package/lib/server/__integration__/test-helpers.ts +205 -0
  17. package/lib/server/ab/generateFunctions.ts +346 -0
  18. package/lib/server/ab/trackingScript.ts +45 -0
  19. package/lib/server/index.ts +2 -2
  20. package/lib/server/jsonLoader.ts +124 -46
  21. package/lib/server/routes/api/cms.ts +3 -2
  22. package/lib/server/routes/api/components.ts +13 -2
  23. package/lib/server/services/cmsService.ts +0 -5
  24. package/lib/server/services/componentService.ts +255 -29
  25. package/lib/server/services/configService.test.ts +950 -0
  26. package/lib/server/services/configService.ts +39 -0
  27. package/lib/server/services/index.ts +1 -1
  28. package/lib/server/ssr/htmlGenerator.test.ts +992 -0
  29. package/lib/server/ssr/htmlGenerator.ts +3 -3
  30. package/lib/server/ssr/imageMetadata.test.ts +168 -0
  31. package/lib/server/ssr/imageMetadata.ts +58 -0
  32. package/lib/server/ssr/jsCollector.test.ts +287 -0
  33. package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
  34. package/lib/server/ssr/ssrRenderer.ts +131 -15
  35. package/lib/shared/constants.ts +3 -0
  36. package/lib/shared/fontLoader.test.ts +335 -0
  37. package/lib/shared/i18n.test.ts +106 -0
  38. package/lib/shared/i18n.ts +17 -11
  39. package/lib/shared/index.ts +3 -0
  40. package/lib/shared/itemTemplateUtils.ts +43 -1
  41. package/lib/shared/libraryLoader.test.ts +392 -0
  42. package/lib/shared/linkUtils.ts +24 -0
  43. package/lib/shared/nodeUtils.test.ts +100 -0
  44. package/lib/shared/nodeUtils.ts +43 -0
  45. package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
  46. package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
  47. package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
  48. package/lib/shared/richtext/htmlToTiptap.ts +46 -2
  49. package/lib/shared/richtext/tiptapToHtml.ts +65 -0
  50. package/lib/shared/richtext/types.ts +4 -1
  51. package/lib/shared/types/cms.ts +2 -0
  52. package/lib/shared/types/components.ts +12 -3
  53. package/lib/shared/types/experiments.ts +55 -0
  54. package/lib/shared/types/index.ts +10 -0
  55. package/lib/shared/utils.ts +2 -6
  56. package/lib/shared/validation/propValidator.test.ts +50 -0
  57. package/lib/shared/validation/propValidator.ts +2 -2
  58. package/lib/shared/validation/schemas.ts +10 -2
  59. package/package.json +1 -1
@@ -4,7 +4,7 @@
4
4
  * responsive styles, event handlers, and element registration.
5
5
  */
6
6
 
7
- import { createElement as h } from "react";
7
+ import { createElement as h, Fragment } from "react";
8
8
  import type { ReactElement } from "react";
9
9
 
10
10
  // Component registry and utilities
@@ -15,6 +15,7 @@ import { ErrorBoundary } from "../ErrorBoundary";
15
15
  import type { ComponentNode, StyleValue } from "../../shared/types";
16
16
  import { isComponentNode, extractNodeProperties, isSlotMarker, isEmbedNode, isLinkNode, isLocaleListNode, isListNode, markAsSlotContent, evaluateNodeIf, isBooleanMapping } from "../../shared/nodeUtils";
17
17
  import { mergeNodeStyles } from "../../shared/styleNodeUtils";
18
+ import { isCurrentLink } from "../../shared/linkUtils";
18
19
  import { extractAttributesFromNode } from "../../shared/attributeNodeUtils";
19
20
  import { resolvePropsFromDefinition } from "../../shared/propResolver";
20
21
  import { ElementRegistry } from "../elementRegistry";
@@ -56,6 +57,8 @@ export interface ComponentBuilderConfig {
56
57
  getCurrentPageName?: () => string;
57
58
  /** Optional getter for current file type (for interactive styles class generation) */
58
59
  getCurrentFileType?: () => 'page' | 'component';
60
+ /** Optional getter for current page URL path (for is-current class on link nodes) */
61
+ getCurrentPagePath?: () => string;
59
62
  }
60
63
 
61
64
  // Re-export types for backward compatibility
@@ -70,6 +73,7 @@ export class ComponentBuilder {
70
73
  private prefetchService?: PrefetchService;
71
74
  private getCurrentPageName?: () => string;
72
75
  private getCurrentFileType?: () => 'page' | 'component';
76
+ private getCurrentPagePath?: () => string;
73
77
  // Cache for utility classes computed from style objects (avoids recomputation for same styles)
74
78
  private styleClassCache: WeakMap<object, string[]> = new WeakMap();
75
79
 
@@ -79,6 +83,7 @@ export class ComponentBuilder {
79
83
  this.prefetchService = config.prefetchService;
80
84
  this.getCurrentPageName = config.getCurrentPageName;
81
85
  this.getCurrentFileType = config.getCurrentFileType;
86
+ this.getCurrentPagePath = config.getCurrentPagePath;
82
87
  }
83
88
 
84
89
  /**
@@ -306,6 +311,7 @@ export class ComponentBuilder {
306
311
  getEffectiveParentComponentName: this.getEffectiveParentComponentName.bind(this),
307
312
  getCurrentPageName: this.getCurrentPageName,
308
313
  getCurrentFileType: this.getCurrentFileType,
314
+ getCurrentPagePath: this.getCurrentPagePath,
309
315
  };
310
316
 
311
317
  // Handle text nodes - process CMS templates and item templates
@@ -362,7 +368,7 @@ export class ComponentBuilder {
362
368
  const processedProps = this.processPropsTemplates(props, ctx);
363
369
 
364
370
  // Convert styles to utility classes
365
- const propsWithClasses = this.applyStyleClasses(processedProps, node);
371
+ const propsWithClasses = this.applyStyleClasses(processedProps, node, ctx);
366
372
 
367
373
  // Apply interactive styles
368
374
  const propsWithInteractive = this.applyInteractiveStyles(propsWithClasses, node, ctx);
@@ -375,6 +381,30 @@ export class ComponentBuilder {
375
381
  return this.buildCustomComponent(componentName, nodeProps, children, finalProps, options);
376
382
  }
377
383
 
384
+ // Component not found in registry - warn and show placeholder
385
+ if (nodeType === NODE_TYPE.COMPONENT && componentName) {
386
+ if (process.env.NODE_ENV !== 'production') {
387
+ console.warn(
388
+ `[Meno] Component "${componentName}" not found in registry. ` +
389
+ `Registered: [${this.componentRegistry.getNames().join(', ')}]`
390
+ );
391
+ }
392
+ return h('div', {
393
+ key,
394
+ style: {
395
+ border: '1px dashed #e5a00d',
396
+ padding: '8px 12px',
397
+ margin: '4px 0',
398
+ borderRadius: '4px',
399
+ color: '#b8860b',
400
+ fontSize: '13px',
401
+ fontFamily: 'monospace',
402
+ background: '#fffbe6',
403
+ },
404
+ 'data-missing-component': componentName,
405
+ }, `Component not found: ${componentName}`);
406
+ }
407
+
378
408
  // Handle Link component
379
409
  if (tag === 'Link') {
380
410
  return buildLink(finalProps, children, ctx, builderDeps);
@@ -385,6 +415,90 @@ export class ComponentBuilder {
385
415
  return this.buildHtmlElement(tag, finalProps, children, customProps, htmlElementOptions);
386
416
  }
387
417
 
418
+ /**
419
+ * Expand Meno component markers in rich-text HTML into rendered React components.
420
+ * Mirrors the SSR expandRichTextComponents() function but produces React elements
421
+ * instead of HTML strings.
422
+ */
423
+ private expandRichTextComponents(html: string, ctx: BuilderContext): ReactElement {
424
+ // Quick bail-out if no component markers
425
+ if (!html.includes('data-meno-component')) {
426
+ return h('span', { dangerouslySetInnerHTML: { __html: html } });
427
+ }
428
+
429
+ const markerRegex = /<div\s+data-meno-component="([^"]+)"\s+data-meno-props="([^"]*)"[^>]*><\/div>/g;
430
+ const segments: ReactElement[] = [];
431
+ let lastIndex = 0;
432
+ let match: RegExpExecArray | null;
433
+ let segmentIndex = 0;
434
+
435
+ while ((match = markerRegex.exec(html)) !== null) {
436
+ // Add HTML before this match
437
+ if (match.index > lastIndex) {
438
+ const chunk = html.slice(lastIndex, match.index);
439
+ segments.push(h('span', { key: `rt-${segmentIndex++}`, dangerouslySetInnerHTML: { __html: chunk } }));
440
+ }
441
+
442
+ const componentName = match[1];
443
+ let props: Record<string, unknown> = {};
444
+ try {
445
+ const propsStr = match[2]
446
+ .replace(/&quot;/g, '"')
447
+ .replace(/&#039;/g, "'")
448
+ .replace(/&amp;/g, '&');
449
+ props = JSON.parse(propsStr);
450
+ } catch {
451
+ // ignore parse errors
452
+ }
453
+
454
+ if (this.componentRegistry.has(componentName)) {
455
+ const componentNode: ComponentNode = {
456
+ type: NODE_TYPE.COMPONENT as 'component',
457
+ component: componentName,
458
+ props,
459
+ };
460
+
461
+ const rendered = this.buildComponent({
462
+ node: componentNode,
463
+ key: segmentIndex,
464
+ customProps: {},
465
+ elementPath: ctx.elementPath,
466
+ parentComponentName: ctx.parentComponentName,
467
+ viewportWidth: ctx.viewportWidth,
468
+ componentContext: ctx.componentContext,
469
+ componentRootPath: ctx.componentRootPath,
470
+ locale: ctx.locale,
471
+ i18nConfig: ctx.i18nConfig,
472
+ cmsContext: ctx.cmsContext,
473
+ cmsLocale: ctx.cmsLocale,
474
+ collectionItemsMap: ctx.collectionItemsMap || {},
475
+ itemContext: ctx.itemContext,
476
+ cmsItemIndexPath: ctx.cmsItemIndexPath,
477
+ cmsListPaths: ctx.cmsListPaths,
478
+ templateContext: ctx.templateContext,
479
+ componentResolvedProps: ctx.componentResolvedProps,
480
+ });
481
+
482
+ if (rendered !== null) {
483
+ segments.push(rendered as ReactElement);
484
+ }
485
+ } else {
486
+ // Keep marker as-is for unregistered components
487
+ segments.push(h('span', { key: `rt-${segmentIndex}`, dangerouslySetInnerHTML: { __html: match[0] } }));
488
+ }
489
+
490
+ segmentIndex++;
491
+ lastIndex = match.index + match[0].length;
492
+ }
493
+
494
+ // Add remaining HTML
495
+ if (lastIndex < html.length) {
496
+ segments.push(h('span', { key: `rt-${segmentIndex}`, dangerouslySetInnerHTML: { __html: html.slice(lastIndex) } }));
497
+ }
498
+
499
+ return h(Fragment, null, ...segments);
500
+ }
501
+
388
502
  /**
389
503
  * Process text node with CMS and item templates
390
504
  * Returns a ReactElement with dangerouslySetInnerHTML for raw HTML content (rich-text),
@@ -412,7 +526,7 @@ export class ComponentBuilder {
412
526
  // Check for raw HTML marker (from rich-text fields) - render with dangerouslySetInnerHTML
413
527
  if (result.startsWith(RAW_HTML_PREFIX)) {
414
528
  const rawHtml = result.slice(RAW_HTML_PREFIX.length);
415
- return h('span', { dangerouslySetInnerHTML: { __html: rawHtml } });
529
+ return this.expandRichTextComponents(rawHtml, ctx);
416
530
  }
417
531
 
418
532
  return result;
@@ -502,11 +616,22 @@ export class ComponentBuilder {
502
616
  /**
503
617
  * Apply style classes to props
504
618
  */
505
- private applyStyleClasses(props: Record<string, unknown>, node: ComponentNode): Record<string, unknown> {
619
+ private applyStyleClasses(props: Record<string, unknown>, node: ComponentNode, ctx: BuilderContext): Record<string, unknown> {
506
620
  const nodeStyle = node.style || (isComponentNode(node) ? node.props?.style : undefined);
507
621
 
508
622
  if (nodeStyle && typeof nodeStyle === 'object') {
509
- const utilityClasses = this.getCachedStyleClasses(nodeStyle);
623
+ // Process item templates in style values (for List context)
624
+ let processedStyle = nodeStyle as Record<string, unknown>;
625
+ const templateCtx = ctx.templateContext || ctx.itemContext;
626
+ if (templateCtx) {
627
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
628
+ const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
629
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
630
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
631
+ : undefined;
632
+ processedStyle = processItemPropsTemplate(processedStyle, templateCtx, i18nResolver);
633
+ }
634
+ const utilityClasses = this.getCachedStyleClasses(processedStyle);
510
635
  if (utilityClasses.length > 0) {
511
636
  const existingClassName = (props.className || '') as string;
512
637
  const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
@@ -651,20 +776,16 @@ export class ComponentBuilder {
651
776
  return h(ErrorBoundary, { key, componentName, level: 'component' }, null);
652
777
  }
653
778
 
654
- // Resolve props
655
- let resolvedProps = resolvePropsFromDefinition(
656
- structuredComponentDef,
657
- nodeProps,
658
- children as ComponentNode | string | (ComponentNode | string)[] | null | undefined,
659
- locale,
660
- i18nConfig
661
- );
779
+ // Process templates on raw nodeProps BEFORE coercion/defaults
780
+ // This matches the SSR path where templates are resolved before resolvePropsFromDefinition
781
+ let propsToResolve = nodeProps;
662
782
 
663
- // Process templates
783
+ // CMS templates first (matching SSR: ssrRenderer.ts processes CMS before coercion)
664
784
  if (cmsContext) {
665
- resolvedProps = processCMSPropsTemplate(resolvedProps, cmsContext, cmsLocale || locale);
785
+ propsToResolve = processCMSPropsTemplate(propsToResolve, cmsContext, cmsLocale || locale);
666
786
  }
667
787
 
788
+ // Item templates second
668
789
  const effectiveItemContext = templateContext || itemContext;
669
790
  if (effectiveItemContext) {
670
791
  const effectiveLocale = cmsLocale || locale;
@@ -672,9 +793,18 @@ export class ComponentBuilder {
672
793
  const i18nResolver: ValueResolver | undefined = effectiveLocale
673
794
  ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
674
795
  : undefined;
675
- resolvedProps = processItemPropsTemplate(resolvedProps, effectiveItemContext, i18nResolver);
796
+ propsToResolve = processItemPropsTemplate(propsToResolve, effectiveItemContext, i18nResolver);
676
797
  }
677
798
 
799
+ // Resolve props (validate, coerce types, apply defaults) AFTER templates are resolved
800
+ let resolvedProps = resolvePropsFromDefinition(
801
+ structuredComponentDef,
802
+ propsToResolve,
803
+ children as ComponentNode | string | (ComponentNode | string)[] | null | undefined,
804
+ locale,
805
+ i18nConfig
806
+ );
807
+
678
808
  // Process structure
679
809
  const typedChildren = children as ComponentNode | ComponentNode[] | string | number | null | undefined;
680
810
  const markedChildren = typedChildren ? markAsSlotContent(typedChildren) : undefined;
@@ -766,6 +896,15 @@ export class ComponentBuilder {
766
896
  key
767
897
  };
768
898
 
899
+ // Add is-current class for <a> tags when href matches current page path
900
+ if (finalTag === 'a' && this.getCurrentPagePath) {
901
+ const href = processedProps.href as string | undefined;
902
+ if (href && typeof href === 'string' && isCurrentLink(href, this.getCurrentPagePath())) {
903
+ const existing = (processedProps.className || '') as string;
904
+ processedProps.className = existing ? `${existing} is-current` : 'is-current';
905
+ }
906
+ }
907
+
769
908
  // Register element via ref callback
770
909
  processedProps.ref = (el: HTMLElement | null) => {
771
910
  if (el) {
@@ -15,7 +15,9 @@ import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveEx
15
15
  import DOMPurify from "isomorphic-dompurify";
16
16
  import type { ElementRegistry } from "../../elementRegistry";
17
17
  import type { BuilderContext } from "./types";
18
- import { hasItemTemplates, processItemTemplate } from "../../../shared/itemTemplateUtils";
18
+ import { hasItemTemplates, processItemTemplate, processItemPropsTemplate, type ValueResolver } from "../../../shared/itemTemplateUtils";
19
+ import { resolveI18nValue, DEFAULT_I18N_CONFIG } from "../../../shared/i18n";
20
+ import { processCMSTemplate } from "../cmsTemplateProcessor";
19
21
 
20
22
  export interface EmbedBuilderDeps {
21
23
  elementRegistry: ElementRegistry;
@@ -36,7 +38,7 @@ export interface EmbedBuilderDeps {
36
38
  */
37
39
  const SANITIZE_CONFIG = {
38
40
  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'],
39
- 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'],
41
+ 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', 'frameborder', 'allowfullscreen', 'allow', 'title'],
40
42
  KEEP_CONTENT: true
41
43
  };
42
44
 
@@ -55,7 +57,19 @@ export function buildEmbed(
55
57
 
56
58
  // Process item template strings (for List context) - e.g., {{item.icon}}, {{feature.title}}
57
59
  if (ctx.templateContext && hasItemTemplates(htmlContent)) {
58
- htmlContent = processItemTemplate(htmlContent, ctx.templateContext);
60
+ // Create i18n resolver for template processing (matching SSR behavior)
61
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
62
+ const i18nConfig = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
63
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
64
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, i18nConfig)
65
+ : undefined;
66
+ htmlContent = processItemTemplate(htmlContent, ctx.templateContext, i18nResolver);
67
+ }
68
+
69
+ // Process CMS template strings (for CMS page context) - e.g., {{cms.title}}, {{cms.description}}
70
+ if (ctx.cmsContext && htmlContent.includes('{{cms.')) {
71
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
72
+ htmlContent = processCMSTemplate(htmlContent, ctx.cmsContext, effectiveLocale);
59
73
  }
60
74
 
61
75
  // Sanitize HTML with allowlist
@@ -95,7 +109,22 @@ export function buildEmbed(
95
109
 
96
110
  // Convert embed styles to utility classes
97
111
  if (node.style) {
98
- const utilityClasses = responsiveStylesToClasses(node.style as StyleObject | ResponsiveStyleObject);
112
+ // Process item templates in style values (for List context)
113
+ let processedStyle = node.style;
114
+ if (ctx.templateContext) {
115
+ // Create i18n resolver for style template processing (matching SSR behavior)
116
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
117
+ const i18nConfig = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
118
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
119
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, i18nConfig)
120
+ : undefined;
121
+ processedStyle = processItemPropsTemplate(
122
+ node.style as Record<string, unknown>,
123
+ ctx.templateContext,
124
+ i18nResolver
125
+ ) as StyleObject | ResponsiveStyleObject;
126
+ }
127
+ const utilityClasses = responsiveStylesToClasses(processedStyle as StyleObject | ResponsiveStyleObject);
99
128
  classNames.push(...utilityClasses);
100
129
  }
101
130
 
@@ -165,9 +194,22 @@ export function buildEmbed(
165
194
  embedProps.className = classNames.filter(Boolean).join(' ');
166
195
  }
167
196
 
197
+ // Process item templates in extracted attributes (for List context)
198
+ let processedAttributes = extractedAttributes;
199
+ if (ctx.templateContext && Object.keys(extractedAttributes).length > 0) {
200
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
201
+ const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
202
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
203
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
204
+ : undefined;
205
+ processedAttributes = processItemPropsTemplate(
206
+ extractedAttributes, ctx.templateContext, i18nResolver
207
+ ) as Record<string, string | number | boolean>;
208
+ }
209
+
168
210
  // Add extracted attributes (like class, id, data-*, aria-*, etc.)
169
- if (Object.keys(extractedAttributes).length > 0) {
170
- Object.assign(embedProps, extractedAttributes);
211
+ if (Object.keys(processedAttributes).length > 0) {
212
+ Object.assign(embedProps, processedAttributes);
171
213
  }
172
214
 
173
215
  // Add parent component context
@@ -37,7 +37,7 @@ export function buildLink(
37
37
  const {
38
38
  key, elementPath, parentComponentName, viewportWidth, componentContext, componentRootPath,
39
39
  locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap,
40
- itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
40
+ itemContext, cmsItemIndexPath, cmsListPaths, templateContext, componentResolvedProps
41
41
  } = ctx;
42
42
 
43
43
  const { to, prefetch: prefetchAttr, ...restProps } = props;
@@ -131,6 +131,6 @@ export function buildLink(
131
131
  }
132
132
  }, deps.buildChildren(children, {
133
133
  elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
134
- locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
134
+ locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, cmsListPaths, templateContext, componentResolvedProps
135
135
  }));
136
136
  }
@@ -12,6 +12,9 @@ import { pathToString } from "../../../shared/pathArrayUtils";
12
12
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
13
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
14
14
  import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
15
+ import { processItemPropsTemplate, type ValueResolver } from "../../../shared/itemTemplateUtils";
16
+ import { resolveI18nValue, DEFAULT_I18N_CONFIG } from "../../../shared/i18n";
17
+ import { isCurrentLink } from "../../../shared/linkUtils";
15
18
  import type { ElementRegistry } from "../../elementRegistry";
16
19
  import type { BuilderContext, BuildResult, BuildChildrenContext } from "./types";
17
20
 
@@ -29,6 +32,8 @@ export interface LinkNodeBuilderDeps {
29
32
  getCurrentPageName?: () => string;
30
33
  /** Optional getter for current file type (for interactive styles class generation) */
31
34
  getCurrentFileType?: () => 'page' | 'component';
35
+ /** Optional getter for current page path (for is-current class on link nodes) */
36
+ getCurrentPagePath?: () => string;
32
37
  }
33
38
 
34
39
  /**
@@ -43,7 +48,7 @@ export function buildLinkNode(
43
48
  const {
44
49
  key, elementPath, parentComponentName, viewportWidth, componentContext, componentRootPath,
45
50
  locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap,
46
- itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
51
+ itemContext, cmsItemIndexPath, cmsListPaths, templateContext, componentResolvedProps
47
52
  } = ctx;
48
53
 
49
54
  const effectiveParentComponentName = deps.getEffectiveParentComponentName(componentContext, parentComponentName);
@@ -80,7 +85,22 @@ export function buildLinkNode(
80
85
 
81
86
  // Convert link node styles to utility classes
82
87
  if (node.style) {
83
- const utilityClasses = responsiveStylesToClasses(node.style as StyleObject | ResponsiveStyleObject);
88
+ // Process item templates in style values (for List context)
89
+ let processedStyle = node.style;
90
+ if (ctx.templateContext) {
91
+ // Create i18n resolver for style template processing (matching SSR behavior)
92
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
93
+ const i18nConfig = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
94
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
95
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, i18nConfig)
96
+ : undefined;
97
+ processedStyle = processItemPropsTemplate(
98
+ node.style as Record<string, unknown>,
99
+ ctx.templateContext,
100
+ i18nResolver
101
+ ) as StyleObject | ResponsiveStyleObject;
102
+ }
103
+ const utilityClasses = responsiveStylesToClasses(processedStyle as StyleObject | ResponsiveStyleObject);
84
104
  classNames.push(...utilityClasses);
85
105
  }
86
106
 
@@ -145,14 +165,34 @@ export function buildLinkNode(
145
165
  delete extractedAttributes.className;
146
166
  }
147
167
 
168
+ // Add is-current class when link href matches current page path
169
+ if (deps.getCurrentPagePath && typeof node.href === 'string') {
170
+ if (isCurrentLink(node.href, deps.getCurrentPagePath())) {
171
+ classNames.push('is-current');
172
+ }
173
+ }
174
+
148
175
  // Set final className
149
176
  if (classNames.length > 0) {
150
177
  linkNodeProps.className = classNames.filter(Boolean).join(' ');
151
178
  }
152
179
 
180
+ // Process item templates in extracted attributes (for List context)
181
+ let processedAttributes = extractedAttributes;
182
+ if (ctx.templateContext && Object.keys(extractedAttributes).length > 0) {
183
+ const effectiveLocale = ctx.cmsLocale || ctx.locale;
184
+ const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
185
+ const i18nResolver: ValueResolver | undefined = effectiveLocale
186
+ ? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
187
+ : undefined;
188
+ processedAttributes = processItemPropsTemplate(
189
+ extractedAttributes, ctx.templateContext, i18nResolver
190
+ ) as Record<string, string | number | boolean>;
191
+ }
192
+
153
193
  // Add extracted attributes (like class, id, data-*, aria-*, etc.)
154
- if (Object.keys(extractedAttributes).length > 0) {
155
- Object.assign(linkNodeProps, extractedAttributes);
194
+ if (Object.keys(processedAttributes).length > 0) {
195
+ Object.assign(linkNodeProps, processedAttributes);
156
196
  }
157
197
 
158
198
  // Add parent component context
@@ -166,7 +206,7 @@ export function buildLinkNode(
166
206
  // Build children recursively
167
207
  const linkNodeChildren = deps.buildChildren(children, {
168
208
  elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
169
- locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, templateContext, componentResolvedProps
209
+ locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap, itemContext, cmsItemIndexPath, cmsListPaths, templateContext, componentResolvedProps
170
210
  });
171
211
 
172
212
  return h('div', linkNodeProps, linkNodeChildren);
@@ -5,7 +5,7 @@
5
5
  * - CMS collections (sourceType: 'collection')
6
6
  */
7
7
 
8
- import { createElement as h } from "react";
8
+ import { createElement as h, Fragment } from "react";
9
9
  import type { ReactElement } from "react";
10
10
  import type { ListNode } from "../../../shared/registry/nodeTypes/ListNodeType";
11
11
  import type { CMSItem, CMSFilterCondition, CMSSortConfig, CMSFilterOperator } from "../../../shared/types/cms";
@@ -221,8 +221,8 @@ export function buildList(
221
221
  const textColor = isCollectionMode ? '#8b5cf6' : '#3b82f6';
222
222
  const label = isCollectionMode ? 'CMS List' : 'List';
223
223
 
224
- // Use configurable tag (defaults to 'div') - declared early for all return paths
225
- const tag = node.tag || 'div';
224
+ // Use configurable tag (defaults to 'div') - null means fragment mode (no wrapper)
225
+ const tag = node.tag === false ? null : (node.tag || 'div');
226
226
 
227
227
  if (!source && !sourceIsResolved) {
228
228
  // No source - render empty container with placeholder
@@ -239,6 +239,9 @@ export function buildList(
239
239
  textAlign: 'center'
240
240
  }
241
241
  }, `${label}: No source - No items`);
242
+ if (tag === null) {
243
+ return emptyState;
244
+ }
242
245
  return h(tag, containerProps, emptyState);
243
246
  }
244
247
 
@@ -257,6 +260,9 @@ export function buildList(
257
260
  textAlign: 'center'
258
261
  }
259
262
  }, `${label}: ${source || 'resolved'} - No items`);
263
+ if (tag === null) {
264
+ return emptyState;
265
+ }
260
266
  return h(tag, containerProps, emptyState);
261
267
  }
262
268
 
@@ -316,6 +322,9 @@ export function buildList(
316
322
  }
317
323
  }
318
324
 
325
+ if (tag === null) {
326
+ return h(Fragment, null, renderedItems);
327
+ }
319
328
  return h(tag, containerProps, renderedItems);
320
329
  }
321
330
 
@@ -142,6 +142,8 @@ export function Router(props: RouterProps = {}): ReactElement {
142
142
 
143
143
  // Track if initial mount used SSR CMS context (to skip redundant path-based load)
144
144
  const ssrCmsHandledRef = useRef(false);
145
+ // Track if initial load is done (to prevent currentPath effect from firing on mount)
146
+ const initialLoadDoneRef = useRef(false);
145
147
 
146
148
  // Create RouteLoader instance
147
149
  const routeLoader = useRef(new RouteLoader({
@@ -424,7 +426,12 @@ export function Router(props: RouterProps = {}): ReactElement {
424
426
 
425
427
  // Reload when path changes
426
428
  useEffect(() => {
427
- // Skip initial mount if SSR CMS context was handled (template already loading)
429
+ // Skip initial mount - handled by mount effect above
430
+ if (!initialLoadDoneRef.current) {
431
+ initialLoadDoneRef.current = true;
432
+ return;
433
+ }
434
+ // Skip if SSR CMS context was just handled (template already loading)
428
435
  if (ssrCmsHandledRef.current) {
429
436
  ssrCmsHandledRef.current = false;
430
437
  return;