meno-core 1.0.20 → 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 (34) hide show
  1. package/lib/client/core/ComponentBuilder.test.ts +68 -56
  2. package/lib/client/core/ComponentBuilder.ts +6 -4
  3. package/lib/client/core/builders/embedBuilder.ts +10 -1
  4. package/lib/client/core/builders/index.ts +6 -2
  5. package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
  6. package/lib/client/routing/Router.tsx +35 -7
  7. package/lib/client/templateEngine.test.ts +126 -0
  8. package/lib/client/templateEngine.ts +32 -11
  9. package/lib/server/ssr/attributeBuilder.ts +8 -0
  10. package/lib/server/ssr/index.ts +1 -1
  11. package/lib/server/ssr/ssrRenderer.ts +223 -110
  12. package/lib/server/ssrRenderer.test.ts +197 -3
  13. package/lib/shared/constants.test.ts +1 -1
  14. package/lib/shared/constants.ts +5 -1
  15. package/lib/shared/cssGeneration.test.ts +17 -0
  16. package/lib/shared/cssGeneration.ts +3 -2
  17. package/lib/shared/index.ts +3 -0
  18. package/lib/shared/itemTemplateUtils.test.ts +44 -2
  19. package/lib/shared/itemTemplateUtils.ts +15 -2
  20. package/lib/shared/nodeUtils.ts +23 -4
  21. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
  22. package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
  23. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
  24. package/lib/shared/registry/nodeTypes/index.ts +6 -5
  25. package/lib/shared/styleNodeUtils.ts +5 -5
  26. package/lib/shared/tree/PathBuilder.ts +3 -3
  27. package/lib/shared/treePathUtils.ts +7 -5
  28. package/lib/shared/types/cms.ts +4 -57
  29. package/lib/shared/types/components.ts +45 -4
  30. package/lib/shared/types/index.ts +13 -0
  31. package/lib/shared/validation/propValidator.ts +9 -1
  32. package/lib/shared/validation/schemas.ts +60 -14
  33. package/package.json +1 -1
  34. package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
@@ -1214,8 +1214,9 @@ describe("SSR Renderer - CMSList rendering", () => {
1214
1214
 
1215
1215
  const pageData: JSONPage = {
1216
1216
  root: {
1217
- type: "cms-list",
1218
- collection: "blog-posts",
1217
+ type: "list",
1218
+ sourceType: "collection",
1219
+ source: "blog-posts",
1219
1220
  children: []
1220
1221
  } as unknown as ComponentNode
1221
1222
  };
@@ -1224,7 +1225,7 @@ describe("SSR Renderer - CMSList rendering", () => {
1224
1225
  const result = await renderPageSSR(pageData, {}, '/', '');
1225
1226
 
1226
1227
  expect(result.html).toBe('');
1227
- expect(consoleSpy).toHaveBeenCalledWith('CMSList requires CMS service');
1228
+ expect(consoleSpy).toHaveBeenCalledWith('List with sourceType "collection" requires CMS service');
1228
1229
 
1229
1230
  consoleSpy.mockRestore();
1230
1231
  });
@@ -1918,3 +1919,196 @@ describe("SSR Renderer - Interactive Styles", () => {
1918
1919
  });
1919
1920
  });
1920
1921
  });
