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.
- 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/routing/Router.tsx +35 -7
- package/lib/client/templateEngine.test.ts +126 -0
- package/lib/client/templateEngine.ts +32 -11
- package/lib/server/ssr/attributeBuilder.ts +8 -0
- package/lib/server/ssr/index.ts +1 -1
- package/lib/server/ssr/ssrRenderer.ts +223 -110
- package/lib/server/ssrRenderer.test.ts +197 -3
- 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 +3 -2
- 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/styleNodeUtils.ts +5 -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/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
|
@@ -1214,8 +1214,9 @@ describe("SSR Renderer - CMSList rendering", () => {
|
|
|
1214
1214
|
|
|
1215
1215
|
const pageData: JSONPage = {
|
|
1216
1216
|
root: {
|
|
1217
|
-
type: "
|
|
1218
|
-
|
|
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('
|
|
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.
|
|
133
|
+
expect(NODE_TYPE.LIST).toBe('list');
|
|
134
134
|
});
|
|
135
135
|
});
|
|
136
136
|
|
package/lib/shared/constants.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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
|
-
|
|
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
|
}
|
package/lib/shared/index.ts
CHANGED
|
@@ -298,10 +298,52 @@ describe('itemTemplateUtils', () => {
|
|
|
298
298
|
});
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
-
it('should
|
|
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: ['
|
|
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 (
|
|
221
|
-
//
|
|
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;
|
package/lib/shared/nodeUtils.ts
CHANGED
|
@@ -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,
|
|
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
|
|
127
|
-
|
|
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.
|
|
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
|