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
@@ -4,17 +4,94 @@
4
4
  * Supports dynamic breakpoints from project.config.json
5
5
  */
6
6
 
7
- export const DEFAULT_BREAKPOINTS = {
8
- tablet: 1024,
9
- mobile: 540,
10
- } as const;
7
+ /**
8
+ * Extended breakpoint entry with separate CSS threshold and editor preview width
9
+ * - breakpoint: The CSS media query threshold (max-width value)
10
+ * - previewPoint: The width used in editor preview mode (defaults to breakpoint if not specified)
11
+ * - label: Optional display label for UI (e.g., "Tablet Landscape", "Phone")
12
+ */
13
+ export interface BreakpointEntry {
14
+ breakpoint: number;
15
+ previewPoint: number;
16
+ label?: string;
17
+ }
18
+
19
+ /**
20
+ * Input format for breakpoint config - supports both legacy (number) and new (object) format
21
+ * Legacy: { tablet: 1024 }
22
+ * New: { tablet: { breakpoint: 1024, previewPoint: 768 } }
23
+ */
24
+ export type BreakpointConfigInput = Record<string, number | BreakpointEntry>;
25
+
26
+ /**
27
+ * Normalized breakpoint config - always uses object format
28
+ */
29
+ export type BreakpointConfig = Record<string, BreakpointEntry>;
11
30
 
12
- // BreakpointConfig now supports any breakpoint names from config
13
- export type BreakpointConfig = Record<string, number>;
31
+ /**
32
+ * Legacy format for backward compatibility (simple number values)
33
+ */
34
+ export type LegacyBreakpointConfig = Record<string, number>;
14
35
 
15
36
  // BreakpointName is now a string to support dynamic breakpoints
16
37
  export type BreakpointName = string;
17
38
 
