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.
- package/build-static.test.ts +424 -0
- package/build-static.ts +100 -13
- package/lib/client/ClientInitializer.ts +4 -0
- package/lib/client/core/ComponentBuilder.ts +155 -16
- package/lib/client/core/builders/embedBuilder.ts +48 -6
- package/lib/client/core/builders/linkBuilder.ts +2 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
- package/lib/client/core/builders/listBuilder.ts +12 -3
- package/lib/client/routing/Router.tsx +8 -1
- package/lib/client/templateEngine.ts +89 -98
- package/lib/server/__integration__/api-routes.test.ts +148 -0
- package/lib/server/__integration__/cms-integration.test.ts +161 -0
- package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
- package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
- package/lib/server/__integration__/static-assets.test.ts +80 -0
- package/lib/server/__integration__/test-helpers.ts +205 -0
- package/lib/server/ab/generateFunctions.ts +346 -0
- package/lib/server/ab/trackingScript.ts +45 -0
- package/lib/server/index.ts +2 -2
- package/lib/server/jsonLoader.ts +124 -46
- package/lib/server/routes/api/cms.ts +3 -2
- package/lib/server/routes/api/components.ts +13 -2
- package/lib/server/services/cmsService.ts +0 -5
- package/lib/server/services/componentService.ts +255 -29
- package/lib/server/services/configService.test.ts +950 -0
- package/lib/server/services/configService.ts +39 -0
- package/lib/server/services/index.ts +1 -1
- package/lib/server/ssr/htmlGenerator.test.ts +992 -0
- package/lib/server/ssr/htmlGenerator.ts +3 -3
- package/lib/server/ssr/imageMetadata.test.ts +168 -0
- package/lib/server/ssr/imageMetadata.ts +58 -0
- package/lib/server/ssr/jsCollector.test.ts +287 -0
- package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
- package/lib/server/ssr/ssrRenderer.ts +131 -15
- package/lib/shared/constants.ts +3 -0
- package/lib/shared/fontLoader.test.ts +335 -0
- package/lib/shared/i18n.test.ts +106 -0
- package/lib/shared/i18n.ts +17 -11
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.ts +43 -1
- package/lib/shared/libraryLoader.test.ts +392 -0
- package/lib/shared/linkUtils.ts +24 -0
- package/lib/shared/nodeUtils.test.ts +100 -0
- package/lib/shared/nodeUtils.ts +43 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
- package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
- package/lib/shared/richtext/htmlToTiptap.ts +46 -2
- package/lib/shared/richtext/tiptapToHtml.ts +65 -0
- package/lib/shared/richtext/types.ts +4 -1
- package/lib/shared/types/cms.ts +2 -0
- package/lib/shared/types/components.ts +12 -3
- package/lib/shared/types/experiments.ts +55 -0
- package/lib/shared/types/index.ts +10 -0
- package/lib/shared/utils.ts +2 -6
- package/lib/shared/validation/propValidator.test.ts +50 -0
- package/lib/shared/validation/propValidator.ts +2 -2
- package/lib/shared/validation/schemas.ts +10 -2
- 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(/"/g, '"')
|
|
447
|
+
.replace(/'/g, "'")
|
|
448
|
+
.replace(/&/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
|
|
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
|
-
|
|
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
|
-
//
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
//
|
|
783
|
+
// CMS templates first (matching SSR: ssrRenderer.ts processes CMS before coercion)
|
|
664
784
|
if (cmsContext) {
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
170
|
-
Object.assign(embedProps,
|
|
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
|
-
|
|
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(
|
|
155
|
-
Object.assign(linkNodeProps,
|
|
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') -
|
|
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
|
|
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;
|