meno-core 1.0.6 → 1.0.8

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
@@ -150,6 +150,37 @@ function getDisplayPath(
150
150
  return slug === "" ? `/${locale}` : `/${locale}/${slug}`;
151
151
  }
152
152
 
153
+ /**
154
+ * Generate robots.txt with sensible defaults
155
+ */
156
+ async function generateRobotsTxt(siteUrl: string, distDir: string): Promise<void> {
157
+ const content = `User-agent: *
158
+ Allow: /
159
+
160
+ Sitemap: ${siteUrl}/sitemap.xml
161
+ `;
162
+ await writeFile(join(distDir, 'robots.txt'), content, 'utf-8');
163
+ }
164
+
165
+ /**
166
+ * Generate sitemap.xml from collected URLs
167
+ */
168
+ async function generateSitemap(urls: string[], siteUrl: string, distDir: string): Promise<void> {
169
+ // Sort URLs for deterministic output
170
+ const sortedUrls = [...urls].sort();
171
+
172
+ const urlEntries = sortedUrls
173
+ .map(url => ` <url><loc>${siteUrl}${url}</loc></url>`)
174
+ .join('\n');
175
+
176
+ const content = `<?xml version="1.0" encoding="UTF-8"?>
177
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
178
+ ${urlEntries}
179
+ </urlset>
180
+ `;
181
+ await writeFile(join(distDir, 'sitemap.xml'), content, 'utf-8');
182
+ }
183
+
153
184
  /**
154
185
  * Clean dist directory, keeping only production files
155
186
  */
@@ -232,7 +263,8 @@ async function buildCMSTemplates(
232
263
  i18nConfig: I18nConfig,
233
264
  slugMappings: SlugMap[],
234
265
  distDir: string,
235
- cmsService: CMSService
266
+ cmsService: CMSService,
267
+ generatedUrls: Set<string>
236
268
  ): Promise<{ success: number; errors: number }> {
237
269
  let successCount = 0;
238
270
  let errorCount = 0;
@@ -313,6 +345,7 @@ async function buildCMSTemplates(
313
345
  await writeFile(outputPath, finalHtml, 'utf-8');
314
346
 
315
347
  const displayPath = locale === i18nConfig.defaultLocale ? itemPath : `/${locale}${itemPath}`;
348
+ generatedUrls.add(displayPath);
316
349
  console.log(` āœ… ${displayPath}`);
317
350
  successCount++;
318
351
  }
@@ -333,7 +366,11 @@ async function buildStaticPages(): Promise<void> {
333
366
  console.log("šŸ—ļø Building static HTML files...\n");
334
367
 
335
368
  // Load project config first
336
- await loadProjectConfig();
369
+ const projectConfig = await loadProjectConfig();
370
+ const siteUrl = (projectConfig as { siteUrl?: string }).siteUrl?.replace(/\/$/, ''); // Remove trailing slash
371
+
372
+ // Track all generated URLs for sitemap
373
+ const generatedUrls = new Set<string>();
337
374
 
338
375
  // Load i18n config for multi-locale build
339
376
  const i18nConfig = await loadI18nConfig();
@@ -483,6 +520,7 @@ async function buildStaticPages(): Promise<void> {
483
520
 
484
521
  await writeFile(outputPath, finalHtml, "utf-8");
485
522
 
523
+ generatedUrls.add(urlPath);
486
524
  console.log(`āœ… Built: ${urlPath} → ${outputPath}`);
487
525
  successCount++;
488
526
  }
@@ -501,11 +539,21 @@ async function buildStaticPages(): Promise<void> {
501
539
  i18nConfig,
502
540
  slugMappings,
503
541
  distDir,
504
- cmsService
542
+ cmsService,
543
+ generatedUrls
505
544
  );
506
545
  successCount += cmsResult.success;
507
546
  errorCount += cmsResult.errors;
508
547
 
548
+ // Generate SEO files (robots.txt and sitemap.xml)
549
+ if (siteUrl) {
550
+ await generateRobotsTxt(siteUrl, distDir);
551
+ await generateSitemap([...generatedUrls], siteUrl, distDir);
552
+ console.log(`\nšŸ” SEO files generated (robots.txt, sitemap.xml)`);
553
+ } else {
554
+ console.warn(`\nāš ļø Skipping SEO files: siteUrl not configured in project.config.json`);
555
+ }
556
+
509
557
  console.log("\n" + "=".repeat(50));
510
558
  console.log(`✨ Build complete!`);
511
559
  console.log(` āœ… Success: ${successCount}`);
@@ -520,6 +568,9 @@ async function buildStaticPages(): Promise<void> {
520
568
  if (existsSync(functionsDir)) {
521
569
  console.log(` - functions/ (Cloudflare Pages Functions)`);
522
570
  }
571
+ if (siteUrl) {
572
+ console.log(` - robots.txt, sitemap.xml (SEO)`);
573
+ }
523
574
  console.log(` - No React, no client-router āœ“`);
524
575
  }
525
576
 
@@ -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]);
@@ -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;
@@ -13,12 +13,14 @@ export interface FileWatchCallbacks {
13
13
  onComponentChange?: () => Promise<void>;
14
14
  onPageChange?: (pagePath: string) => Promise<void>;
15
15
  onColorsChange?: () => Promise<void>;
16
+ onCMSChange?: (collection: string) => Promise<void>;
16
17
  }
17
18
 
18
19
  export class FileWatcher {
19
20
  private componentsWatcher: FSWatcher | null = null;
20
21
  private pagesWatcher: FSWatcher | null = null;
21
22
  private colorsWatcher: FSWatcher | null = null;
23
+ private cmsWatcher: FSWatcher | null = null;
22
24
 
23
25
  constructor(private callbacks: FileWatchCallbacks) {}
24
26
 
@@ -98,12 +100,37 @@ export class FileWatcher {
98
100
  }
99
101
 
100
102
  /**
101
- * Start watching both directories
103
+ * Start watching CMS directory
104
+ * Watches for changes in CMS content files (cms/{collection}/*.json)
105
+ */
106
+ watchCMS(dirPath: string = projectPaths.cms()): void {
107
+ if (!existsSync(dirPath)) {
108
+ return;
109
+ }
110
+
111
+ this.cmsWatcher = watch(
112
+ dirPath,
113
+ { recursive: true },
114
+ async (event, filename) => {
115
+ if (filename && filename.endsWith('.json')) {
116
+ // Extract collection from path: "blog/my-post.json" -> "blog"
117
+ const collection = filename.split('/')[0];
118
+ if (this.callbacks.onCMSChange) {
119
+ await this.callbacks.onCMSChange(collection);
120
+ }
121
+ }
122
+ }
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Start watching all directories
102
128
  */
103
129
  watchAll(): void {
104
130
  this.watchComponents();
105
131
  this.watchPages();
106
132
  this.watchColors();
133
+ this.watchCMS();
107
134
  }
108
135
 
109
136
  /**
@@ -124,13 +151,18 @@ export class FileWatcher {
124
151
  this.colorsWatcher.close();
125
152
  this.colorsWatcher = null;
126
153
  }
154
+
155
+ if (this.cmsWatcher) {
156
+ this.cmsWatcher.close();
157
+ this.cmsWatcher = null;
158
+ }
127
159
  }
128
160
 
129
161
  /**
130
162
  * Check if watchers are active
131
163
  */
132
164
  isWatching(): boolean {
133
- return this.componentsWatcher !== null || this.pagesWatcher !== null;
165
+ return this.componentsWatcher !== null || this.pagesWatcher !== null || this.cmsWatcher !== null;
134
166
  }
135
167
  }
136
168