meno-core 1.0.19 → 1.0.21
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/.claude/settings.local.json +7 -0
- package/lib/client/core/ComponentBuilder.test.ts +68 -56
- package/lib/client/core/ComponentBuilder.ts +6 -4
- package/lib/client/core/builders/embedBuilder.ts +10 -1
- package/lib/client/core/builders/index.ts +6 -2
- package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
- package/lib/client/responsiveStyleResolver.test.ts +12 -12
- package/lib/client/responsiveStyleResolver.ts +19 -7
- package/lib/client/routing/Router.tsx +35 -7
- package/lib/client/templateEngine.test.ts +126 -0
- package/lib/client/templateEngine.ts +53 -13
- package/lib/server/jsonLoader.test.ts +4 -1
- package/lib/server/jsonLoader.ts +64 -15
- package/lib/server/services/configService.ts +68 -13
- package/lib/server/ssr/attributeBuilder.ts +8 -0
- package/lib/server/ssr/index.ts +1 -1
- package/lib/server/ssr/ssrRenderer.ts +245 -111
- package/lib/server/ssrRenderer.test.ts +197 -3
- package/lib/server/validateStyleCoverage.ts +14 -17
- package/lib/shared/breakpoints.test.ts +210 -23
- package/lib/shared/breakpoints.ts +124 -17
- package/lib/shared/constants.test.ts +1 -1
- package/lib/shared/constants.ts +5 -1
- package/lib/shared/cssGeneration.test.ts +17 -0
- package/lib/shared/cssGeneration.ts +49 -12
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.test.ts +44 -2
- package/lib/shared/itemTemplateUtils.ts +15 -2
- package/lib/shared/nodeUtils.ts +23 -4
- package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
- package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
- package/lib/shared/registry/nodeTypes/index.ts +6 -5
- package/lib/shared/responsiveScaling.test.ts +87 -0
- package/lib/shared/responsiveScaling.ts +33 -29
- package/lib/shared/responsiveStyleUtils.test.ts +7 -7
- package/lib/shared/responsiveStyleUtils.ts +22 -16
- package/lib/shared/styleNodeUtils.ts +5 -5
- package/lib/shared/styleValueRegistry.ts +60 -5
- package/lib/shared/tree/PathBuilder.ts +3 -3
- package/lib/shared/treePathUtils.ts +7 -5
- package/lib/shared/types/cms.ts +4 -57
- package/lib/shared/types/components.ts +45 -4
- package/lib/shared/types/index.ts +13 -0
- package/lib/shared/utilityClassConfig.ts +14 -0
- package/lib/shared/utilityClassMapper.ts +43 -2
- package/lib/shared/validation/propValidator.ts +9 -1
- package/lib/shared/validation/schemas.ts +60 -14
- package/package.json +1 -1
- package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Handles rendering of
|
|
2
|
+
* Unified List Node Builder
|
|
3
|
+
* Handles rendering of list nodes with item iteration from either:
|
|
4
|
+
* - Component props (sourceType: 'prop')
|
|
5
|
+
* - CMS collections (sourceType: 'collection')
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import { createElement as h } from "react";
|
|
7
9
|
import type { ReactElement } from "react";
|
|
8
|
-
import type {
|
|
10
|
+
import type { ListNode } from "../../../shared/registry/nodeTypes/ListNodeType";
|
|
11
|
+
import type { CMSItem, CMSFilterCondition, CMSSortConfig, CMSFilterOperator } from "../../../shared/types/cms";
|
|
9
12
|
import type { InteractiveStyles, StyleObject, ResponsiveStyleObject } from "../../../shared/types";
|
|
10
13
|
import { singularize } from "../../../shared/types/cms";
|
|
11
14
|
import { buildTemplateContext, resolveItemsTemplate, getNestedValue } from "../../../shared/itemTemplateUtils";
|
|
12
15
|
import type { TemplateContext } from "../../../shared/types/cms";
|
|
13
|
-
import {
|
|
16
|
+
import { pathToString, getChildPath } from "../../../shared/pathArrayUtils";
|
|
14
17
|
import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
|
|
15
|
-
import {
|
|
18
|
+
import { extractAttributesFromNode } from "../../../shared/attributeNodeUtils";
|
|
16
19
|
import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
|
|
17
20
|
import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
|
|
18
21
|
import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
|
|
19
22
|
import type { ElementRegistry } from "../../elementRegistry";
|
|
20
23
|
import type { BuilderContext, BuildResult, BuildChildrenContext } from "./types";
|
|
21
24
|
|
|
22
|
-
export interface
|
|
25
|
+
export interface ListBuilderDeps {
|
|
23
26
|
elementRegistry: ElementRegistry;
|
|
24
27
|
getCachedStyleClasses: (style: object) => string[];
|
|
25
28
|
buildChildren: (
|
|
@@ -37,13 +40,13 @@ export interface CMSListBuilderDeps {
|
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
|
-
* Build a
|
|
43
|
+
* Build a unified list node - handles both prop and collection source types
|
|
41
44
|
*/
|
|
42
|
-
export function
|
|
43
|
-
node:
|
|
45
|
+
export function buildList(
|
|
46
|
+
node: ListNode,
|
|
44
47
|
children: unknown,
|
|
45
48
|
ctx: BuilderContext,
|
|
46
|
-
deps:
|
|
49
|
+
deps: ListBuilderDeps
|
|
47
50
|
): ReactElement {
|
|
48
51
|
const {
|
|
49
52
|
key, elementPath, parentComponentName, viewportWidth, componentContext, componentRootPath,
|
|
@@ -51,17 +54,27 @@ export function buildCMSList(
|
|
|
51
54
|
cmsItemIndexPath, cmsListPaths, templateContext, componentResolvedProps
|
|
52
55
|
} = ctx;
|
|
53
56
|
|
|
57
|
+
// Determine source type (default to 'prop', but handle legacy 'cms-list' type)
|
|
58
|
+
const nodeType = (node as any).type;
|
|
59
|
+
const isLegacyCMSList = nodeType === 'cms-list';
|
|
60
|
+
const sourceType = isLegacyCMSList ? 'collection' : (node.sourceType || 'prop');
|
|
61
|
+
const isCollectionMode = sourceType === 'collection';
|
|
62
|
+
|
|
63
|
+
// Get source - handle both new 'source' and legacy 'collection' property
|
|
64
|
+
// Source can be a string (prop name) or array (pre-resolved by processStructure)
|
|
65
|
+
const rawSource = node.source || (node as any).collection;
|
|
66
|
+
const source = typeof rawSource === 'string' ? rawSource : '';
|
|
67
|
+
const sourceIsResolved = Array.isArray(rawSource);
|
|
68
|
+
|
|
54
69
|
const effectiveParentComponentName = deps.getEffectiveParentComponentName(componentContext, parentComponentName);
|
|
55
70
|
|
|
56
71
|
// Extract attributes
|
|
57
72
|
const extractedAttributes = extractAttributesFromNode(node as any);
|
|
58
73
|
|
|
59
|
-
// Build
|
|
60
|
-
const
|
|
74
|
+
// Build container props with appropriate data attributes
|
|
75
|
+
const containerProps: Record<string, any> = {
|
|
61
76
|
key,
|
|
62
77
|
'data-element-path': pathToString(elementPath),
|
|
63
|
-
'data-cms-list': 'true',
|
|
64
|
-
'data-collection': node.collection || '',
|
|
65
78
|
ref: (el: HTMLElement | null) => {
|
|
66
79
|
deps.elementRegistry.register(elementPath, el, effectiveParentComponentName, false);
|
|
67
80
|
// Apply CSS variables for interactive styles
|
|
@@ -76,16 +89,26 @@ export function buildCMSList(
|
|
|
76
89
|
}
|
|
77
90
|
};
|
|
78
91
|
|
|
92
|
+
// Set data attributes based on source type
|
|
93
|
+
// All list types need data-cms-list for click handler to find them
|
|
94
|
+
containerProps['data-cms-list'] = 'true';
|
|
95
|
+
if (isCollectionMode) {
|
|
96
|
+
containerProps['data-collection'] = source || '';
|
|
97
|
+
} else {
|
|
98
|
+
containerProps['data-list'] = 'true';
|
|
99
|
+
containerProps['data-source'] = source || (sourceIsResolved ? 'resolved' : '');
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
// Start building className
|
|
80
103
|
let classNames: string[] = [];
|
|
81
104
|
|
|
82
|
-
//
|
|
105
|
+
// Apply styles
|
|
83
106
|
if (node.style) {
|
|
84
107
|
const utilityClasses = deps.getCachedStyleClasses(node.style);
|
|
85
108
|
classNames.push(...utilityClasses);
|
|
86
109
|
}
|
|
87
110
|
|
|
88
|
-
// Handle interactive styles
|
|
111
|
+
// Handle interactive styles
|
|
89
112
|
const nodeInteractiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
|
|
90
113
|
const nodeGenerateElementClass = node.generateElementClass;
|
|
91
114
|
const nodeLabel = node.label;
|
|
@@ -148,42 +171,179 @@ export function buildCMSList(
|
|
|
148
171
|
|
|
149
172
|
// Set final className
|
|
150
173
|
if (classNames.length > 0) {
|
|
151
|
-
|
|
174
|
+
containerProps.className = classNames.filter(Boolean).join(' ');
|
|
152
175
|
}
|
|
153
176
|
|
|
154
177
|
// Add extracted attributes
|
|
155
178
|
if (Object.keys(extractedAttributes).length > 0) {
|
|
156
|
-
Object.assign(
|
|
179
|
+
Object.assign(containerProps, extractedAttributes);
|
|
157
180
|
}
|
|
158
181
|
|
|
159
182
|
// Add parent component context
|
|
160
183
|
if (effectiveParentComponentName) {
|
|
161
|
-
|
|
184
|
+
containerProps['data-parent-component'] = effectiveParentComponentName;
|
|
162
185
|
}
|
|
163
186
|
if (componentContext) {
|
|
164
|
-
|
|
187
|
+
containerProps['data-component-context'] = componentContext;
|
|
165
188
|
}
|
|
166
189
|
|
|
167
|
-
//
|
|
168
|
-
|
|
190
|
+
// Get items based on source type
|
|
191
|
+
let itemsToRender: unknown[];
|
|
169
192
|
|
|
170
|
-
|
|
171
|
-
|
|
193
|
+
if (isCollectionMode) {
|
|
194
|
+
itemsToRender = getCollectionItems(node, source, ctx);
|
|
195
|
+
} else {
|
|
196
|
+
// Prop mode: Read from component props or template context
|
|
197
|
+
// If source was already resolved to an array by processStructure (e.g., from {{features}}),
|
|
198
|
+
// use it directly instead of looking up by prop name
|
|
199
|
+
if (sourceIsResolved) {
|
|
200
|
+
itemsToRender = rawSource as unknown[];
|
|
201
|
+
} else if (source) {
|
|
202
|
+
itemsToRender = getPropItems(source, ctx);
|
|
203
|
+
} else {
|
|
204
|
+
itemsToRender = [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Determine variable name for this list's items
|
|
209
|
+
let variableName: string;
|
|
210
|
+
if (node.itemAs) {
|
|
211
|
+
variableName = node.itemAs;
|
|
212
|
+
} else if (isCollectionMode) {
|
|
213
|
+
variableName = singularize(source);
|
|
214
|
+
} else {
|
|
215
|
+
variableName = 'item';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Different colors for collection vs prop modes
|
|
219
|
+
const bgColor = isCollectionMode ? 'rgba(139, 92, 246, 0.05)' : 'rgba(59, 130, 246, 0.05)';
|
|
220
|
+
const borderColor = isCollectionMode ? 'rgba(139, 92, 246, 0.3)' : 'rgba(59, 130, 246, 0.3)';
|
|
221
|
+
const textColor = isCollectionMode ? '#8b5cf6' : '#3b82f6';
|
|
222
|
+
const label = isCollectionMode ? 'CMS List' : 'List';
|
|
223
|
+
|
|
224
|
+
// Use configurable tag (defaults to 'div') - declared early for all return paths
|
|
225
|
+
const tag = node.tag || 'div';
|
|
226
|
+
|
|
227
|
+
if (!source && !sourceIsResolved) {
|
|
228
|
+
// No source - render empty container with placeholder
|
|
229
|
+
const emptyState = h('div', {
|
|
230
|
+
key: 'list-empty',
|
|
231
|
+
style: {
|
|
232
|
+
padding: '12px',
|
|
233
|
+
background: bgColor,
|
|
234
|
+
border: `1px dashed ${borderColor}`,
|
|
235
|
+
borderRadius: '4px',
|
|
236
|
+
color: textColor,
|
|
237
|
+
fontSize: '12px',
|
|
238
|
+
fontFamily: 'system-ui, sans-serif',
|
|
239
|
+
textAlign: 'center'
|
|
240
|
+
}
|
|
241
|
+
}, `${label}: No source - No items`);
|
|
242
|
+
return h(tag, containerProps, emptyState);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (itemsToRender.length === 0) {
|
|
246
|
+
// No items - render empty container with placeholder
|
|
247
|
+
const emptyState = h('div', {
|
|
248
|
+
key: 'list-empty',
|
|
249
|
+
style: {
|
|
250
|
+
padding: '12px',
|
|
251
|
+
background: bgColor,
|
|
252
|
+
border: `1px dashed ${borderColor}`,
|
|
253
|
+
borderRadius: '4px',
|
|
254
|
+
color: textColor,
|
|
255
|
+
fontSize: '12px',
|
|
256
|
+
fontFamily: 'system-ui, sans-serif',
|
|
257
|
+
textAlign: 'center'
|
|
258
|
+
}
|
|
259
|
+
}, `${label}: ${source || 'resolved'} - No items`);
|
|
260
|
+
return h(tag, containerProps, emptyState);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Render children for each item
|
|
264
|
+
const renderedItems: (ReactElement | string | number)[] = [];
|
|
265
|
+
|
|
266
|
+
for (let index = 0; index < itemsToRender.length; index++) {
|
|
267
|
+
const item = itemsToRender[index];
|
|
268
|
+
// Build template context for this item
|
|
269
|
+
const itemTemplateContext = buildTemplateContext(
|
|
270
|
+
variableName,
|
|
271
|
+
item as CMSItem,
|
|
272
|
+
index,
|
|
273
|
+
itemsToRender.length,
|
|
274
|
+
templateContext || undefined
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Build children with item context
|
|
278
|
+
// Track item index for BOTH collection and prop mode (enables selection of individual list items)
|
|
279
|
+
const newIndexPath = [...(cmsItemIndexPath || []), index];
|
|
280
|
+
const newListPaths = [...(cmsListPaths || []), elementPath];
|
|
281
|
+
|
|
282
|
+
const itemChildren = deps.buildChildren(children, {
|
|
283
|
+
elementPath,
|
|
284
|
+
parentComponentName: effectiveParentComponentName,
|
|
285
|
+
viewportWidth,
|
|
286
|
+
componentContext,
|
|
287
|
+
componentRootPath,
|
|
288
|
+
locale,
|
|
289
|
+
i18nConfig,
|
|
290
|
+
cmsContext,
|
|
291
|
+
cmsLocale,
|
|
292
|
+
collectionItemsMap,
|
|
293
|
+
itemContext: null,
|
|
294
|
+
cmsItemIndexPath: newIndexPath,
|
|
295
|
+
cmsListPaths: newListPaths,
|
|
296
|
+
templateContext: itemTemplateContext,
|
|
297
|
+
componentResolvedProps
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Add children directly (no wrapper div)
|
|
301
|
+
if (itemChildren === null) continue;
|
|
302
|
+
if (Array.isArray(itemChildren)) {
|
|
303
|
+
// Add unique keys by combining original key with item index
|
|
304
|
+
for (const child of itemChildren) {
|
|
305
|
+
if (typeof child === 'object' && child !== null && 'key' in child) {
|
|
306
|
+
renderedItems.push({ ...child, key: `${child.key}-item-${index}` } as ReactElement);
|
|
307
|
+
} else {
|
|
308
|
+
renderedItems.push(child);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} else if (typeof itemChildren === 'object' && itemChildren !== null && 'key' in itemChildren) {
|
|
312
|
+
// Single child - ensure unique key
|
|
313
|
+
renderedItems.push({ ...itemChildren, key: `${(itemChildren as ReactElement).key}-item-${index}` } as ReactElement);
|
|
314
|
+
} else {
|
|
315
|
+
renderedItems.push(itemChildren);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return h(tag, containerProps, renderedItems);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Item Fetching Functions
|
|
324
|
+
// ============================================================================
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Get items from CMS collection (for sourceType: 'collection')
|
|
328
|
+
*/
|
|
329
|
+
function getCollectionItems(node: ListNode, source: string, ctx: BuilderContext): CMSItem[] {
|
|
330
|
+
const { collectionItemsMap = {}, cmsContext, templateContext } = ctx;
|
|
331
|
+
const collectionItems = source ? (collectionItemsMap[source] || []) : [];
|
|
172
332
|
|
|
173
333
|
// Helper to lookup items by ID or filename
|
|
174
|
-
const lookupItemsByIds = (ids: string[],
|
|
175
|
-
const itemMap = new Map(
|
|
176
|
-
const filenameMap = new Map(
|
|
334
|
+
const lookupItemsByIds = (ids: string[], items: CMSItem[]): CMSItem[] => {
|
|
335
|
+
const itemMap = new Map(items.map(item => [item._id, item]));
|
|
336
|
+
const filenameMap = new Map(items.map(item => [item._filename, item]));
|
|
177
337
|
return ids
|
|
178
338
|
.filter(Boolean)
|
|
179
339
|
.map(id => itemMap.get(id) || filenameMap.get(id))
|
|
180
340
|
.filter((item): item is CMSItem => item !== undefined);
|
|
181
341
|
};
|
|
182
342
|
|
|
343
|
+
let itemsToRender: CMSItem[] = [];
|
|
344
|
+
|
|
183
345
|
// Check if items are specified directly (for nested reference lists)
|
|
184
346
|
if (node.items) {
|
|
185
|
-
const collectionItems = node.collection ? (collectionItemsMap[node.collection] || []) : [];
|
|
186
|
-
|
|
187
347
|
// If items is a template expression, resolve it from current context
|
|
188
348
|
if (typeof node.items === 'string' && node.items.startsWith('{{')) {
|
|
189
349
|
let resolvedIds: string | string[] | undefined;
|
|
@@ -204,7 +364,7 @@ export function buildCMSList(
|
|
|
204
364
|
resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
|
|
205
365
|
}
|
|
206
366
|
} else {
|
|
207
|
-
// Otherwise resolve from template context (for nested
|
|
367
|
+
// Otherwise resolve from template context (for nested lists)
|
|
208
368
|
const effectiveTemplateContext = templateContext || { _type: 'template' as const };
|
|
209
369
|
resolvedIds = resolveItemsTemplate(node.items, effectiveTemplateContext);
|
|
210
370
|
}
|
|
@@ -221,19 +381,18 @@ export function buildCMSList(
|
|
|
221
381
|
|
|
222
382
|
// Apply filters and sorting to referenced items
|
|
223
383
|
if (node.filter) {
|
|
224
|
-
itemsToRender = applyClientFilters(itemsToRender, node.filter, cmsContext ?? undefined, templateContext ?? undefined);
|
|
384
|
+
itemsToRender = applyClientFilters(itemsToRender, node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown>, cmsContext ?? undefined, templateContext ?? undefined);
|
|
225
385
|
}
|
|
226
386
|
if (node.sort) {
|
|
227
387
|
itemsToRender = applyClientSorting(itemsToRender, node.sort);
|
|
228
388
|
}
|
|
229
389
|
} else {
|
|
230
390
|
// Get items for this collection from the map
|
|
231
|
-
|
|
232
|
-
itemsToRender = collectionItems;
|
|
391
|
+
itemsToRender = [...collectionItems];
|
|
233
392
|
|
|
234
393
|
// Apply filters (BEFORE offset/limit)
|
|
235
394
|
if (node.filter) {
|
|
236
|
-
itemsToRender = applyClientFilters(itemsToRender, node.filter, cmsContext ?? undefined, templateContext ?? undefined);
|
|
395
|
+
itemsToRender = applyClientFilters(itemsToRender, node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown>, cmsContext ?? undefined, templateContext ?? undefined);
|
|
237
396
|
}
|
|
238
397
|
|
|
239
398
|
// Apply sorting (BEFORE offset/limit)
|
|
@@ -256,73 +415,46 @@ export function buildCMSList(
|
|
|
256
415
|
itemsToRender = itemsToRender.filter(item => item._id !== currentId);
|
|
257
416
|
}
|
|
258
417
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
418
|
+
return itemsToRender;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get items from component props or template context (for sourceType: 'prop')
|
|
423
|
+
*/
|
|
424
|
+
function getPropItems(source: string, ctx: BuilderContext): unknown[] {
|
|
425
|
+
const { componentResolvedProps, templateContext, cmsContext } = ctx;
|
|
426
|
+
|
|
427
|
+
// Check if source is a template expression
|
|
428
|
+
if (source.startsWith('{{') && source.endsWith('}}')) {
|
|
429
|
+
// Template expression - resolve from template context (for nested lists)
|
|
430
|
+
// e.g., {{category.items}} where category is from parent list
|
|
431
|
+
if (templateContext) {
|
|
432
|
+
const path = source.slice(2, -2).trim(); // Extract "category.items"
|
|
433
|
+
const resolved = getNestedValue(templateContext as Record<string, unknown>, path);
|
|
434
|
+
if (Array.isArray(resolved)) {
|
|
435
|
+
return resolved;
|
|
272
436
|
}
|
|
273
|
-
}
|
|
274
|
-
return
|
|
437
|
+
}
|
|
438
|
+
return [];
|
|
275
439
|
}
|
|
276
440
|
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
item,
|
|
285
|
-
index,
|
|
286
|
-
itemsToRender.length,
|
|
287
|
-
templateContext || undefined
|
|
288
|
-
);
|
|
289
|
-
|
|
290
|
-
// Build children with templateContext (contains both named and legacy context)
|
|
291
|
-
// Append current index to existing path to support nested CMS lists
|
|
292
|
-
const newIndexPath = [...(cmsItemIndexPath || []), index];
|
|
293
|
-
// Also track the CMS list element paths (parallel array to newIndexPath)
|
|
294
|
-
const newListPaths = [...(cmsListPaths || []), elementPath];
|
|
295
|
-
const itemChildren = deps.buildChildren(children, {
|
|
296
|
-
elementPath, parentComponentName: effectiveParentComponentName, viewportWidth, componentContext, componentRootPath,
|
|
297
|
-
locale, i18nConfig, cmsContext, cmsLocale, collectionItemsMap,
|
|
298
|
-
itemContext: null, // itemContext no longer needed - templateContext has all data
|
|
299
|
-
cmsItemIndexPath: newIndexPath, // Pass updated path - will be applied to all descendants via ref callback
|
|
300
|
-
cmsListPaths: newListPaths, // Pass updated list paths - enables full CMS context on elements
|
|
301
|
-
templateContext: newTemplateContext, // Pass named template context for nested lists
|
|
302
|
-
componentResolvedProps
|
|
303
|
-
});
|
|
441
|
+
// Direct prop name - resolve from component props
|
|
442
|
+
if (componentResolvedProps) {
|
|
443
|
+
const propValue = componentResolvedProps[source];
|
|
444
|
+
if (Array.isArray(propValue)) {
|
|
445
|
+
return propValue;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
304
448
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if (Array.isArray(
|
|
309
|
-
|
|
310
|
-
for (const child of itemChildren) {
|
|
311
|
-
if (typeof child === 'object' && child !== null && 'key' in child) {
|
|
312
|
-
renderedItems.push({ ...child, key: `${child.key}-item-${index}` } as ReactElement);
|
|
313
|
-
} else {
|
|
314
|
-
renderedItems.push(child);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
} else if (typeof itemChildren === 'object' && itemChildren !== null && 'key' in itemChildren) {
|
|
318
|
-
// Single child - ensure unique key
|
|
319
|
-
renderedItems.push({ ...itemChildren, key: `${(itemChildren as ReactElement).key}-item-${index}` } as ReactElement);
|
|
320
|
-
} else {
|
|
321
|
-
renderedItems.push(itemChildren);
|
|
449
|
+
// Also try CMS context for prop mode (for cms context values)
|
|
450
|
+
if (cmsContext) {
|
|
451
|
+
const cmsValue = (cmsContext as Record<string, unknown>)[source];
|
|
452
|
+
if (Array.isArray(cmsValue)) {
|
|
453
|
+
return cmsValue;
|
|
322
454
|
}
|
|
323
455
|
}
|
|
324
456
|
|
|
325
|
-
return
|
|
457
|
+
return [];
|
|
326
458
|
}
|
|
327
459
|
|
|
328
460
|
// ============================================================================
|
|
@@ -387,7 +519,7 @@ function resolveTemplateValue(
|
|
|
387
519
|
return result;
|
|
388
520
|
}
|
|
389
521
|
|
|
390
|
-
// Handle {{itemAs.field}} - resolve from template context (for nested
|
|
522
|
+
// Handle {{itemAs.field}} - resolve from template context (for nested lists)
|
|
391
523
|
if (templateContext) {
|
|
392
524
|
const result = getNestedValue(templateContext as Record<string, unknown>, path);
|
|
393
525
|
if (result !== undefined) {
|
|
@@ -54,8 +54,8 @@ describe("Responsive Style Resolver - getBreakpointName", () => {
|
|
|
54
54
|
test("should use custom breakpoints when provided", () => {
|
|
55
55
|
const viewportWidth = 800;
|
|
56
56
|
const customBreakpoints = {
|
|
57
|
-
tablet: 900,
|
|
58
|
-
mobile: 400
|
|
57
|
+
tablet: { breakpoint: 900, previewPoint: 768 },
|
|
58
|
+
mobile: { breakpoint: 400, previewPoint: 375 }
|
|
59
59
|
};
|
|
60
60
|
const result = getBreakpointName(viewportWidth, customBreakpoints);
|
|
61
61
|
expect(result).toBe("tablet");
|
|
@@ -64,13 +64,13 @@ describe("Responsive Style Resolver - getBreakpointName", () => {
|
|
|
64
64
|
|
|
65
65
|
describe("Edge cases", () => {
|
|
66
66
|
test("should handle exact breakpoint boundaries", () => {
|
|
67
|
-
const tabletWidth = DEFAULT_BREAKPOINTS.tablet;
|
|
68
|
-
const mobileWidth = DEFAULT_BREAKPOINTS.mobile;
|
|
69
|
-
|
|
67
|
+
const tabletWidth = DEFAULT_BREAKPOINTS.tablet.breakpoint;
|
|
68
|
+
const mobileWidth = DEFAULT_BREAKPOINTS.mobile.breakpoint;
|
|
69
|
+
|
|
70
70
|
expect(getBreakpointName(tabletWidth)).toBe("tablet");
|
|
71
71
|
expect(getBreakpointName(tabletWidth - 1)).toBe("tablet");
|
|
72
72
|
expect(getBreakpointName(tabletWidth + 1)).toBe("base");
|
|
73
|
-
|
|
73
|
+
|
|
74
74
|
expect(getBreakpointName(mobileWidth)).toBe("mobile");
|
|
75
75
|
expect(getBreakpointName(mobileWidth - 1)).toBe("mobile");
|
|
76
76
|
expect(getBreakpointName(mobileWidth + 1)).toBe("tablet");
|
|
@@ -301,9 +301,9 @@ describe("Responsive Style Resolver - resolveResponsiveStyleSync", () => {
|
|
|
301
301
|
base: { fontSize: "16px" },
|
|
302
302
|
tablet: { fontSize: "14px" }
|
|
303
303
|
};
|
|
304
|
-
|
|
305
|
-
const result = resolveResponsiveStyleSync(style, DEFAULT_BREAKPOINTS.tablet);
|
|
306
|
-
|
|
304
|
+
|
|
305
|
+
const result = resolveResponsiveStyleSync(style, DEFAULT_BREAKPOINTS.tablet.breakpoint);
|
|
306
|
+
|
|
307
307
|
expect(result.fontSize).toBe("14px");
|
|
308
308
|
});
|
|
309
309
|
|
|
@@ -313,9 +313,9 @@ describe("Responsive Style Resolver - resolveResponsiveStyleSync", () => {
|
|
|
313
313
|
tablet: { fontSize: "14px" },
|
|
314
314
|
mobile: { fontSize: "12px" }
|
|
315
315
|
};
|
|
316
|
-
|
|
317
|
-
const result = resolveResponsiveStyleSync(style, DEFAULT_BREAKPOINTS.mobile);
|
|
318
|
-
|
|
316
|
+
|
|
317
|
+
const result = resolveResponsiveStyleSync(style, DEFAULT_BREAKPOINTS.mobile.breakpoint);
|
|
318
|
+
|
|
319
319
|
expect(result.fontSize).toBe("12px");
|
|
320
320
|
});
|
|
321
321
|
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ResponsiveStyleObject, StyleObject } from '../shared/types';
|
|
7
|
-
import type { BreakpointConfig } from '../shared/breakpoints';
|
|
8
|
-
import { DEFAULT_BREAKPOINTS } from '../shared/breakpoints';
|
|
7
|
+
import type { BreakpointConfig, BreakpointConfigInput, BreakpointEntry } from '../shared/breakpoints';
|
|
8
|
+
import { DEFAULT_BREAKPOINTS, normalizeBreakpointConfig } from '../shared/breakpoints';
|
|
9
9
|
import type { ResponsiveScales } from '../shared/responsiveScaling';
|
|
10
10
|
import { DEFAULT_RESPONSIVE_SCALES } from '../shared/responsiveScaling';
|
|
11
11
|
import { isResponsiveStyle } from '../shared/styleUtils';
|
|
@@ -81,16 +81,28 @@ async function loadConfig(): Promise<void> {
|
|
|
81
81
|
const response = await fetch('/api/config');
|
|
82
82
|
const config = await response.json();
|
|
83
83
|
|
|
84
|
-
// Parse breakpoints
|
|
84
|
+
// Parse breakpoints - supports both legacy and new format
|
|
85
85
|
if (config.breakpoints && typeof config.breakpoints === 'object') {
|
|
86
|
-
const
|
|
86
|
+
const validInput: BreakpointConfigInput = {};
|
|
87
87
|
for (const [key, value] of Object.entries(config.breakpoints)) {
|
|
88
88
|
if (typeof value === 'number' && value > 0) {
|
|
89
|
-
|
|
89
|
+
// Legacy format: number
|
|
90
|
+
validInput[key] = value;
|
|
91
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
92
|
+
// New format: object with breakpoint and optional previewPoint
|
|
93
|
+
const entry = value as BreakpointEntry;
|
|
94
|
+
if (typeof entry.breakpoint === 'number' && entry.breakpoint > 0) {
|
|
95
|
+
validInput[key] = {
|
|
96
|
+
breakpoint: entry.breakpoint,
|
|
97
|
+
previewPoint: typeof entry.previewPoint === 'number' && entry.previewPoint > 0
|
|
98
|
+
? entry.previewPoint
|
|
99
|
+
: entry.breakpoint,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
|
-
breakpointConfig = Object.keys(
|
|
93
|
-
?
|
|
104
|
+
breakpointConfig = Object.keys(validInput).length > 0
|
|
105
|
+
? normalizeBreakpointConfig(validInput)
|
|
94
106
|
: { ...DEFAULT_BREAKPOINTS };
|
|
95
107
|
} else {
|
|
96
108
|
breakpointConfig = { ...DEFAULT_BREAKPOINTS };
|
|
@@ -119,6 +119,8 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
119
119
|
const [componentTree, setComponentTree] = useState<ComponentNode | ComponentNode[] | null>(null);
|
|
120
120
|
// Keep previous componentTree to show during transitions
|
|
121
121
|
const [previousComponentTree, setPreviousComponentTree] = useState<ComponentNode | ComponentNode[] | null>(null);
|
|
122
|
+
// Preview component tree (for hover preview from editor - temporarily overrides componentTree)
|
|
123
|
+
const [previewComponentTree, setPreviewComponentTree] = useState<ComponentNode | ComponentNode[] | null>(null);
|
|
122
124
|
// Ref to track current componentTree for closure access (needed for capturing current value in async functions)
|
|
123
125
|
const componentTreeRef = useRef<ComponentNode | ComponentNode[] | null>(null);
|
|
124
126
|
const [loading, setLoading] = useState(getInitialLoadingState(hasSSRContent));
|
|
@@ -270,6 +272,28 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
270
272
|
return () => window.removeEventListener('message', handleMessage);
|
|
271
273
|
}, []);
|
|
272
274
|
|
|
275
|
+
// Listen for page data preview updates from parent window (editor)
|
|
276
|
+
// Used for component hover preview in the components tab
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (typeof window === 'undefined') return;
|
|
279
|
+
|
|
280
|
+
const handleMessage = (event: MessageEvent) => {
|
|
281
|
+
if (event.data?.type === IFRAME_MESSAGE_TYPES.PAGE_DATA_PREVIEW) {
|
|
282
|
+
// Set preview tree from pageData.root
|
|
283
|
+
const pageData = event.data.pageData;
|
|
284
|
+
if (pageData?.root) {
|
|
285
|
+
setPreviewComponentTree(pageData.root);
|
|
286
|
+
}
|
|
287
|
+
} else if (event.data?.type === IFRAME_MESSAGE_TYPES.PAGE_DATA_PREVIEW_REVERT) {
|
|
288
|
+
// Clear preview tree to revert to normal componentTree
|
|
289
|
+
setPreviewComponentTree(null);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
window.addEventListener('message', handleMessage);
|
|
294
|
+
return () => window.removeEventListener('message', handleMessage);
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
273
297
|
// Request CMS context from parent when iframe is ready
|
|
274
298
|
// Don't block rendering - just request context and re-render when it arrives
|
|
275
299
|
useEffect(() => {
|
|
@@ -282,8 +306,10 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
282
306
|
}, []);
|
|
283
307
|
|
|
284
308
|
// Inject CSS and execute JavaScript after component tree is rendered
|
|
309
|
+
// Include previewComponentTree to ensure styles are injected for hover previews
|
|
285
310
|
useEffect(() => {
|
|
286
|
-
|
|
311
|
+
const treeToRender = previewComponentTree || componentTree;
|
|
312
|
+
if (treeToRender) {
|
|
287
313
|
// Inject CSS immediately
|
|
288
314
|
services.styleInjector.inject();
|
|
289
315
|
|
|
@@ -313,7 +339,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
313
339
|
// Clear registry when tree is cleared (no elements to register)
|
|
314
340
|
elementRegistry.clear();
|
|
315
341
|
}
|
|
316
|
-
}, [componentTree, services, disableScripts]);
|
|
342
|
+
}, [previewComponentTree, componentTree, services, disableScripts]);
|
|
317
343
|
|
|
318
344
|
// Prefetch all internal links when using 'load' strategy
|
|
319
345
|
useEffect(() => {
|
|
@@ -415,13 +441,15 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
415
441
|
};
|
|
416
442
|
}, [services]);
|
|
417
443
|
|
|
418
|
-
// Render logic: prioritize current tree, then previous tree during loading, then loading/not found states
|
|
444
|
+
// Render logic: prioritize preview tree, then current tree, then previous tree during loading, then loading/not found states
|
|
445
|
+
// Use preview tree if set (from component hover preview), otherwise use current tree
|
|
446
|
+
const treeToRender = previewComponentTree || componentTree;
|
|
419
447
|
let pageContent: ReactElement | null = null;
|
|
420
448
|
|
|
421
|
-
if (
|
|
422
|
-
// Show current tree when we have it and CMS context is ready (or not needed)
|
|
449
|
+
if (treeToRender && !awaitingCmsContext) {
|
|
450
|
+
// Show current/preview tree when we have it and CMS context is ready (or not needed)
|
|
423
451
|
pageContent = renderPage({
|
|
424
|
-
tree:
|
|
452
|
+
tree: treeToRender,
|
|
425
453
|
currentPath,
|
|
426
454
|
viewportWidth,
|
|
427
455
|
componentBuilder: services.componentBuilder,
|
|
@@ -432,7 +460,7 @@ export function Router(props: RouterProps = {}): ReactElement {
|
|
|
432
460
|
collectionItemsMap,
|
|
433
461
|
templateContext: null
|
|
434
462
|
});
|
|
435
|
-
} else if (
|
|
463
|
+
} else if (treeToRender && awaitingCmsContext && previousComponentTree) {
|
|
436
464
|
// While waiting for CMS context, show previous tree to avoid blink
|
|
437
465
|
pageContent = renderPage({
|
|
438
466
|
tree: previousComponentTree,
|