39
+ export const DEFAULT_BREAKPOINTS: BreakpointConfig = {
40
+ tablet: { breakpoint: 1024, previewPoint: 768 },
41
+ mobile: { breakpoint: 540, previewPoint: 375 },
42
+ } as const;
43
+
44
+ /**
45
+ * Normalize a breakpoint config input to the full object format
46
+ * Converts legacy number format to object format
47
+ * If previewPoint is not specified, defaults to breakpoint value
48
+ */
49
+ export function normalizeBreakpointConfig(
50
+ input: BreakpointConfigInput | LegacyBreakpointConfig
51
+ ): BreakpointConfig {
52
+ const result: BreakpointConfig = {};
53
+
54
+ for (const [name, value] of Object.entries(input)) {
55
+ if (typeof value === 'number') {
56
+ // Legacy format: number -> { breakpoint: value, previewPoint: value }
57
+ result[name] = { breakpoint: value, previewPoint: value };
58
+ } else if (typeof value === 'object' && value !== null) {
59
+ // New format: ensure both values exist, preserve label if present
60
+ result[name] = {
61
+ breakpoint: value.breakpoint,
62
+ previewPoint: value.previewPoint ?? value.breakpoint,
63
+ ...(value.label !== undefined && { label: value.label }),
64
+ };
65
+ }
66
+ }
67
+
68
+ return result;
69
+ }
70
+
71
+ /**
72
+ * Get just the breakpoint values for CSS generation (media queries)
73
+ * Returns Record<string, number> with breakpoint values
74
+ */
75
+ export function getBreakpointValues(config: BreakpointConfig): LegacyBreakpointConfig {
76
+ const result: LegacyBreakpointConfig = {};
77
+ for (const [name, entry] of Object.entries(config)) {
78
+ result[name] = entry.breakpoint;
79
+ }
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * Get just the preview point values for editor preview
85
+ * Returns Record<string, number> with previewPoint values
86
+ */
87
+ export function getPreviewPointValues(config: BreakpointConfig): LegacyBreakpointConfig {
88
+ const result: LegacyBreakpointConfig = {};
89
+ for (const [name, entry] of Object.entries(config)) {
90
+ result[name] = entry.previewPoint;
91
+ }
92
+ return result;
93
+ }
94
+
18
95
  /**
19
96
  * Get all breakpoint names from the breakpoint configuration
20
97
  * Always includes 'base' plus all keys from the config
@@ -25,17 +102,17 @@ export function getAllBreakpointNames(
25
102
  ): BreakpointName[] {
26
103
  // Base is always included first
27
104
  const names: BreakpointName[] = ['base'];
28
-
29
- // Get all breakpoint names from config and sort by value (descending)
105
+
106
+ // Get all breakpoint names from config and sort by breakpoint value (descending)
30
107
  // This ensures proper order: largest viewport first
31
108
  const breakpointEntries = Object.entries(breakpoints);
32
- breakpointEntries.sort((a, b) => b[1] - a[1]); // Sort descending by value
33
-
109
+ breakpointEntries.sort((a, b) => b[1].breakpoint - a[1].breakpoint); // Sort descending by breakpoint value
110
+
34
111
  // Add breakpoint names in sorted order
35
112
  for (const [name] of breakpointEntries) {
36
113
  names.push(name);
37
114
  }
38
-
115
+
39
116
  return names;
40
117
  }
41
118
 
@@ -43,23 +120,53 @@ export function getAllBreakpointNames(
43
120
  * Get active breakpoint name based on viewport width
44
121
  * Returns the smallest breakpoint that the viewport width is less than or equal to
45
122
  * If viewport is larger than all breakpoints, returns 'base'
123
+ * Uses .breakpoint value for comparison (CSS threshold)
46
124
  */
47
125
  export function getBreakpointName(
48
126
  viewportWidth: number,
49
127
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS
50
128
  ): BreakpointName {
51
- // Sort breakpoints by value (ascending) to find the smallest one that matches
129
+ // Sort breakpoints by breakpoint value (ascending) to find the smallest one that matches
52
130
  const breakpointEntries = Object.entries(breakpoints);
53
- breakpointEntries.sort((a, b) => a[1] - b[1]); // Sort ascending by value
54
-
131
+ breakpointEntries.sort((a, b) => a[1].breakpoint - b[1].breakpoint); // Sort ascending by breakpoint value
132
+
55
133
  // Find the smallest breakpoint that viewport width is <= to
56
- for (const [name, value] of breakpointEntries) {
57
- if (viewportWidth <= value) {
134
+ for (const [name, entry] of breakpointEntries) {
135
+ if (viewportWidth <= entry.breakpoint) {
58
136
  return name;
59
137
  }
60
138
  }
61
-
139
+
62
140
  // If viewport is larger than all breakpoints, return 'base'
63
141
  return 'base';
64
142
  }
65
143
 
144
+ /**
145
+ * Get display label for a breakpoint
146
+ * Priority:
147
+ * 1. Label from config (if set)
148
+ * 2. "Desktop" for 'base'
149
+ * 3. Auto-capitalized name (camelCase -> Title Case)
150
+ */
151
+ export function getBreakpointLabel(
152
+ name: BreakpointName,
153
+ breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS
154
+ ): string {
155
+ // Base always returns "Desktop"
156
+ if (name === 'base') {
157
+ return 'Desktop';
158
+ }
159
+
160
+ // Check for custom label in config
161
+ const entry = breakpoints[name];
162
+ if (entry?.label) {
163
+ return entry.label;
164
+ }
165
+
166
+ // Auto-format: camelCase -> Title Case
167
+ // e.g., "tabletLandscape" -> "Tablet Landscape"
168
+ return name
169
+ .replace(/([A-Z])/g, ' $1')
170
+ .replace(/^./, (str) => str.toUpperCase())
171
+ .trim();
172
+ }
@@ -130,7 +130,7 @@ describe('constants', () => {
130
130
  expect(NODE_TYPE.EMBED).toBe('embed');
131
131
  expect(NODE_TYPE.LINK).toBe('link');
132
132
  expect(NODE_TYPE.LOCALE_LIST).toBe('locale-list');
133
- expect(NODE_TYPE.CMS_LIST).toBe('cms-list');
133
+ expect(NODE_TYPE.LIST).toBe('list');
134
134
  });
135
135
  });
136
136
 
@@ -35,6 +35,8 @@ export const API_ROUTES = {
35
35
  SAVE_COLORS: '/api/save-colors', // Save colors config
36
36
  // Page deletion
37
37
  DELETE_PAGE: '/api/delete-page', // Delete a page
38
+ // Component preview
39
+ COMPONENT_PREVIEW: '/api/component-preview', // Render component preview HTML
38
40
  } as const;
39
41
 
40
42
  export const HMR_ROUTE = '/hmr';
@@ -92,6 +94,8 @@ export const IFRAME_MESSAGE_TYPES = {
92
94
  REDO_REQUEST: 'REDO_REQUEST',
93
95
  TOGGLE_INTERACTIVITY_EDITOR: 'TOGGLE_INTERACTIVITY_EDITOR',
94
96
  SET_BREAKPOINT: 'SET_BREAKPOINT',
97
+ PAGE_DATA_PREVIEW: 'PAGE_DATA_PREVIEW', // Editor → Iframe for component hover preview
98
+ PAGE_DATA_PREVIEW_REVERT: 'PAGE_DATA_PREVIEW_REVERT', // Editor → Iframe to revert preview
95
99
  } as const;
96
100
 
97
101
  // Component node type constants
@@ -102,7 +106,7 @@ export const NODE_TYPE = {
102
106
  EMBED: 'embed',
103
107
  LINK: 'link',
104
108
  LOCALE_LIST: 'locale-list',
105
- CMS_LIST: 'cms-list',
109
+ LIST: 'list',
106
110
  } as const;
107
111
 
108
112
  export type NodeType = typeof NODE_TYPE[keyof typeof NODE_TYPE];
@@ -40,6 +40,14 @@ describe('extractUtilityClassesFromHTML', () => {
40
40
  const classes = extractUtilityClassesFromHTML(html);
41
41
  expect(classes.has('bgi-linear-gradient(#0000,-#0f1442-94%)')).toBe(true);
42
42
  });
43
+
44
+ test('extracts l- (left) classes correctly without treating as responsive prefix', () => {
45
+ const html = '<div class="l-10px l-auto l-0"></div>';
46
+ const classes = extractUtilityClassesFromHTML(html);
47
+ expect(classes.has('l-10px')).toBe(true);
48
+ expect(classes.has('l-auto')).toBe(true);
49
+ expect(classes.has('l-0')).toBe(true);
50
+ });
43
51
  });
