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 +82 -9
- 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/scripts/ScriptExecutor.test.ts +5 -5
- 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 +169 -21
- package/lib/server/ssr/index.ts +1 -0
- 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
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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]);
|
|
@@ -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 =
|
|
449
|
-
const mockElement2 =
|
|
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
|
|
460
|
-
// DynamicComponent: 2
|
|
461
|
-
expect(
|
|
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 -
|
|
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;
|