meno-core 1.0.6 → 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/lib/client/core/ComponentBuilder.ts +19 -3
- package/lib/client/core/builders/cmsListBuilder.ts +39 -4
- package/lib/client/core/builders/embedBuilder.ts +38 -3
- package/lib/client/core/builders/localeListBuilder.ts +38 -3
- package/lib/client/core/builders/objectLinkBuilder.ts +38 -3
- package/lib/client/core/cmsTemplateProcessor.ts +13 -1
- package/lib/client/routing/Router.tsx +43 -3
- package/lib/client/templateEngine.ts +21 -6
- package/lib/server/fileWatcher.ts +34 -2
- package/lib/server/routes/pages.ts +16 -8
- package/lib/server/services/cmsService.test.ts +182 -0
- package/lib/server/services/cmsService.ts +57 -2
- package/lib/server/services/fileWatcherService.ts +4 -0
- package/lib/server/ssr/cmsSSRProcessor.ts +13 -1
- package/lib/server/ssr/htmlGenerator.ts +15 -1
- package/lib/server/ssr/ssrRenderer.ts +96 -13
- package/lib/server/websocketManager.ts +10 -0
- package/lib/shared/constants.ts +6 -0
- package/lib/shared/cssGeneration.test.ts +52 -0
- package/lib/shared/cssGeneration.ts +18 -0
- package/lib/shared/responsiveScaling.test.ts +16 -3
- package/lib/shared/responsiveScaling.ts +4 -0
- package/lib/shared/types/api.ts +2 -1
- package/lib/shared/utilityClassConfig.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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
|
-
//
|
|
396
|
+
// Check if this is a valid node structure or a plain object (like props)
|
|
395
397
|
const inputNode = structure as ComponentNode;
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
|
|
@@ -7,7 +7,7 @@ import type { RouteContext } from './index';
|
|
|
7
7
|
import { generateSSRHTML } from '../ssrRenderer';
|
|
8
8
|
import { getStaticFilePath } from '../../shared/pathUtils';
|
|
9
9
|
import { parseJSON, loadI18nConfig } from '../jsonLoader';
|
|
10
|
-
import { packagePaths } from '../projectContext';
|
|
10
|
+
import { packagePaths, projectPaths } from '../projectContext';
|
|
11
11
|
import { parseLocaleFromPath } from '../../shared/i18n';
|
|
12
12
|
import { buildSlugIndex, resolveSlugToPageId } from '../../shared/slugTranslator';
|
|
13
13
|
import type { CMSItem } from '../../shared/types';
|
|
@@ -48,19 +48,27 @@ export async function handlePageRoute(
|
|
|
48
48
|
// Convert global components Map to Record for SSR
|
|
49
49
|
const globalComponentsRecord = componentService.getAllComponents();
|
|
50
50
|
|
|
51
|
+
// Convert absolute template path to URL path for client-side hydration
|
|
52
|
+
// e.g., /Users/.../pages/templates/posts.json -> /templates/posts
|
|
53
|
+
const pagesDir = projectPaths.pages();
|
|
54
|
+
const cmsTemplatePath = cmsMatch.pagePath
|
|
55
|
+
.replace(pagesDir, '') // Remove pages directory prefix
|
|
56
|
+
.replace(/\.json$/, ''); // Remove .json extension
|
|
57
|
+
|
|
51
58
|
// Generate SSR HTML with CMS context
|
|
52
59
|
const baseUrl = `${url.protocol}//${url.host}`;
|
|
53
|
-
const ssrHTML = await generateSSRHTML(
|
|
54
|
-
pageData as import('../../shared/types').JSONPage,
|
|
55
|
-
globalComponentsRecord,
|
|
60
|
+
const ssrHTML = await generateSSRHTML({
|
|
61
|
+
pageData: pageData as import('../../shared/types').JSONPage,
|
|
62
|
+
globalComponents: globalComponentsRecord,
|
|
56
63
|
pagePath,
|
|
57
64
|
baseUrl,
|
|
58
|
-
false,
|
|
65
|
+
useBuiltBundle: false,
|
|
59
66
|
locale,
|
|
60
67
|
slugMappings,
|
|
61
|
-
{ cms: cmsMatch.item },
|
|
62
|
-
cmsService
|
|
63
|
-
|
|
68
|
+
cmsContext: { cms: cmsMatch.item },
|
|
69
|
+
cmsService,
|
|
70
|
+
cmsTemplatePath,
|
|
71
|
+
});
|
|
64
72
|
|
|
65
73
|
return new Response(ssrHTML, {
|
|
66
74
|
headers: {
|