44
52
 
45
53
  describe('generateUtilityCSS end-to-end', () => {
@@ -56,6 +64,15 @@ describe('generateUtilityCSS end-to-end', () => {
56
64
  const css = generateUtilityCSS(classes);
57
65
  expect(css).toContain('background-image: linear-gradient(#000, #fff)');
58
66
  });
67
+
68
+ test('generates CSS for l- (left) classes from HTML', () => {
69
+ const html = '<div class="l-10px l-auto l-0"></div>';
70
+ const classes = extractUtilityClassesFromHTML(html);
71
+ const css = generateUtilityCSS(classes);
72
+ expect(css).toContain('left: 10px');
73
+ expect(css).toContain('left: auto');
74
+ expect(css).toContain('left: 0');
75
+ });
59
76
  });
60
77
 
61
78
  describe('cssGeneration', () => {
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { prefixToCSSProperty, propertyMap } from './utilityClassConfig';
8
- import { getStyleValue } from './styleValueRegistry';
9
- import type { BreakpointConfig } from './breakpoints';
10
- import { DEFAULT_BREAKPOINTS } from './breakpoints';
8
+ import { getStyleValue, getDynamicStyle, isDynamicClass } from './styleValueRegistry';
9
+ import type { BreakpointConfig, LegacyBreakpointConfig } from './breakpoints';
10
+ import { DEFAULT_BREAKPOINTS, getBreakpointValues } from './breakpoints';
11
11
  import type { ResponsiveScales } from './responsiveScaling';
12
12
  import { scalePropertyValue } from './responsiveScaling';
13
13
  import type { InteractiveStyles, StyleObject, ResponsiveStyleObject, StyleValue } from './types/styles';
@@ -67,6 +67,13 @@ const utilityClassRules: Record<string, string> = {
67
67
  'us-text': 'user-select: text;',
68
68
  'us-all': 'user-select: all;',
69
69
 
70
+ // White space
71
+ 'whs-normal': 'white-space: normal;',
72
+ 'whs-nowrap': 'white-space: nowrap;',
73
+ 'whs-pre': 'white-space: pre;',
74
+ 'whs-pre-wrap': 'white-space: pre-wrap;',
75
+ 'whs-pre-line': 'white-space: pre-line;',
76
+
70
77
  // Shadow presets
71
78
  'sh-0': 'box-shadow: none;',
72
79
  'sh-1': 'box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);',
@@ -132,11 +139,25 @@ export function generateRuleForClass(className: string): string | null {
132
139
  }
133
140
  }
134
141
 
135
- if (!prefix || !classValue) return null;
142
+ if (!prefix || !classValue) {
143
+ // Check dynamic registry for classes with unknown prefixes
144
+ const dynamicStyle = getDynamicStyle(className);
145
+ if (dynamicStyle) {
146
+ return `${dynamicStyle.property}: ${dynamicStyle.value};`;
147
+ }
148
+ return null;
149
+ }
136
150
 
137
151
  // Look up the CSS property from prefix
138
152
  const cssProp = prefixToCSSProperty[prefix];
139
- if (!cssProp) return null;
153
+ if (!cssProp) {
154
+ // Check dynamic registry for classes with unknown prefixes
155
+ const dynamicStyle = getDynamicStyle(className);
156
+ if (dynamicStyle) {
157
+ return `${dynamicStyle.property}: ${dynamicStyle.value};`;
158
+ }
159
+ return null;
160
+ }
140
161
 
141
162
  // Handle border-side classes (bt-, bb-, bl-, border-r-) with special syntax FIRST
142
163
  // Generate ONLY width and style, NOT color - allows bc- to control color independently
@@ -314,13 +335,16 @@ export function generateUtilityCSS(
314
335
  const baseClasses = new Set<string>();
315
336
  const autoResponsiveClasses = new Set<string>(); // Classes that should get auto-scaling
316
337
 
338
+ // Extract breakpoint values for CSS media queries
339
+ const breakpointValues = getBreakpointValues(breakpoints);
340
+
317
341
  // Create a map for responsive breakpoint classes
318
342
  // Map from prefix (e.g., 't', 'm') to the class name and breakpoint info
319
343
  type BreakpointClassMap = Record<string, { classes: Set<string>; breakpointName: string; value: number }>;
320
344
  const responsiveClasses: BreakpointClassMap = {};
321
345
 
322
346
  // Initialize responsive class sets for each breakpoint
323
- for (const [breakpointName, breakpointValue] of Object.entries(breakpoints)) {
347
+ for (const [breakpointName, breakpointValue] of Object.entries(breakpointValues)) {
324
348
  // Generate prefix from breakpoint name, avoiding conflicts with property prefixes
325
349
  // For 'mobile', use 'mob' to avoid conflict with 'margin' (m-), etc.
326
350
  let prefix = breakpointName.charAt(0).toLowerCase();
@@ -436,9 +460,10 @@ export function generateUtilityCSS(
436
460
  const escapedClassName = escapeCSSClassName(className);
437
461
 
438
462
  // Generate scaled rules for each breakpoint
439
- for (const [breakpointName, breakpointValue] of Object.entries(breakpoints)) {
440
- const scaleType = breakpointName === 'mobile' ? 'mobile' : 'tablet';
441
- const scale = scaleConfig[scaleType];
463
+ for (const [breakpointName, breakpointValue] of Object.entries(breakpointValues)) {
464
+ // Use the actual breakpoint name to look up the scale
465
+ // This allows custom breakpoints like 'small', 'large', etc. to have their own scales
466
+ const scale = scaleConfig[breakpointName];
442
467
  if (!scale) continue;
443
468
 
444
469
  const scaledValue = scalePropertyValue(propValue.value, baseRef, scale);
@@ -530,6 +555,8 @@ export function extractUtilityClassesFromHTML(html: string): Set<string> {
530
555
  'o-h', 'o-a', 'o-s', 'o-v',
531
556
  // Cursor
532
557
  'cursor-pointer', 'cursor-default',
558
+ // White space
559
+ 'whs-normal', 'whs-nowrap', 'whs-pre', 'whs-pre-wrap', 'whs-pre-line',
533
560
  ]);
534
561
 
535
562
  while ((match = classRegex.exec(html)) !== null) {
@@ -560,9 +587,10 @@ export function extractUtilityClassesFromHTML(html: string): Set<string> {
560
587
  else if (className.length > 2 && className.charAt(1) === '-' && className.match(/^[a-z]-/)) {
561
588
  const firstChar = className.charAt(0);
562
589
  // Only treat as responsive prefix if it looks like a breakpoint indicator
563
- // Common breakpoint prefixes: t (tablet), s (small), l (large), x (extra), u (ultra)
590
+ // Common breakpoint prefixes: t (tablet), s (small), x (extra), u (ultra)
564
591
  // NOTE: We exclude 'm' because it conflicts with margin prefix - use 'mob' instead
565
- if (['t', 's', 'l', 'x', 'u'].includes(firstChar)) {
592
+ // NOTE: We exclude 'l' because it conflicts with left property prefix - use 'lg' instead
593
+ if (['t', 's', 'x', 'u'].includes(firstChar)) {
566
594
  classToCheck = className.substring(2); // Remove responsive prefix
567
595
  hasResponsivePrefix = true;
568
596
  }
@@ -594,6 +622,12 @@ export function extractUtilityClassesFromHTML(html: string): Set<string> {
594
622
  }
595
623
  }
596
624
  }
625
+
626
+ // Check if it's a dynamic class (registered in the dynamic registry)
627
+ // This handles classes for properties not in propertyMap
628
+ if (!classes.has(className) && isDynamicClass(className)) {
629
+ classes.add(className);
630
+ }
597
631
  }
598
632
  }
599
633
 
@@ -673,6 +707,9 @@ export function generateInteractiveCSS(
673
707
  ): string {
674
708
  const css: string[] = [];
675
709
 
710
+ // Extract breakpoint values for CSS media queries
711
+ const breakpointValues = getBreakpointValues(breakpoints);
712
+
676
713
  for (const rule of interactiveStyles) {
677
714
  const { prefix, postfix, style } = rule;
678
715
 
@@ -692,7 +729,7 @@ export function generateInteractiveCSS(
692
729
  }
693
730
 
694
731
  // Breakpoint styles (sorted by value descending)
695
- const sortedBreakpoints = Object.entries(breakpoints).sort(
732
+ const sortedBreakpoints = Object.entries(breakpointValues).sort(
696
733
  ([, a], [, b]) => b - a
697
734
  );
698
735
 
@@ -114,3 +114,6 @@ export * from './globalTemplateContext';
114
114
 
115
115
  // CMS query parsing utilities
116
116
  export * from './cmsQueryParser';
117
+
118
+ // Prop resolution utilities
119
+ export { resolvePropsFromDefinition, isRichTextMarker, richTextMarkerToHtml } from './propResolver';
@@ -298,10 +298,52 @@ describe('itemTemplateUtils', () => {
298
298
  });
299
299
  });
300
300
 
301
- it('should not process arrays (leave them as-is)', () => {
301
+ it('should process arrays with template strings', () => {
302
302
  const props = { items: ['{{item.title}}', '{{item.url}}'] };
303
303
  const result = processItemPropsTemplate(props, baseContext);
304
- expect(result).toEqual({ items: ['{{item.title}}', '{{item.url}}'] });
304
+ expect(result).toEqual({ items: ['Test Title', '/test-url'] });
305
+ });
306
+
307
+ it('should process nested arrays', () => {
308
+ const props = {
309
+ outer: [
310
+ ['{{item.title}}', 'static'],
311
+ ['{{item.url}}'],
312
+ ]
313
+ };
314
+ const result = processItemPropsTemplate(props, baseContext);
315
+ expect(result).toEqual({
316
+ outer: [
317
+ ['Test Title', 'static'],
318
+ ['/test-url'],
319
+ ]
320
+ });
321
+ });
322
+
323
+ it('should process arrays containing objects with templates', () => {
324
+ const props = {
325
+ items: [
326
+ { label: '{{item.title}}', href: '{{item.url}}' },
327
+ { label: 'Static', href: '/static' },
328
+ ]
329
+ };
330
+ const result = processItemPropsTemplate(props, baseContext);
331
+ expect(result).toEqual({
332
+ items: [
333
+ { label: 'Test Title', href: '/test-url' },
334
+ { label: 'Static', href: '/static' },
335
+ ]
336
+ });
337
+ });
338
+
339
+ it('should handle mixed array content', () => {
340
+ const props = {
341
+ tags: ['{{item.title}}', 42, true, null, { nested: '{{item.url}}' }]
342
+ };
343
+ const result = processItemPropsTemplate(props, baseContext);
344
+ expect(result).toEqual({
345
+ tags: ['Test Title', 42, true, null, { nested: '/test-url' }]
346
+ });
305
347
  });
306
348
 
307
349
  it('should handle mixed content', () => {
@@ -217,8 +217,21 @@ export function processItemPropsTemplate(
217
217
  for (const [key, value] of Object.entries(props)) {
218
218
  if (typeof value === 'string' && hasItemTemplates(value)) {
219
219
  result[key] = processItemTemplate(value, ctx, resolveValue);
220
- } else if (value && typeof value === 'object' && !Array.isArray(value)) {
221
- // Recursively process nested objects (but not arrays or null)
220
+ } else if (Array.isArray(value)) {
221
+ // Process arrays - recursively handle each element
222
+ result[key] = value.map(item => {
223
+ if (typeof item === 'string' && hasItemTemplates(item)) {
224
+ return processItemTemplate(item, ctx, resolveValue);
225
+ } else if (item && typeof item === 'object' && !Array.isArray(item)) {
226
+ return processItemPropsTemplate(item as Record<string, unknown>, ctx, resolveValue);
227
+ } else if (Array.isArray(item)) {
228
+ // Handle nested arrays recursively
229
+ return processItemPropsTemplate({ _arr: item }, ctx, resolveValue)._arr;
230
+ }
231
+ return item;
232
+ });
233
+ } else if (value && typeof value === 'object') {
234
+ // Recursively process nested objects (not arrays or null)
222
235
  result[key] = processItemPropsTemplate(value as Record<string, unknown>, ctx, resolveValue);
223
236
  } else {
224
237
  result[key] = value;
@@ -3,7 +3,11 @@
3
3
  * Provides helper functions for working with ComponentNode types
4
4
  */
5
5
 
6
- import type { ComponentNode, ComponentInstanceNode, HtmlNode, SlotMarker, EmbedNode, LinkNode, LocaleListNode, CMSListNode, ComponentDefinition, StructuredComponentDefinition } from './types';
6
+ import type { ComponentNode, ComponentInstanceNode, HtmlNode, SlotMarker, EmbedNode, LinkNode, LocaleListNode, ComponentDefinition, StructuredComponentDefinition } from './types';
7
+ import type { ListNode } from './registry/nodeTypes/ListNodeType';
8
+
9
+ // For backward compatibility during migration
10
+ type CMSListNode = ListNode;
7
11
  import { NODE_TYPE } from './constants';
8
12
 
9
13
  /**
@@ -122,9 +126,24 @@ export function isLocaleListNode(node: ComponentNode | null | undefined): node i
122
126
 
123
127
  /**
124
128
  * Type guard to check if a node is a CMS list node
129
+ * @deprecated Use isListNode() and check sourceType === 'collection' instead.
130
+ * Kept for backward compatibility during migration.
131
+ */
132
+ export function isCMSListNode(node: unknown): node is ListNode {
133
+ if (node === null || typeof node !== 'object') return false;
134
+ const n = node as Record<string, unknown>;
135
+ // Support both old cms-list type and new list with sourceType: 'collection'
136
+ return n.type === 'cms-list' || (n.type === NODE_TYPE.LIST && n.sourceType === 'collection');
137
+ }
138
+
139
+ /**
140
+ * Type guard to check if a node is a list node (unified type)
125
141
  */
126
- export function isCMSListNode(node: unknown): node is CMSListNode {
127
- return node !== null && typeof node === 'object' && (node as Record<string, unknown>).type === NODE_TYPE.CMS_LIST;
142
+ export function isListNode(node: unknown): node is ListNode {
143
+ if (node === null || typeof node !== 'object') return false;
144
+ const n = node as Record<string, unknown>;
145
+ // Support both old cms-list type (for migration) and new list type
146
+ return n.type === NODE_TYPE.LIST || n.type === 'cms-list';
128
147
  }
129
148
 
130
149
  /**
@@ -133,7 +152,7 @@ export function isCMSListNode(node: unknown): node is CMSListNode {
133
152
  export function isValidNodeType(type: string): type is typeof NODE_TYPE[keyof typeof NODE_TYPE] {
134
153
  return type === NODE_TYPE.NODE || type === NODE_TYPE.COMPONENT || type === NODE_TYPE.SLOT ||
135
154
  type === NODE_TYPE.EMBED || type === NODE_TYPE.LINK || type === NODE_TYPE.LOCALE_LIST ||
136
- type === NODE_TYPE.CMS_LIST;
155
+ type === NODE_TYPE.LIST || type === 'cms-list'; // 'cms-list' supported for migration
137
156
  }
138
157
 
139
158
  /**
@@ -42,7 +42,7 @@ describe('BaseNodeTypeRegistry', () => {
42
42
  describe('registerAll', () => {
43
43
  it('should register multiple node type definitions', () => {
44
44
  registry.registerAll(builtInNodeTypes);
45
- expect(registry.size).toBe(7);
45
+ expect(registry.size).toBe(7); // 7 node types after cms-list merged into list
46
46
  expect(registry.has(NODE_TYPE.NODE)).toBe(true);
47
47
  expect(registry.has(NODE_TYPE.COMPONENT)).toBe(true);
48
48
  expect(registry.has(NODE_TYPE.EMBED)).toBe(true);
@@ -185,6 +185,6 @@ describe('globalNodeTypeManager', () => {
185
185
  registerBuiltInNodeTypes();
186
186
  expect(globalNodeTypeManager.has(NODE_TYPE.NODE)).toBe(true);
187
187
  expect(globalNodeTypeManager.has(NODE_TYPE.EMBED)).toBe(true);
188
- expect(globalNodeTypeManager.getAll().length).toBe(7);
188
+ expect(globalNodeTypeManager.getAll().length).toBe(7); // 7 node types after cms-list merged into list
189
189
  });
190
190
  });