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.
Files changed (50) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/lib/client/core/ComponentBuilder.test.ts +68 -56
  3. package/lib/client/core/ComponentBuilder.ts +6 -4
  4. package/lib/client/core/builders/embedBuilder.ts +10 -1
  5. package/lib/client/core/builders/index.ts +6 -2
  6. package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
  7. package/lib/client/responsiveStyleResolver.test.ts +12 -12
  8. package/lib/client/responsiveStyleResolver.ts +19 -7
  9. package/lib/client/routing/Router.tsx +35 -7
  10. package/lib/client/templateEngine.test.ts +126 -0
  11. package/lib/client/templateEngine.ts +53 -13
  12. package/lib/server/jsonLoader.test.ts +4 -1
  13. package/lib/server/jsonLoader.ts +64 -15
  14. package/lib/server/services/configService.ts +68 -13
  15. package/lib/server/ssr/attributeBuilder.ts +8 -0
  16. package/lib/server/ssr/index.ts +1 -1
  17. package/lib/server/ssr/ssrRenderer.ts +245 -111
  18. package/lib/server/ssrRenderer.test.ts +197 -3
  19. package/lib/server/validateStyleCoverage.ts +14 -17
  20. package/lib/shared/breakpoints.test.ts +210 -23
  21. package/lib/shared/breakpoints.ts +124 -17
  22. package/lib/shared/constants.test.ts +1 -1
  23. package/lib/shared/constants.ts +5 -1
  24. package/lib/shared/cssGeneration.test.ts +17 -0
  25. package/lib/shared/cssGeneration.ts +49 -12
  26. package/lib/shared/index.ts +3 -0
  27. package/lib/shared/itemTemplateUtils.test.ts +44 -2
  28. package/lib/shared/itemTemplateUtils.ts +15 -2
  29. package/lib/shared/nodeUtils.ts +23 -4
  30. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
  31. package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
  32. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
  33. package/lib/shared/registry/nodeTypes/index.ts +6 -5
  34. package/lib/shared/responsiveScaling.test.ts +87 -0
  35. package/lib/shared/responsiveScaling.ts +33 -29
  36. package/lib/shared/responsiveStyleUtils.test.ts +7 -7
  37. package/lib/shared/responsiveStyleUtils.ts +22 -16
  38. package/lib/shared/styleNodeUtils.ts +5 -5
  39. package/lib/shared/styleValueRegistry.ts +60 -5
  40. package/lib/shared/tree/PathBuilder.ts +3 -3
  41. package/lib/shared/treePathUtils.ts +7 -5
  42. package/lib/shared/types/cms.ts +4 -57
  43. package/lib/shared/types/components.ts +45 -4
  44. package/lib/shared/types/index.ts +13 -0
  45. package/lib/shared/utilityClassConfig.ts +14 -0
  46. package/lib/shared/utilityClassMapper.ts +43 -2
  47. package/lib/shared/validation/propValidator.ts +9 -1
  48. package/lib/shared/validation/schemas.ts +60 -14
  49. package/package.json +1 -1
  50. package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
@@ -1,25 +1,28 @@
1
1
  /**
2
- * CMS List Node Builder
3
- * Handles rendering of CMS list nodes with collection items
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 { CMSListNode, CMSItem, CMSFilterCondition, CMSSortConfig, CMSFilterOperator } from "../../../shared/types/cms";
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 { extractAttributesFromNode } from "../../../shared/attributeNodeUtils";
16
+ import { pathToString, getChildPath } from "../../../shared/pathArrayUtils";
14
17
  import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
15
- import { pathToString } from "../../../shared/pathArrayUtils";
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 CMSListBuilderDeps {
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 CMS list node
43
+ * Build a unified list node - handles both prop and collection source types
41
44
  */
42
- export function buildCMSList(
43
- node: CMSListNode,
45
+ export function buildList(
46
+ node: ListNode,
44
47
  children: unknown,
45
48
  ctx: BuilderContext,
46
- deps: CMSListBuilderDeps
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 CMS list container props
60
- const cmsListProps: Record<string, any> = {
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
- // Convert container styles to utility classes (using cache for performance)
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 (cast to InteractiveStyles since Zod-inferred type is slightly different)
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
- cmsListProps.className = classNames.filter(Boolean).join(' ');
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(cmsListProps, extractedAttributes);
179
+ Object.assign(containerProps, extractedAttributes);
157
180
  }
158
181
 
159
182
  // Add parent component context
160
183
  if (effectiveParentComponentName) {
161
- cmsListProps['data-parent-component'] = effectiveParentComponentName;
184
+ containerProps['data-parent-component'] = effectiveParentComponentName;
162
185
  }
163
186
  if (componentContext) {
164
- cmsListProps['data-component-context'] = componentContext;
187
+ containerProps['data-component-context'] = componentContext;
165
188
  }
166
189
 
167
- // Determine variable name for this list's items (use itemAs or singular of collection)
168
- const variableName = node.itemAs || singularize(node.collection);
190
+ // Get items based on source type
191
+ let itemsToRender: unknown[];
169
192
 
170
- // Get items for this collection
171
- let itemsToRender: CMSItem[] = [];
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[], collectionItems: CMSItem[]): CMSItem[] => {
175
- const itemMap = new Map(collectionItems.map(item => [item._id, item]));
176
- const filenameMap = new Map(collectionItems.map(item => [item._filename, item]));
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 cms-lists)
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
- const collectionItems = node.collection ? (collectionItemsMap[node.collection] || []) : [];
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
- // If no items, show empty state in editor
260
- if (itemsToRender.length === 0) {
261
- const emptyState = h('div', {
262
- key: 'cms-list-empty',
263
- style: {
264
- padding: '12px',
265
- background: 'rgba(139, 92, 246, 0.05)',
266
- border: '1px dashed rgba(139, 92, 246, 0.3)',
267
- borderRadius: '4px',
268
- color: '#8b5cf6',
269
- fontSize: '12px',
270
- fontFamily: 'system-ui, sans-serif',
271
- textAlign: 'center'
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
- }, `CMS List: ${node.collection || 'No collection'} - No items`);
274
- return h('div', cmsListProps, emptyState);
437
+ }
438
+ return [];
275
439
  }
276
440
 
277
- // Render children for each item - no wrapper div, cmsItemIndexPath flows to all descendants
278
- const renderedItems: (ReactElement | string | number)[] = [];
279
- for (let index = 0; index < itemsToRender.length; index++) {
280
- const item = itemsToRender[index];
281
- // Build template context with named variable (preserves parent context for nested lists)
282
- const newTemplateContext = buildTemplateContext(
283
- variableName,
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
- // Add children directly (no wrapper div)
306
- // Each child element will have data-cms-item-index attribute via ref callback
307
- if (itemChildren === null) continue;
308
- if (Array.isArray(itemChildren)) {
309
- // Add unique keys by combining original key with item index
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 h('div', cmsListProps, renderedItems);
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 cms-lists)
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 breakpoints: BreakpointConfig = {};
86
+ const validInput: BreakpointConfigInput = {};
87
87
  for (const [key, value] of Object.entries(config.breakpoints)) {
88
88
  if (typeof value === 'number' && value > 0) {
89
- breakpoints[key] = value;
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(breakpoints).length > 0
93
- ? breakpoints
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
- if (componentTree) {
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 (componentTree && !awaitingCmsContext) {
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: componentTree,
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 (componentTree && awaitingCmsContext && previousComponentTree) {
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,