meno-core 1.0.5 → 1.0.7

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.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Static Site Generation Build Script
3
3
  * Pre-generates HTML files for all pages at build time
4
+ * CSP-compliant: Extracts JavaScript to external files
4
5
  */
5
6
 
6
7
  import { existsSync, readdirSync, mkdirSync, rmSync, statSync, copyFileSync } from "fs";
7
8
  import { writeFile } from "fs/promises";
8
9
  import { join } from "path";
10
+ import { createHash } from "crypto";
9
11
  import {
10
12
  loadJSONFile,
11
13
  loadComponentDirectory,
@@ -14,6 +16,7 @@ import {
14
16
  loadI18nConfig
15
17
  } from "./lib/server/jsonLoader";
16
18
  import { generateSSRHTML } from "./lib/server/ssrRenderer";
19
+ import type { SSRHTMLResult } from "./lib/server/ssr/htmlGenerator";
17
20
  import { projectPaths } from "./lib/server/projectContext";
18
21
  import { loadProjectConfig } from "./lib/shared/fontLoader";
19
22
  import { FileSystemCMSProvider } from "./lib/server/providers/fileSystemCMSProvider";
@@ -22,6 +25,48 @@ import { isI18nValue, resolveI18nValue } from "./lib/shared/i18n";
22
25
  import type { ComponentDefinition, JSONPage, CMSSchema, CMSItem, I18nConfig } from "./lib/shared/types";
23
26
  import type { SlugMap } from "./lib/shared/slugTranslator";
24
27
 
28
+ /**
29
+ * Generate short hash from content for file naming
30
+ */
31
+ function hashContent(content: string): string {
32
+ return createHash('sha256').update(content).digest('hex').slice(0, 8);
33
+ }
34
+
35
+ /**
36
+ * Track JavaScript files to avoid duplicates
37
+ * Maps content hash -> script path
38
+ */
39
+ const jsFileCache = new Map<string, string>();
40
+
41
+ /**
42
+ * Get or create script file path for given JS content
43
+ * Returns the path to reference in HTML
44
+ */
45
+ async function getScriptPath(jsContent: string, distDir: string): Promise<string> {
46
+ const hash = hashContent(jsContent);
47
+
48
+ // Check if we already wrote this content
49
+ if (jsFileCache.has(hash)) {
50
+ return jsFileCache.get(hash)!;
51
+ }
52
+
53
+ // Create scripts directory if needed
54
+ const scriptsDir = join(distDir, '_scripts');
55
+ if (!existsSync(scriptsDir)) {
56
+ mkdirSync(scriptsDir, { recursive: true });
57
+ }
58
+
59
+ // Write script file
60
+ const scriptPath = `/_scripts/${hash}.js`;
61
+ const fullPath = join(distDir, '_scripts', `${hash}.js`);
62
+ await writeFile(fullPath, jsContent, 'utf-8');
63
+
64
+ // Cache for reuse
65
+ jsFileCache.set(hash, scriptPath);
66
+
67
+ return scriptPath;
68
+ }
69
+
25
70
  /**
26
71
  * Recursively copy directory contents
27
72
  */
@@ -235,17 +280,26 @@ async function buildCMSTemplates(
235
280
  const baseUrl = "";
236
281
  const itemPath = buildCMSItemPath(cmsSchema.urlPattern, item, cmsSchema.slugField, locale, i18nConfig);
237
282
 
238
- const html = await generateSSRHTML(
283
+ // Generate HTML with JS returned separately (CSP-compliant)
284
+ const result = await generateSSRHTML({
239
285
  pageData,
240
286
  globalComponents,
241
- itemPath,
287
+ pagePath: itemPath,
242
288
  baseUrl,
243
- true,
289
+ useBuiltBundle: true,
244
290
  locale,
245
291
  slugMappings,
246
- { cms: item },
247
- cmsService
248
- );
292
+ cmsContext: { cms: item },
293
+ cmsService,
294
+ returnSeparateJS: true
295
+ }) as SSRHTMLResult;
296
+
297
+ // If there's JavaScript, write to external file and update HTML
298
+ let finalHtml = result.html;
299
+ if (result.javascript) {
300
+ const scriptPath = await getScriptPath(result.javascript, distDir);
301
+ finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}"></script>\n</body>`);
302
+ }
249
303
 
250
304
  const outputPath = locale === i18nConfig.defaultLocale
251
305
  ? `${distDir}${itemPath}.html`
@@ -256,7 +310,7 @@ async function buildCMSTemplates(
256
310
  mkdirSync(outputDir, { recursive: true });
257
311
  }
258
312
 
259
- await writeFile(outputPath, html, 'utf-8');
313
+ await writeFile(outputPath, finalHtml, 'utf-8');
260
314
 
261
315
  const displayPath = locale === i18nConfig.defaultLocale ? itemPath : `/${locale}${itemPath}`;
262
316
  console.log(` ✅ ${displayPath}`);
@@ -397,7 +451,26 @@ async function buildStaticPages(): Promise<void> {
397
451
  // Build the URL path that will be used for this locale
398
452
  const urlPath = getDisplayPath(basePath, locale, i18nConfig.defaultLocale, slugs);
399
453
 
400
- const html = await generateSSRHTML(pageData, globalComponents, urlPath, baseUrl, true, locale, slugMappings, undefined, cmsService);
454
+ // Generate HTML with JS returned separately (CSP-compliant)
455
+ const result = await generateSSRHTML({
456
+ pageData,
457
+ globalComponents,
458
+ pagePath: urlPath,
459
+ baseUrl,
460
+ useBuiltBundle: true,
461
+ locale,
462
+ slugMappings,
463
+ cmsService,
464
+ returnSeparateJS: true
465
+ }) as SSRHTMLResult;
466
+
467
+ // If there's JavaScript, write to external file and update HTML
468
+ let finalHtml = result.html;
469
+ if (result.javascript) {
470
+ const scriptPath = await getScriptPath(result.javascript, distDir);
471
+ // Insert script reference before </body>
472
+ finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}"></script>\n</body>`);
473
+ }
401
474
 
402
475
  // Determine locale-specific output path with translated slug
403
476
  const outputPath = getLocalizedOutputPath(basePath, locale, i18nConfig.defaultLocale, distDir, slugs);
@@ -408,7 +481,7 @@ async function buildStaticPages(): Promise<void> {
408
481
  mkdirSync(outputDir, { recursive: true });
409
482
  }
410
483
 
411
- await writeFile(outputPath, html, "utf-8");
484
+ await writeFile(outputPath, finalHtml, "utf-8");
412
485
 
413
486
  console.log(`✅ Built: ${urlPath} → ${outputPath}`);
414
487
  successCount++;
@@ -26,7 +26,7 @@ import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, type V
26
26
  import { DEFAULT_I18N_CONFIG, resolveI18nValue } from "../../shared/i18n";
27
27
  import { getChildPath, pathToString } from "../../shared/pathArrayUtils";
28
28
  import { responsiveStylesToClasses } from "../../shared/utilityClassMapper";
29
- import { processCMSTemplate, processCMSPropsTemplate } from "./cmsTemplateProcessor";
29
+ import { processCMSTemplate, processCMSPropsTemplate, RAW_HTML_PREFIX } from "./cmsTemplateProcessor";
30
30
  import type { PrefetchService } from "../services/PrefetchService";
31
31
  import { generateElementClassName, type ElementClassContext } from "../../shared/elementClassName";
32
32
  import type { InteractiveStyles } from "../../shared/types/styles";
@@ -318,8 +318,10 @@ export class ComponentBuilder {
318
318
 
319
319
  /**
320
320
  * Process text node with CMS and item templates
321
+ * Returns a ReactElement with dangerouslySetInnerHTML for raw HTML content (rich-text),
322
+ * or a plain string for regular text content.
321
323
  */
322
- private processTextNode(text: string, ctx: BuilderContext): string {
324
+ private processTextNode(text: string, ctx: BuilderContext): string | ReactElement {
323
325
  let result = text;
324
326
 
325
327
  // Process CMS templates
@@ -338,6 +340,12 @@ export class ComponentBuilder {
338
340
  result = processItemTemplate(result, effectiveTemplateContext, i18nResolver);
339
341
  }
340
342
 
343
+ // Check for raw HTML marker (from rich-text fields) - render with dangerouslySetInnerHTML
344
+ if (result.startsWith(RAW_HTML_PREFIX)) {
345
+ const rawHtml = result.slice(RAW_HTML_PREFIX.length);
346
+ return h('span', { dangerouslySetInnerHTML: { __html: rawHtml } });
347
+ }
348
+
341
349
  return result;
342
350
  }
343
351
 
@@ -536,7 +544,15 @@ export class ComponentBuilder {
536
544
  delete extractedAttributes.className;
537
545
  }
538
546
 
539
- return { ...result, ...extractedAttributes };
547
+ // Convert boolean true to empty string for React compatibility
548
+ // React strips boolean true for non-standard attributes, but SSR outputs them as presence-only
549
+ // Using empty string makes React render the attribute (e.g., cmsrt="" matches [cmsrt] selector)
550
+ const normalizedAttributes: Record<string, unknown> = {};
551
+ for (const [key, value] of Object.entries(extractedAttributes)) {
552
+ normalizedAttributes[key] = value === true ? '' : value;
553
+ }
554
+
555
+ return { ...result, ...normalizedAttributes };
540
556
  }
541
557
 
542
558
  /**
@@ -6,7 +6,7 @@
6
6
  import { createElement as h } from "react";
7
7
  import type { ReactElement } from "react";
8
8
  import type { CMSListNode, CMSItem } from "../../../shared/types/cms";
9
- import type { InteractiveStyles } from "../../../shared/types";
9
+ import type { InteractiveStyles, StyleObject, ResponsiveStyleObject } from "../../../shared/types";
10
10
  import { singularize } from "../../../shared/types/cms";
11
11
  import { buildTemplateContext, resolveItemsTemplate } from "../../../shared/itemTemplateUtils";
12
12
  import { extractAttributesFromNode } from "../../../shared/attributeNodeUtils";
@@ -14,6 +14,7 @@ import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
14
14
  import { pathToString } from "../../../shared/pathArrayUtils";
15
15
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
16
16
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
17
+ import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
17
18
  import type { ElementRegistry } from "../../elementRegistry";
18
19
  import type { BuilderContext, BuildResult, BuildChildrenContext } from "./types";
19
20
 
@@ -60,7 +61,18 @@ export function buildCMSList(
60
61
  'data-element-path': pathToString(elementPath),
61
62
  'data-cms-list': 'true',
62
63
  'data-collection': node.collection || '',
63
- ref: (el: HTMLElement | null) => deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false)
64
+ ref: (el: HTMLElement | null) => {
65
+ deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false);
66
+ // Apply CSS variables for interactive styles
67
+ if (el) {
68
+ const cssVariables = InteractiveStylesRegistry.getVariables(elementPath);
69
+ if (cssVariables && Object.keys(cssVariables).length > 0) {
70
+ for (const [varName, value] of Object.entries(cssVariables)) {
71
+ el.style.setProperty(varName, value);
72
+ }
73
+ }
74
+ }
75
+ }
64
76
  };
65
77
 
66
78
  // Start building className
@@ -97,9 +109,32 @@ export function buildCMSList(
97
109
  // Prepend element class
98
110
  classNames.unshift(elementClass);
99
111
 
100
- // Register interactive styles
112
+ // Register interactive styles with mapping support
101
113
  if (nodeInteractiveStyles && nodeInteractiveStyles.length > 0) {
102
- InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
114
+ if (ctx.componentResolvedProps && hasInteractiveStyleMappings(nodeInteractiveStyles)) {
115
+ const { resolvedStyles, mappings } = extractInteractiveStyleMappings(nodeInteractiveStyles);
116
+ const cssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
117
+ InteractiveStylesRegistry.set(elementClass, resolvedStyles);
118
+ if (Object.keys(cssVariables).length > 0) {
119
+ InteractiveStylesRegistry.setVariables(elementPath, cssVariables);
120
+ }
121
+ } else {
122
+ InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
123
+ }
124
+
125
+ // Apply preview classes when previewProp is set and truthy
126
+ if (ctx.componentResolvedProps) {
127
+ const previewClasses: string[] = [];
128
+ for (const rule of nodeInteractiveStyles) {
129
+ if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
130
+ const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
131
+ previewClasses.push(...styleClasses);
132
+ }
133
+ }
134
+ if (previewClasses.length > 0) {
135
+ classNames.push(...previewClasses);
136
+ }
137
+ }
103
138
  }
104
139
  }
105
140
 
@@ -11,6 +11,7 @@ import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
11
11
  import { pathToString } from "../../../shared/pathArrayUtils";
12
12
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
13
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
14
+ import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
14
15
  import DOMPurify from "isomorphic-dompurify";
15
16
  import type { ElementRegistry } from "../../elementRegistry";
16
17
  import type { BuilderContext } from "./types";
@@ -60,7 +61,18 @@ export function buildEmbed(
60
61
  key,
61
62
  'data-element-path': pathToString(elementPath),
62
63
  'data-embed-node': 'true',
63
- ref: (el: HTMLElement | null) => deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false),
64
+ ref: (el: HTMLElement | null) => {
65
+ deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false);
66
+ // Apply CSS variables for interactive styles
67
+ if (el) {
68
+ const cssVariables = InteractiveStylesRegistry.getVariables(elementPath);
69
+ if (cssVariables && Object.keys(cssVariables).length > 0) {
70
+ for (const [varName, value] of Object.entries(cssVariables)) {
71
+ el.style.setProperty(varName, value);
72
+ }
73
+ }
74
+ }
75
+ },
64
76
  dangerouslySetInnerHTML: { __html: sanitizedHtml }
65
77
  };
66
78
 
@@ -103,9 +115,32 @@ export function buildEmbed(
103
115
  // Prepend element class
104
116
  classNames.unshift(elementClass);
105
117
 
106
- // Register interactive styles
118
+ // Register interactive styles with mapping support
107
119
  if (nodeInteractiveStyles && nodeInteractiveStyles.length > 0) {
108
- InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
120
+ if (ctx.componentResolvedProps && hasInteractiveStyleMappings(nodeInteractiveStyles)) {
121
+ const { resolvedStyles, mappings } = extractInteractiveStyleMappings(nodeInteractiveStyles);
122
+ const cssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
123
+ InteractiveStylesRegistry.set(elementClass, resolvedStyles);
124
+ if (Object.keys(cssVariables).length > 0) {
125
+ InteractiveStylesRegistry.setVariables(elementPath, cssVariables);
126
+ }
127
+ } else {
128
+ InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
129
+ }
130
+
131
+ // Apply preview classes when previewProp is set and truthy
132
+ if (ctx.componentResolvedProps) {
133
+ const previewClasses: string[] = [];
134
+ for (const rule of nodeInteractiveStyles) {
135
+ if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
136
+ const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
137
+ previewClasses.push(...styleClasses);
138
+ }
139
+ }
140
+ if (previewClasses.length > 0) {
141
+ classNames.push(...previewClasses);
142
+ }
143
+ }
109
144
  }
110
145
  }
111
146
 
@@ -11,6 +11,7 @@ import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
11
11
  import { pathToString } from "../../../shared/pathArrayUtils";
12
12
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
13
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
14
+ import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
14
15
  import type { ElementRegistry } from "../../elementRegistry";
15
16
  import type { BuilderContext } from "./types";
16
17
 
@@ -49,7 +50,18 @@ export function buildLocaleList(
49
50
  key,
50
51
  'data-element-path': pathToString(elementPath),
51
52
  'data-locale-list': 'true',
52
- ref: (el: HTMLElement | null) => deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false)
53
+ ref: (el: HTMLElement | null) => {
54
+ deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false);
55
+ // Apply CSS variables for interactive styles
56
+ if (el) {
57
+ const cssVariables = InteractiveStylesRegistry.getVariables(elementPath);
58
+ if (cssVariables && Object.keys(cssVariables).length > 0) {
59
+ for (const [varName, value] of Object.entries(cssVariables)) {
60
+ el.style.setProperty(varName, value);
61
+ }
62
+ }
63
+ }
64
+ }
53
65
  };
54
66
 
55
67
  // Add CMS item index path for elements inside CMS lists
@@ -91,9 +103,32 @@ export function buildLocaleList(
91
103
  // Prepend element class
92
104
  classNames.unshift(elementClass);
93
105
 
94
- // Register interactive styles
106
+ // Register interactive styles with mapping support
95
107
  if (nodeInteractiveStyles && nodeInteractiveStyles.length > 0) {
96
- InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
108
+ if (ctx.componentResolvedProps && hasInteractiveStyleMappings(nodeInteractiveStyles)) {
109
+ const { resolvedStyles, mappings } = extractInteractiveStyleMappings(nodeInteractiveStyles);
110
+ const cssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
111
+ InteractiveStylesRegistry.set(elementClass, resolvedStyles);
112
+ if (Object.keys(cssVariables).length > 0) {
113
+ InteractiveStylesRegistry.setVariables(elementPath, cssVariables);
114
+ }
115
+ } else {
116
+ InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
117
+ }
118
+
119
+ // Apply preview classes when previewProp is set and truthy
120
+ if (ctx.componentResolvedProps) {
121
+ const previewClasses: string[] = [];
122
+ for (const rule of nodeInteractiveStyles) {
123
+ if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
124
+ const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
125
+ previewClasses.push(...styleClasses);
126
+ }
127
+ }
128
+ if (previewClasses.length > 0) {
129
+ classNames.push(...previewClasses);
130
+ }
131
+ }
97
132
  }
98
133
  }
99
134
 
@@ -11,6 +11,7 @@ import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
11
11
  import { pathToString } from "../../../shared/pathArrayUtils";
12
12
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
13
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
14
+ import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
14
15
  import type { ElementRegistry } from "../../elementRegistry";
15
16
  import type { BuilderContext, BuildResult, BuildChildrenContext } from "./types";
16
17
 
@@ -55,7 +56,18 @@ export function buildObjectLink(
55
56
  key,
56
57
  'data-element-path': pathToString(elementPath),
57
58
  'data-object-link-node': 'true',
58
- ref: (el: HTMLElement | null) => deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false)
59
+ ref: (el: HTMLElement | null) => {
60
+ deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false);
61
+ // Apply CSS variables for interactive styles
62
+ if (el) {
63
+ const cssVariables = InteractiveStylesRegistry.getVariables(elementPath);
64
+ if (cssVariables && Object.keys(cssVariables).length > 0) {
65
+ for (const [varName, value] of Object.entries(cssVariables)) {
66
+ el.style.setProperty(varName, value);
67
+ }
68
+ }
69
+ }
70
+ }
59
71
  };
60
72
 
61
73
  // Add CMS item index path for elements inside CMS lists
@@ -97,9 +109,32 @@ export function buildObjectLink(
97
109
  // Prepend element class
98
110
  classNames.unshift(elementClass);
99
111
 
100
- // Register interactive styles
112
+ // Register interactive styles with mapping support
101
113
  if (nodeInteractiveStyles && nodeInteractiveStyles.length > 0) {
102
- InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
114
+ if (ctx.componentResolvedProps && hasInteractiveStyleMappings(nodeInteractiveStyles)) {
115
+ const { resolvedStyles, mappings } = extractInteractiveStyleMappings(nodeInteractiveStyles);
116
+ const cssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
117
+ InteractiveStylesRegistry.set(elementClass, resolvedStyles);
118
+ if (Object.keys(cssVariables).length > 0) {
119
+ InteractiveStylesRegistry.setVariables(elementPath, cssVariables);
120
+ }
121
+ } else {
122
+ InteractiveStylesRegistry.set(elementClass, nodeInteractiveStyles);
123
+ }
124
+
125
+ // Apply preview classes when previewProp is set and truthy
126
+ if (ctx.componentResolvedProps) {
127
+ const previewClasses: string[] = [];
128
+ for (const rule of nodeInteractiveStyles) {
129
+ if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
130
+ const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
131
+ previewClasses.push(...styleClasses);
132
+ }
133
+ }
134
+ if (previewClasses.length > 0) {
135
+ classNames.push(...previewClasses);
136
+ }
137
+ }
103
138
  }
104
139
  }
105
140
 
@@ -8,6 +8,12 @@
8
8
  import type { I18nValue, I18nConfig } from '../../shared/types';
9
9
  import { getI18nConfig } from '../i18nConfigService';
10
10
  import { isRichTextMarker, richTextMarkerToHtml } from '../../shared/propResolver';
11
+ import { tiptapToHtml } from '../../shared/richtext/tiptapToHtml';
12
+ import { isTiptapDocument } from '../../shared/richtext/types';
13
+ import { RAW_HTML_PREFIX } from '../../shared/constants';
14
+
15
+ // Re-export for backward compatibility
16
+ export { RAW_HTML_PREFIX };
11
17
 
12
18
  /**
13
19
  * Check if a value is an I18nValue object
@@ -86,8 +92,14 @@ export function processCMSTemplate(
86
92
  return '';
87
93
  }
88
94
  // Handle rich-text markers - extract HTML content for interpolation
95
+ // Mark with RAW_HTML_PREFIX so renderer knows not to escape
89
96
  if (typeof value === 'object' && '__richtext__' in value && typeof (value as unknown as { html: string }).html === 'string') {
90
- return (value as unknown as { html: string }).html;
97
+ return RAW_HTML_PREFIX + (value as unknown as { html: string }).html;
98
+ }
99
+ // Handle raw TipTap documents (fallback if not preprocessed)
100
+ // Mark with RAW_HTML_PREFIX so renderer knows not to escape
101
+ if (isTiptapDocument(value)) {
102
+ return RAW_HTML_PREFIX + tiptapToHtml(value);
91
103
  }
92
104
  return String(value);
93
105
  });
@@ -29,6 +29,18 @@ import { parseLocaleFromPath, setStoredLocale, DEFAULT_I18N_CONFIG } from "../..
29
29
  import { fetchI18nConfig, setI18nConfig as setCachedI18nConfig } from "../i18nConfigService";
30
30
  import { IFRAME_MESSAGE_TYPES } from "../../shared/constants";
31
31
 
32
+ /** SSR-serialized CMS context for client-side hydration */
33
+ interface SSRCMSContext {
34
+ item: Record<string, unknown>;
35
+ templatePath: string;
36
+ }
37
+
38
+ declare global {
39
+ interface Window {
40
+ __MENO_CMS__?: SSRCMSContext;
41
+ }
42
+ }
43
+
32
44
  /**
33
45
  * Router component props
34
46
  */
@@ -123,6 +135,11 @@ export function Router(props: RouterProps = {}): ReactElement {
123
135
  // Don't block rendering waiting for CMS context - render immediately and re-render when context arrives
124
136
  const [awaitingCmsContext, setAwaitingCmsContext] = useState(false);
125
137
  const [collectionItemsMap, setCollectionItemsMap] = useState<Record<string, CMSItem[]>>({});
138
+ // Store CMS template path for HMR reloads (so we don't lose it after initial load)
139
+ const [cmsTemplatePath, setCmsTemplatePath] = useState<string | null>(null);
140
+
141
+ // Track if initial mount used SSR CMS context (to skip redundant path-based load)
142
+ const ssrCmsHandledRef = useRef(false);
126
143
 
127
144
  // Create RouteLoader instance
128
145
  const routeLoader = useRef(new RouteLoader({
@@ -312,9 +329,11 @@ export function Router(props: RouterProps = {}): ReactElement {
312
329
  }, [componentTree, currentPath, services]);
313
330
 
314
331
  // Load components function using RouteLoader
332
+ // For CMS pages, use the stored template path instead of the URL path
315
333
  const loadComponents = useCallback(async (path: string) => {
316
- await routeLoader.loadComponents(path);
317
- }, []);
334
+ const pathToLoad = cmsTemplatePath || path;
335
+ await routeLoader.loadComponents(pathToLoad);
336
+ }, [cmsTemplatePath]);
318
337
 
319
338
  // Handle navigation
320
339
  useEffect(() => {
@@ -330,16 +349,37 @@ export function Router(props: RouterProps = {}): ReactElement {
330
349
  }, [loadComponents]);
331
350
 
332
351
  useEffect(() => {
352
+ // Check for SSR-serialized CMS context (from window.__MENO_CMS__)
353
+ // If present, use the template path instead of the CMS URL to avoid 404
354
+ const ssrCmsContext = window.__MENO_CMS__;
355
+ if (ssrCmsContext) {
356
+ // Set CMS context from SSR
357
+ setCmsContext(ssrCmsContext.item);
358
+ // Store template path for HMR reloads
359
+ setCmsTemplatePath(ssrCmsContext.templatePath);
360
+ // Mark that we're handling SSR CMS (to skip path-based load below)
361
+ ssrCmsHandledRef.current = true;
362
+ // Clear to prevent stale data on SPA navigation
363
+ delete window.__MENO_CMS__;
364
+ }
365
+
333
366
  // Initial load
334
367
  routeLoader.loadPages();
335
368
  routeLoader.loadGlobalComponents().then(() => {
336
- loadComponents(currentPath);
369
+ // Use template path for CMS pages, otherwise use the current URL path
370
+ const pathToLoad = ssrCmsContext ? ssrCmsContext.templatePath : currentPath;
371
+ loadComponents(pathToLoad);
337
372
  });
338
373
  // eslint-disable-next-line react-hooks/exhaustive-deps
339
374
  }, []); // Only run on mount - currentPath handled by separate effect below
340
375
 
341
376
  // Reload when path changes
342
377
  useEffect(() => {
378
+ // Skip initial mount if SSR CMS context was handled (template already loading)
379
+ if (ssrCmsHandledRef.current) {
380
+ ssrCmsHandledRef.current = false;
381
+ return;
382
+ }
343
383
  setShowNotFound(false); // Reset not found state when path changes
344
384
  loadComponents(currentPath);
345
385
  }, [currentPath, loadComponents]);
@@ -445,8 +445,8 @@ describe("ScriptExecutor", () => {
445
445
  componentRegistry.register("DynamicComponent", componentDef2);
446
446
 
447
447
  // Register multiple instances of dynamic component
448
- const mockElement1 = originalDocument.createElement("div");
449
- const mockElement2 = originalDocument.createElement("div");
448
+ const mockElement1 = document.createElement("div");
449
+ const mockElement2 = document.createElement("div");
450
450
  elementRegistry.register([0], mockElement1, "DynamicComponent", true, {
451
451
  id: "1",
452
452
  });
@@ -456,9 +456,9 @@ describe("ScriptExecutor", () => {
456
456
 
457
457
  scriptExecutor.execute();
458
458
 
459
- // StaticComponent: 1 script tag (no templates)
460
- // DynamicComponent: 2 script tags (with templates, 2 instances)
461
- expect(injectedScripts.size).toBe(3);
459
+ // StaticComponent: 1 execution (no templates)
460
+ // DynamicComponent: 2 executions (with templates, 2 instances)
461
+ expect(executedCode.length).toBe(3);
462
462
  });
463
463
  });
464
464
  });
@@ -16,6 +16,7 @@ import { isRichTextMarker, richTextMarkerToHtml } from '../shared/propResolver';
16
16
  import { isTiptapDocument, tiptapToHtml } from '../shared/richtext';
17
17
  import { hasItemTemplates } from '../shared/itemTemplateUtils';
18
18
  import { safeEvaluate } from '../shared/expressionEvaluator';
19
+ import { RAW_HTML_PREFIX } from '../shared/constants';
19
20
 
20
21
  // Re-export for backward compatibility
21
22
  export { isResponsiveStyle };
@@ -327,9 +328,10 @@ export function processStructure(
327
328
  // Use evaluateTemplate to preserve type (objects, arrays, numbers)
328
329
  if (/^\{\{.+\}\}$/.test(structure)) {
329
330
  const result = evaluateTemplate(structure, evalContext);
330
- // Check for rich-text marker - convert to inline embed node
331
+ // Check for rich-text marker - extract HTML content with RAW_HTML_PREFIX
332
+ // The prefix signals to ComponentBuilder.processTextNode to render as HTML
331
333
  if (isRichTextMarker(result)) {
332
- return { type: NODE_TYPE.EMBED, html: result.html, inline: true } as any;
334
+ return RAW_HTML_PREFIX + richTextMarkerToHtml(result);
333
335
  }
334
336
  if (typeof result === 'string' || typeof result === 'number') {
335
337
  return result;
@@ -391,11 +393,24 @@ export function processStructure(
391
393
  return null;
392
394
  }
393
395
 
394
- // Preserve type from input structure, default to "node" if not present or invalid
396
+ // Check if this is a valid node structure or a plain object (like props)
395
397
  const inputNode = structure as ComponentNode;
396
- const preservedType = inputNode.type && isValidNodeType(inputNode.type)
397
- ? inputNode.type
398
- : NODE_TYPE.NODE;
398
+ const hasValidNodeType = inputNode.type && isValidNodeType(inputNode.type);
399
+
400
+ // If no valid node type, treat as plain object and process values recursively
401
+ // This handles props objects like { text: "{{text}}", isMarginBottom: false }
402
+ if (!hasValidNodeType) {
403
+ const result: Record<string, unknown> = {};
404
+ for (const [key, value] of Object.entries(structure)) {
405
+ const processedValue = processStructure(value as any, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
406
+ if (processedValue !== null && processedValue !== undefined) {
407
+ result[key] = processedValue;
408
+ }
409
+ }
410
+ return result as any;
411
+ }
412
+
413
+ const preservedType = inputNode.type;
399
414
 
400
415
  // Create base processed object based on type
401
416
  let processed: ComponentNode;