1922
+
1923
+ describe("SSR Renderer - List node rendering", () => {
1924
+ test("should render children for each item in list prop", async () => {
1925
+ // Component with list prop definition
1926
+ const components: Record<string, ComponentDefinition> = {
1927
+ ItemsList: {
1928
+ component: {
1929
+ interface: {
1930
+ items: {
1931
+ type: 'list',
1932
+ itemSchema: {
1933
+ title: { type: 'string' }
1934
+ },
1935
+ default: []
1936
+ }
1937
+ },
1938
+ structure: {
1939
+ type: "list",
1940
+ source: "items",
1941
+ children: [
1942
+ { type: "node", tag: "div", children: ["{{item.title}}"] }
1943
+ ]
1944
+ } as any
1945
+ }
1946
+ }
1947
+ };
1948
+
1949
+ const pageData: JSONPage = {
1950
+ root: {
1951
+ type: "component",
1952
+ component: "ItemsList",
1953
+ props: {
1954
+ items: [
1955
+ { title: "First Item" },
1956
+ { title: "Second Item" },
1957
+ { title: "Third Item" }
1958
+ ]
1959
+ }
1960
+ } as any
1961
+ };
1962
+
1963
+ const result = await renderPageSSR(pageData, components, '/', '');
1964
+
1965
+ expect(result.html).toContain('First Item');
1966
+ expect(result.html).toContain('Second Item');
1967
+ expect(result.html).toContain('Third Item');
1968
+ });
1969
+
1970
+ test("should provide item context variables (itemIndex, itemFirst, itemLast)", async () => {
1971
+ const components: Record<string, ComponentDefinition> = {
1972
+ TagsList: {
1973
+ component: {
1974
+ interface: {
1975
+ tags: {
1976
+ type: 'list',
1977
+ itemSchema: {
1978
+ name: { type: 'string' }
1979
+ },
1980
+ default: []
1981
+ }
1982
+ },
1983
+ structure: {
1984
+ type: "list",
1985
+ source: "tags",
1986
+ itemAs: "tag",
1987
+ children: [
1988
+ { type: "node", tag: "span", attributes: { 'data-index': '{{tagIndex}}', 'data-first': '{{tagFirst}}', 'data-last': '{{tagLast}}' }, children: ["{{tag.name}}"] }
1989
+ ]
1990
+ } as any
1991
+ }
1992
+ }
1993
+ };
1994
+
1995
+ const pageData: JSONPage = {
1996
+ root: {
1997
+ type: "component",
1998
+ component: "TagsList",
1999
+ props: {
2000
+ tags: [
2001
+ { name: "Tag1" },
2002
+ { name: "Tag2" }
2003
+ ]
2004
+ }
2005
+ } as any
2006
+ };
2007
+
2008
+ const result = await renderPageSSR(pageData, components, '/', '');
2009
+
2010
+ expect(result.html).toContain('data-index="0"');
2011
+ expect(result.html).toContain('data-index="1"');
2012
+ expect(result.html).toContain('data-first="true"');
2013
+ expect(result.html).toContain('data-last="true"');
2014
+ expect(result.html).toContain('Tag1');
2015
+ expect(result.html).toContain('Tag2');
2016
+ });
2017
+
2018
+ test("should render empty when list prop is empty", async () => {
2019
+ const components: Record<string, ComponentDefinition> = {
2020
+ EmptyList: {
2021
+ component: {
2022
+ interface: {
2023
+ items: {
2024
+ type: 'list',
2025
+ itemSchema: {
2026
+ title: { type: 'string' }
2027
+ },
2028
+ default: []
2029
+ }
2030
+ },
2031
+ structure: {
2032
+ type: "list",
2033
+ source: "items",
2034
+ children: [
2035
+ { type: "node", tag: "div", children: ["{{item.title}}"] }
2036
+ ]
2037
+ } as any
2038
+ }
2039
+ }
2040
+ };
2041
+
2042
+ const pageData: JSONPage = {
2043
+ root: {
2044
+ type: "component",
2045
+ component: "EmptyList",
2046
+ props: {
2047
+ items: []
2048
+ }
2049
+ } as any
2050
+ };
2051
+
2052
+ const result = await renderPageSSR(pageData, components, '/', '');
2053
+
2054
+ // Should not render any list items
2055
+ expect(result.html).not.toContain('item.');
2056
+ });
2057
+
2058
+ test("should support nested lists", async () => {
2059
+ const components: Record<string, ComponentDefinition> = {
2060
+ CategoryList: {
2061
+ component: {
2062
+ interface: {
2063
+ categories: {
2064
+ type: 'list',
2065
+ itemSchema: {
2066
+ name: { type: 'string' }
2067
+ },
2068
+ default: []
2069
+ }
2070
+ },
2071
+ structure: {
2072
+ type: "list",
2073
+ source: "categories",
2074
+ itemAs: "category",
2075
+ children: [
2076
+ { type: "node", tag: "div", children: [
2077
+ "{{category.name}}",
2078
+ {
2079
+ type: "list",
2080
+ source: "{{category.items}}",
2081
+ itemAs: "subItem",
2082
+ children: [
2083
+ { type: "node", tag: "span", children: ["{{subItem.label}}"] }
2084
+ ]
2085
+ }
2086
+ ] }
2087
+ ]
2088
+ } as any
2089
+ }
2090
+ }
2091
+ };
2092
+
2093
+ const pageData: JSONPage = {
2094
+ root: {
2095
+ type: "component",
2096
+ component: "CategoryList",
2097
+ props: {
2098
+ categories: [
2099
+ { name: "Category A", items: [{ label: "Item A1" }, { label: "Item A2" }] },
2100
+ { name: "Category B", items: [{ label: "Item B1" }] }
2101
+ ]
2102
+ }
2103
+ } as any
2104
+ };
2105
+
2106
+ const result = await renderPageSSR(pageData, components, '/', '');
2107
+
2108
+ expect(result.html).toContain('Category A');
2109
+ expect(result.html).toContain('Category B');
2110
+ expect(result.html).toContain('Item A1');
2111
+ expect(result.html).toContain('Item A2');
2112
+ expect(result.html).toContain('Item B1');
2113
+ });
2114
+ });
@@ -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', () => {
@@ -587,9 +587,10 @@ export function extractUtilityClassesFromHTML(html: string): Set<string> {
587
587
  else if (className.length > 2 && className.charAt(1) === '-' && className.match(/^[a-z]-/)) {
588
588
  const firstChar = className.charAt(0);
589
589
  // Only treat as responsive prefix if it looks like a breakpoint indicator
590
- // 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)
591
591
  // NOTE: We exclude 'm' because it conflicts with margin prefix - use 'mob' instead
592
- 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)) {
593
594
  classToCheck = className.substring(2); // Remove responsive prefix
594
595
  hasResponsivePrefix = true;
595
596
  }
@@ -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
  });
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Unified List Node Type Definition
3
+ * Renders children for each item from either component props or CMS collections.
4
+ *
5
+ * Replaces both the original 'list' and 'cms-list' node types with a unified
6
+ * implementation that uses sourceType to determine data source.
7
+ */
8
+
9
+ import { z } from 'zod';
10
+ import { createElement as h } from 'react';
11
+ import { StyleValueSchema, InteractiveStylesSchema, IfConditionSchema } from '../../validation/schemas';
12
+ import { createNodeType } from '../createNodeType';
13
+ import { NODE_TYPE } from '../../constants';
14
+
15
+ // Filter condition schema (shared with CMS list queries)
16
+ const CMSFilterConditionSchema = z.object({
17
+ field: z.string(),
18
+ operator: z.enum(['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'contains', 'in']).optional(),
19
+ value: z.unknown(),
20
+ });
21
+
22
+ // Sort configuration schema
23
+ const CMSSortConfigSchema = z.union([
24
+ z.object({
25
+ field: z.string(),
26
+ order: z.enum(['asc', 'desc']).optional(),
27
+ }),
28
+ z.array(z.object({
29
+ field: z.string(),
30
+ order: z.enum(['asc', 'desc']).optional(),
31
+ })),
32
+ ]);
33
+
34
+ // Schema is the SINGLE source of truth
35
+ const ListNodeSchemaInternal = z.object({
36
+ type: z.literal('list'),
37
+ /**
38
+ * Data source type:
39
+ * - 'prop': Read items from component props (default)
40
+ * - 'collection': Query items from CMS collection
41
+ */
42
+ sourceType: z.enum(['prop', 'collection']).default('prop'),
43
+ /**
44
+ * Source identifier:
45
+ * - For sourceType 'prop': Prop name (e.g., "items") or template expression (e.g., "{{category.items}}")
46
+ * - For sourceType 'collection': Collection name (e.g., "posts", "authors")
47
+ */
48
+ source: z.string(),
49
+ /**
50
+ * HTML element tag for the list container.
51
+ * Defaults to 'div'. Common alternatives: 'ul', 'ol', 'section', 'nav', 'article'.
52
+ */
53
+ tag: z.string().optional(),
54
+ label: z.string().optional(), // Custom label displayed in structure tree
55
+ if: IfConditionSchema.optional(), // Conditional rendering - skip node when false
56
+ style: StyleValueSchema.optional(),
57
+ interactiveStyles: InteractiveStylesSchema.optional(), // Interactive CSS rules (hover, active, etc.)
58
+ generateElementClass: z.boolean().optional(), // Generate element class without styles (for custom CSS)
59
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
60
+ /**
61
+ * Variable name for item in templates.
62
+ * - For sourceType 'prop': defaults to 'item'
63
+ * - For sourceType 'collection': defaults to singularized collection name
64
+ */
65
+ itemAs: z.string().optional(),
66
+
67
+ // Collection-only options (ignored when sourceType: 'prop')
68
+ /** Direct item IDs or template expression for referenced items (e.g., "{{post.authorId}}") */
69
+ items: z.union([z.string(), z.array(z.string())]).optional(),
70
+ /** Filter conditions */
71
+ filter: z.union([
72
+ CMSFilterConditionSchema,
73
+ z.array(CMSFilterConditionSchema),
74
+ z.record(z.unknown()),
75
+ ]).optional(),
76
+ /** Sort configuration */
77
+ sort: CMSSortConfigSchema.optional(),
78
+ /** Exclude the current CMS item from the list (useful for "related items" sections) */
79
+ excludeCurrentItem: z.boolean().optional(),
80
+ /**
81
+ * Emit item template for dynamic client-side rendering.
82
+ * When true, SSR emits a `<template data-meno-item>` element with unprocessed
83
+ * {{item.field}} placeholders. MenoFilter uses this to render items beyond
84
+ * the SSR'd limit dynamically.
85
+ */
86
+ emitTemplate: z.boolean().optional(),
87
+
88
+ // Shared options
89
+ /** Maximum number of items to return */
90
+ limit: z.number().optional(),
91
+ /** Number of items to skip */
92
+ offset: z.number().optional(),
93
+ /** Children are repeated for each item */
94
+ children: z.array(z.unknown()).optional(),
95
+ }).passthrough();
96
+
97
+ // TypeScript type inferred from schema
98
+ export type ListNode = z.infer<typeof ListNodeSchemaInternal>;
99
+
100
+ // Export schema for validation/schemas.ts
101
+ export const ListNodeSchema = ListNodeSchemaInternal;
102
+
103
+ export const ListNodeType = createNodeType({
104
+ type: NODE_TYPE.LIST,
105
+ displayName: 'List',
106
+ category: 'special',
107
+ schema: ListNodeSchemaInternal,
108
+
109
+ defaultValues: {
110
+ sourceType: 'prop',
111
+ source: '',
112
+ children: [],
113
+ style: { base: {} },
114
+ },
115
+
116
+ treeDisplay: {
117
+ icon: 'HTML_ELEMENT',
118
+ getLabel: (node) => {
119
+ const listNode = node as ListNode;
120
+ const sourceType = listNode.sourceType || 'prop';
121
+ const prefix = sourceType === 'collection' ? 'CMS List' : 'List';
122
+ return listNode.source ? `${prefix}: ${listNode.source}` : prefix;
123
+ },
124
+ },
125
+
126
+ clientRenderer: (node, context) => {
127
+ const listNode = node as ListNode;
128
+ const sourceType = listNode.sourceType || 'prop';
129
+ const isCollection = sourceType === 'collection';
130
+
131
+ // Different styling for collection vs prop lists
132
+ const bgColor = isCollection ? 'rgba(139, 92, 246, 0.1)' : 'rgba(59, 130, 246, 0.1)';
133
+ const borderColor = isCollection ? 'rgba(139, 92, 246, 0.5)' : 'rgba(59, 130, 246, 0.5)';
134
+ const textColor = isCollection ? '#8b5cf6' : '#3b82f6';
135
+ const label = isCollection ? 'CMS List' : 'List';
136
+
137
+ return h('div', {
138
+ key: context.key,
139
+ 'data-list': 'true',
140
+ 'data-source-type': sourceType,
141
+ 'data-source': listNode.source,
142
+ style: {
143
+ padding: '8px 12px',
144
+ background: bgColor,
145
+ border: `1px dashed ${borderColor}`,
146
+ borderRadius: '4px',
147
+ color: textColor,
148
+ fontSize: '12px',
149
+ },
150
+ }, `[${label}: ${listNode.source || 'No source'}]`);
151
+ },
152
+
153
+ ssrRenderer: (_node, _context) => {
154
+ // Placeholder - actual SSR is handled by processList in ssrRenderer.ts
155
+ return '<!-- list rendered by processList -->';
156
+ },
157
+
158
+ capabilities: {
159
+ canHaveChildren: true,
160
+ requiresProps: ['source'],
161
+ },
162
+
163
+ editableFields: [
164
+ {
165
+ name: 'sourceType',
166
+ label: 'Source Type',
167
+ type: 'select',
168
+ required: false,
169
+ options: ['prop', 'collection'],
170
+ },
171
+ {
172
+ name: 'source',
173
+ label: 'Source',
174
+ type: 'string',
175
+ required: true,
176
+ placeholder: 'e.g., items or posts'
177
+ },
178
+ {
179
+ name: 'itemAs',
180
+ label: 'Item Variable',
181
+ type: 'string',
182
+ required: false,
183
+ placeholder: 'default: item (prop) or singular (collection)'
184
+ }
185
+ ],
186
+ });
@@ -7,8 +7,14 @@ import { z } from 'zod';
7
7
  import { createNodeType } from '../createNodeType';
8
8
 
9
9
  // Schema is the SINGLE source of truth
10
+ // Note: default uses z.any() to avoid circular reference with ComponentNodeSchema
11
+ // Full validation is done via the schema in validation/schemas.ts
10
12
  const SlotMarkerSchemaInternal = z.object({
11
13
  type: z.literal('slot'),
14
+ default: z.union([
15
+ z.array(z.any()),
16
+ z.string(),
17
+ ]).optional(),
12
18
  }).passthrough();
13
19
 
14
20
  // TypeScript type inferred from schema