meno-core 1.0.0
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/bin/cli.ts +281 -0
- package/build-static.ts +298 -0
- package/bunfig.toml +39 -0
- package/entries/client-router.tsx +111 -0
- package/entries/server-router.tsx +71 -0
- package/lib/client/ClientInitializer.test.ts +9 -0
- package/lib/client/ClientInitializer.test.ts.skip +92 -0
- package/lib/client/ClientInitializer.ts +60 -0
- package/lib/client/ErrorBoundary.test.tsx +595 -0
- package/lib/client/ErrorBoundary.tsx +230 -0
- package/lib/client/componentRegistry.test.ts +165 -0
- package/lib/client/componentRegistry.ts +18 -0
- package/lib/client/contexts/ThemeContext.tsx +73 -0
- package/lib/client/core/ComponentBuilder.test.ts +677 -0
- package/lib/client/core/ComponentBuilder.ts +660 -0
- package/lib/client/core/ComponentRenderer.test.tsx +176 -0
- package/lib/client/core/ComponentRenderer.tsx +83 -0
- package/lib/client/core/cmsTemplateProcessor.ts +129 -0
- package/lib/client/elementRegistry.ts +81 -0
- package/lib/client/hmr/HMRManager.tsx +179 -0
- package/lib/client/hmr/index.ts +5 -0
- package/lib/client/hmrWebSocket.test.ts +9 -0
- package/lib/client/hmrWebSocket.ts +250 -0
- package/lib/client/hooks/useColorVariables.test.ts +166 -0
- package/lib/client/hooks/useColorVariables.ts +249 -0
- package/lib/client/hooks/usePropertyAutocomplete.test.ts +9 -0
- package/lib/client/hooks/usePropertyAutocomplete.ts +40 -0
- package/lib/client/hydration/HydrationUtils.test.ts +154 -0
- package/lib/client/hydration/HydrationUtils.ts +35 -0
- package/lib/client/i18nConfigService.test.ts +74 -0
- package/lib/client/i18nConfigService.ts +78 -0
- package/lib/client/index.ts +56 -0
- package/lib/client/navigation.test.ts +441 -0
- package/lib/client/navigation.ts +23 -0
- package/lib/client/responsiveStyleResolver.test.ts +491 -0
- package/lib/client/responsiveStyleResolver.ts +184 -0
- package/lib/client/routing/RouteLoader.test.ts +635 -0
- package/lib/client/routing/RouteLoader.ts +347 -0
- package/lib/client/routing/Router.tsx +382 -0
- package/lib/client/scripts/ScriptExecutor.test.ts +489 -0
- package/lib/client/scripts/ScriptExecutor.ts +171 -0
- package/lib/client/scripts/formHandler.ts +103 -0
- package/lib/client/styleProcessor.test.ts +126 -0
- package/lib/client/styleProcessor.ts +92 -0
- package/lib/client/styles/StyleInjector.test.ts +354 -0
- package/lib/client/styles/StyleInjector.ts +154 -0
- package/lib/client/templateEngine.test.ts +660 -0
- package/lib/client/templateEngine.ts +667 -0
- package/lib/client/theme.test.ts +173 -0
- package/lib/client/theme.ts +159 -0
- package/lib/client/utils/toast.ts +46 -0
- package/lib/server/createServer.ts +170 -0
- package/lib/server/cssGenerator.test.ts +172 -0
- package/lib/server/cssGenerator.ts +58 -0
- package/lib/server/fileWatcher.ts +134 -0
- package/lib/server/index.ts +55 -0
- package/lib/server/jsonLoader.test.ts +103 -0
- package/lib/server/jsonLoader.ts +350 -0
- package/lib/server/middleware/cors.test.ts +177 -0
- package/lib/server/middleware/cors.ts +69 -0
- package/lib/server/middleware/errorHandler.test.ts +208 -0
- package/lib/server/middleware/errorHandler.ts +63 -0
- package/lib/server/middleware/index.ts +9 -0
- package/lib/server/middleware/logger.test.ts +233 -0
- package/lib/server/middleware/logger.ts +99 -0
- package/lib/server/pageCache.test.ts +167 -0
- package/lib/server/pageCache.ts +97 -0
- package/lib/server/projectContext.ts +51 -0
- package/lib/server/providers/fileSystemCMSProvider.test.ts +292 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +227 -0
- package/lib/server/providers/fileSystemPageProvider.ts +83 -0
- package/lib/server/routes/api/cms.test.ts +177 -0
- package/lib/server/routes/api/cms.ts +82 -0
- package/lib/server/routes/api/colors.ts +59 -0
- package/lib/server/routes/api/components.ts +70 -0
- package/lib/server/routes/api/config.test.ts +9 -0
- package/lib/server/routes/api/config.ts +28 -0
- package/lib/server/routes/api/core-routes.ts +182 -0
- package/lib/server/routes/api/functions.ts +170 -0
- package/lib/server/routes/api/index.ts +69 -0
- package/lib/server/routes/api/pages.ts +95 -0
- package/lib/server/routes/api/shared.test.ts +81 -0
- package/lib/server/routes/api/shared.ts +31 -0
- package/lib/server/routes/editor.test.ts +9 -0
- package/lib/server/routes/index.ts +104 -0
- package/lib/server/routes/pages.ts +161 -0
- package/lib/server/routes/static.ts +107 -0
- package/lib/server/services/ColorService.ts +193 -0
- package/lib/server/services/cmsService.test.ts +388 -0
- package/lib/server/services/cmsService.ts +296 -0
- package/lib/server/services/componentService.test.ts +276 -0
- package/lib/server/services/componentService.ts +346 -0
- package/lib/server/services/configService.ts +156 -0
- package/lib/server/services/fileWatcherService.ts +67 -0
- package/lib/server/services/index.ts +10 -0
- package/lib/server/services/pageService.test.ts +258 -0
- package/lib/server/services/pageService.ts +240 -0
- package/lib/server/ssrRenderer.test.ts +1005 -0
- package/lib/server/ssrRenderer.ts +878 -0
- package/lib/server/utilityClassGenerator.ts +11 -0
- package/lib/server/utils/index.ts +5 -0
- package/lib/server/utils/jsonLineMapper.test.ts +100 -0
- package/lib/server/utils/jsonLineMapper.ts +166 -0
- package/lib/server/validateStyleCoverage.test.ts +9 -0
- package/lib/server/validateStyleCoverage.ts +167 -0
- package/lib/server/websocketManager.test.ts +9 -0
- package/lib/server/websocketManager.ts +95 -0
- package/lib/shared/attributeNodeUtils.test.ts +152 -0
- package/lib/shared/attributeNodeUtils.ts +50 -0
- package/lib/shared/breakpoints.test.ts +166 -0
- package/lib/shared/breakpoints.ts +65 -0
- package/lib/shared/colorProperties.test.ts +111 -0
- package/lib/shared/colorProperties.ts +40 -0
- package/lib/shared/colorVariableUtils.test.ts +319 -0
- package/lib/shared/colorVariableUtils.ts +97 -0
- package/lib/shared/constants.test.ts +175 -0
- package/lib/shared/constants.ts +116 -0
- package/lib/shared/cssGeneration.ts +481 -0
- package/lib/shared/cssProperties.test.ts +252 -0
- package/lib/shared/cssProperties.ts +338 -0
- package/lib/shared/elementUtils.test.ts +245 -0
- package/lib/shared/elementUtils.ts +90 -0
- package/lib/shared/fontLoader.ts +97 -0
- package/lib/shared/i18n.test.ts +313 -0
- package/lib/shared/i18n.ts +286 -0
- package/lib/shared/index.ts +50 -0
- package/lib/shared/interfaces/contentProvider.test.ts +9 -0
- package/lib/shared/interfaces/contentProvider.ts +121 -0
- package/lib/shared/nodeUtils.test.ts +320 -0
- package/lib/shared/nodeUtils.ts +220 -0
- package/lib/shared/pathArrayUtils.test.ts +315 -0
- package/lib/shared/pathArrayUtils.ts +17 -0
- package/lib/shared/pathUtils.test.ts +260 -0
- package/lib/shared/pathUtils.ts +244 -0
- package/lib/shared/paths/Path.test.ts +74 -0
- package/lib/shared/paths/Path.ts +23 -0
- package/lib/shared/paths/PathConverter.test.ts +232 -0
- package/lib/shared/paths/PathConverter.ts +141 -0
- package/lib/shared/paths/PathUtils.ts +290 -0
- package/lib/shared/paths/PathValidator.test.ts +193 -0
- package/lib/shared/paths/PathValidator.ts +53 -0
- package/lib/shared/paths/index.ts +48 -0
- package/lib/shared/propResolver.test.ts +639 -0
- package/lib/shared/propResolver.ts +124 -0
- package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +190 -0
- package/lib/shared/registry/BaseNodeTypeRegistry.ts +200 -0
- package/lib/shared/registry/ClientNodeTypeRegistry.ts +34 -0
- package/lib/shared/registry/ClientRegistry.test.ts +26 -0
- package/lib/shared/registry/ClientRegistry.ts +15 -0
- package/lib/shared/registry/ComponentRegistry.test.ts +293 -0
- package/lib/shared/registry/ComponentRegistry.ts +100 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +198 -0
- package/lib/shared/registry/NodeTypeManager.ts +94 -0
- package/lib/shared/registry/RegistryManager.test.ts +58 -0
- package/lib/shared/registry/RegistryManager.ts +60 -0
- package/lib/shared/registry/SSRNodeTypeRegistry.ts +33 -0
- package/lib/shared/registry/SSRRegistry.test.ts +26 -0
- package/lib/shared/registry/SSRRegistry.ts +15 -0
- package/lib/shared/registry/createNodeType.ts +175 -0
- package/lib/shared/registry/defineNodeType.ts +73 -0
- package/lib/shared/registry/fieldPresets.ts +109 -0
- package/lib/shared/registry/index.ts +50 -0
- package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +71 -0
- package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +61 -0
- package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +88 -0
- package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +66 -0
- package/lib/shared/registry/nodeTypes/ObjectLinkNodeType.ts +75 -0
- package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +49 -0
- package/lib/shared/registry/nodeTypes/TextNodeType.ts +52 -0
- package/lib/shared/registry/nodeTypes/index.ts +75 -0
- package/lib/shared/responsiveScaling.test.ts +268 -0
- package/lib/shared/responsiveScaling.ts +194 -0
- package/lib/shared/responsiveStyleUtils.test.ts +300 -0
- package/lib/shared/responsiveStyleUtils.ts +139 -0
- package/lib/shared/slugTranslator.test.ts +325 -0
- package/lib/shared/slugTranslator.ts +177 -0
- package/lib/shared/styleNodeUtils.test.ts +132 -0
- package/lib/shared/styleNodeUtils.ts +102 -0
- package/lib/shared/styleUtils.test.ts +238 -0
- package/lib/shared/styleUtils.ts +63 -0
- package/lib/shared/themeDefaults.test.ts +113 -0
- package/lib/shared/themeDefaults.ts +103 -0
- package/lib/shared/tree/PathBuilder.ts +383 -0
- package/lib/shared/treePathUtils.test.ts +539 -0
- package/lib/shared/treePathUtils.ts +339 -0
- package/lib/shared/types/api.ts +58 -0
- package/lib/shared/types/cms.ts +95 -0
- package/lib/shared/types/colors.ts +45 -0
- package/lib/shared/types/components.ts +121 -0
- package/lib/shared/types/errors.test.ts +103 -0
- package/lib/shared/types/errors.ts +69 -0
- package/lib/shared/types/index.ts +96 -0
- package/lib/shared/types/nodes.ts +20 -0
- package/lib/shared/types/rendering.ts +61 -0
- package/lib/shared/types/styles.ts +38 -0
- package/lib/shared/types.ts +11 -0
- package/lib/shared/utilityClassConfig.ts +287 -0
- package/lib/shared/utilityClassMapper.test.ts +140 -0
- package/lib/shared/utilityClassMapper.ts +229 -0
- package/lib/shared/utils/fileUtils.test.ts +99 -0
- package/lib/shared/utils/fileUtils.ts +56 -0
- package/lib/shared/utils.test.ts +261 -0
- package/lib/shared/utils.ts +84 -0
- package/lib/shared/validation/index.ts +7 -0
- package/lib/shared/validation/propValidator.test.ts +178 -0
- package/lib/shared/validation/propValidator.ts +238 -0
- package/lib/shared/validation/schemas.test.ts +177 -0
- package/lib/shared/validation/schemas.ts +401 -0
- package/lib/shared/validation/validators.test.ts +109 -0
- package/lib/shared/validation/validators.ts +304 -0
- package/lib/test-utils/dom-setup.ts +55 -0
- package/lib/test-utils/factories/ConsoleMockFactory.ts +200 -0
- package/lib/test-utils/factories/DomMockFactory.ts +487 -0
- package/lib/test-utils/factories/EventMockFactory.ts +244 -0
- package/lib/test-utils/factories/FetchMockFactory.ts +210 -0
- package/lib/test-utils/factories/ServerMockFactory.ts +223 -0
- package/lib/test-utils/factories/StoreMockFactory.ts +370 -0
- package/lib/test-utils/factories/index.ts +11 -0
- package/lib/test-utils/fixtures.ts +134 -0
- package/lib/test-utils/helpers/asyncHelpers.test.ts +112 -0
- package/lib/test-utils/helpers/asyncHelpers.ts +196 -0
- package/lib/test-utils/helpers/index.ts +6 -0
- package/lib/test-utils/helpers.test.ts +73 -0
- package/lib/test-utils/helpers.ts +90 -0
- package/lib/test-utils/index.ts +17 -0
- package/lib/test-utils/mockFactories.ts +92 -0
- package/lib/test-utils/mocks.ts +341 -0
- package/package.json +38 -0
- package/templates/index-router.html +34 -0
- package/tsconfig.json +14 -0
- package/vite.config.ts +43 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS Service Tests
|
|
3
|
+
* Tests for CMS service functionality including schema loading, route matching, and querying
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
7
|
+
import { CMSService } from './cmsService';
|
|
8
|
+
import type { CMSProvider, CMSSchemaInfo } from '../../shared/interfaces/contentProvider';
|
|
9
|
+
import type { CMSItem } from '../../shared/types';
|
|
10
|
+
|
|
11
|
+
// Mock schema info (schema extracted from page file)
|
|
12
|
+
const mockSchemaInfo: CMSSchemaInfo = {
|
|
13
|
+
schema: {
|
|
14
|
+
id: 'blog-posts',
|
|
15
|
+
name: 'Blog Posts',
|
|
16
|
+
slugField: 'slug',
|
|
17
|
+
urlPattern: '/blog/{{slug}}',
|
|
18
|
+
fields: {
|
|
19
|
+
title: { type: 'string', required: true },
|
|
20
|
+
slug: { type: 'string', required: true },
|
|
21
|
+
content: { type: 'text' },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
pagePath: '/pages/blog-post.json',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const mockProductSchemaInfo: CMSSchemaInfo = {
|
|
28
|
+
schema: {
|
|
29
|
+
id: 'products',
|
|
30
|
+
name: 'Products',
|
|
31
|
+
slugField: 'slug',
|
|
32
|
+
urlPattern: '/shop/{{slug}}',
|
|
33
|
+
fields: {
|
|
34
|
+
name: { type: 'string', required: true },
|
|
35
|
+
slug: { type: 'string', required: true },
|
|
36
|
+
price: { type: 'number' },
|
|
37
|
+
featured: { type: 'boolean' },
|
|
38
|
+
category: { type: 'select', options: ['Electronics', 'Clothing', 'Books'] },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
pagePath: '/pages/product.json',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const mockBlogItems: CMSItem[] = [
|
|
45
|
+
{ _id: '1', slug: 'hello-world', title: 'Hello World', content: 'First post' },
|
|
46
|
+
{ _id: '2', slug: 'getting-started', title: 'Getting Started', content: 'Guide' },
|
|
47
|
+
{ _id: '3', slug: 'advanced-tips', title: 'Advanced Tips', content: 'Pro tips' },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const mockProductItems: CMSItem[] = [
|
|
51
|
+
{ _id: '1', slug: 'laptop', name: 'Laptop Pro', price: 999, featured: true, category: 'Electronics' },
|
|
52
|
+
{ _id: '2', slug: 'tshirt', name: 'Cool T-Shirt', price: 29, featured: false, category: 'Clothing' },
|
|
53
|
+
{ _id: '3', slug: 'headphones', name: 'Wireless Headphones', price: 199, featured: true, category: 'Electronics' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
describe('CMSService', () => {
|
|
57
|
+
let service: CMSService;
|
|
58
|
+
let mockProvider: CMSProvider;
|
|
59
|
+
|
|
60
|
+
beforeEach(async () => {
|
|
61
|
+
mockProvider = {
|
|
62
|
+
getAllSchemas: async () => new Map([
|
|
63
|
+
['blog-posts', mockSchemaInfo],
|
|
64
|
+
['products', mockProductSchemaInfo],
|
|
65
|
+
]),
|
|
66
|
+
getItems: async (collection: string) => {
|
|
67
|
+
if (collection === 'blog-posts') return mockBlogItems;
|
|
68
|
+
if (collection === 'products') return mockProductItems;
|
|
69
|
+
return [];
|
|
70
|
+
},
|
|
71
|
+
getItemBySlug: async (collection: string, slug: string) => {
|
|
72
|
+
if (collection === 'blog-posts') {
|
|
73
|
+
return mockBlogItems.find(i => i.slug === slug) || null;
|
|
74
|
+
}
|
|
75
|
+
if (collection === 'products') {
|
|
76
|
+
return mockProductItems.find(i => i.slug === slug) || null;
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
},
|
|
80
|
+
getItemById: async (collection: string, id: string) => {
|
|
81
|
+
if (collection === 'blog-posts') {
|
|
82
|
+
return mockBlogItems.find(i => i._id === id) || null;
|
|
83
|
+
}
|
|
84
|
+
if (collection === 'products') {
|
|
85
|
+
return mockProductItems.find(i => i._id === id) || null;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
},
|
|
89
|
+
saveItem: async () => {},
|
|
90
|
+
deleteItem: async () => {},
|
|
91
|
+
};
|
|
92
|
+
service = new CMSService(mockProvider);
|
|
93
|
+
await service.initialize();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('initialize', () => {
|
|
97
|
+
it('should extract schemas from pages', async () => {
|
|
98
|
+
const schemas = service.getAllSchemas();
|
|
99
|
+
expect(schemas.size).toBe(2);
|
|
100
|
+
expect(schemas.get('blog-posts')?.schema.id).toBe('blog-posts');
|
|
101
|
+
expect(schemas.get('blog-posts')?.pagePath).toBe('/pages/blog-post.json');
|
|
102
|
+
expect(schemas.get('products')?.schema.id).toBe('products');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should build route patterns from schemas', async () => {
|
|
106
|
+
const match = await service.matchRoute('/blog/hello-world');
|
|
107
|
+
expect(match).not.toBeNull();
|
|
108
|
+
expect(match?.collection).toBe('blog-posts');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should not throw when provider is not set', async () => {
|
|
112
|
+
const emptyService = new CMSService();
|
|
113
|
+
await emptyService.initialize();
|
|
114
|
+
expect(emptyService.getAllSchemas().size).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('setProvider', () => {
|
|
119
|
+
it('should allow setting provider after construction', async () => {
|
|
120
|
+
const newService = new CMSService();
|
|
121
|
+
expect(newService.getAllSchemas().size).toBe(0);
|
|
122
|
+
|
|
123
|
+
newService.setProvider(mockProvider);
|
|
124
|
+
await newService.initialize();
|
|
125
|
+
|
|
126
|
+
expect(newService.getAllSchemas().size).toBe(2);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('matchRoute', () => {
|
|
131
|
+
it('should match CMS URL and return item with pagePath', async () => {
|
|
132
|
+
const match = await service.matchRoute('/blog/hello-world');
|
|
133
|
+
expect(match).toEqual({
|
|
134
|
+
collection: 'blog-posts',
|
|
135
|
+
slug: 'hello-world',
|
|
136
|
+
item: mockBlogItems[0],
|
|
137
|
+
pagePath: '/pages/blog-post.json',
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should match product URLs', async () => {
|
|
142
|
+
const match = await service.matchRoute('/shop/laptop');
|
|
143
|
+
expect(match).toEqual({
|
|
144
|
+
collection: 'products',
|
|
145
|
+
slug: 'laptop',
|
|
146
|
+
item: mockProductItems[0],
|
|
147
|
+
pagePath: '/pages/product.json',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return null for non-matching URL', async () => {
|
|
152
|
+
const match = await service.matchRoute('/about');
|
|
153
|
+
expect(match).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should return null for non-existent slug', async () => {
|
|
157
|
+
const match = await service.matchRoute('/blog/non-existent');
|
|
158
|
+
expect(match).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should not match partial URLs', async () => {
|
|
162
|
+
const match = await service.matchRoute('/blog');
|
|
163
|
+
expect(match).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should not match URLs with extra path segments', async () => {
|
|
167
|
+
const match = await service.matchRoute('/blog/hello-world/extra');
|
|
168
|
+
expect(match).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('getCollectionURLs', () => {
|
|
173
|
+
it('should return all URLs for blog-posts collection', async () => {
|
|
174
|
+
const urls = await service.getCollectionURLs('blog-posts');
|
|
175
|
+
expect(urls).toEqual([
|
|
176
|
+
'/blog/hello-world',
|
|
177
|
+
'/blog/getting-started',
|
|
178
|
+
'/blog/advanced-tips',
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should return all URLs for products collection', async () => {
|
|
183
|
+
const urls = await service.getCollectionURLs('products');
|
|
184
|
+
expect(urls).toEqual([
|
|
185
|
+
'/shop/laptop',
|
|
186
|
+
'/shop/tshirt',
|
|
187
|
+
'/shop/headphones',
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should return empty array for unknown collection', async () => {
|
|
192
|
+
const urls = await service.getCollectionURLs('unknown');
|
|
193
|
+
expect(urls).toEqual([]);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('getSchema', () => {
|
|
198
|
+
it('should return schema for existing collection', () => {
|
|
199
|
+
const schema = service.getSchema('blog-posts');
|
|
200
|
+
expect(schema?.id).toBe('blog-posts');
|
|
201
|
+
expect(schema?.name).toBe('Blog Posts');
|
|
202
|
+
expect(schema?.slugField).toBe('slug');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should return undefined for unknown collection', () => {
|
|
206
|
+
const schema = service.getSchema('unknown');
|
|
207
|
+
expect(schema).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('queryItems', () => {
|
|
212
|
+
it('should return all items without filter', async () => {
|
|
213
|
+
const items = await service.queryItems({ collection: 'blog-posts' });
|
|
214
|
+
expect(items).toHaveLength(3);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should apply simple object filter', async () => {
|
|
218
|
+
const items = await service.queryItems({
|
|
219
|
+
collection: 'products',
|
|
220
|
+
filter: { featured: true },
|
|
221
|
+
});
|
|
222
|
+
expect(items).toHaveLength(2);
|
|
223
|
+
expect(items.every(i => i.featured === true)).toBe(true);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should apply filter with eq operator', async () => {
|
|
227
|
+
const items = await service.queryItems({
|
|
228
|
+
collection: 'products',
|
|
229
|
+
filter: { field: 'category', operator: 'eq', value: 'Electronics' },
|
|
230
|
+
});
|
|
231
|
+
expect(items).toHaveLength(2);
|
|
232
|
+
expect(items.every(i => i.category === 'Electronics')).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should apply filter with neq operator', async () => {
|
|
236
|
+
const items = await service.queryItems({
|
|
237
|
+
collection: 'products',
|
|
238
|
+
filter: { field: 'category', operator: 'neq', value: 'Electronics' },
|
|
239
|
+
});
|
|
240
|
+
expect(items).toHaveLength(1);
|
|
241
|
+
expect(items[0].category).toBe('Clothing');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should apply filter with gt operator', async () => {
|
|
245
|
+
const items = await service.queryItems({
|
|
246
|
+
collection: 'products',
|
|
247
|
+
filter: { field: 'price', operator: 'gt', value: 100 },
|
|
248
|
+
});
|
|
249
|
+
expect(items).toHaveLength(2);
|
|
250
|
+
expect(items.every(i => (i.price as number) > 100)).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('should apply filter with gte operator', async () => {
|
|
254
|
+
const items = await service.queryItems({
|
|
255
|
+
collection: 'products',
|
|
256
|
+
filter: { field: 'price', operator: 'gte', value: 199 },
|
|
257
|
+
});
|
|
258
|
+
expect(items).toHaveLength(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should apply filter with lt operator', async () => {
|
|
262
|
+
const items = await service.queryItems({
|
|
263
|
+
collection: 'products',
|
|
264
|
+
filter: { field: 'price', operator: 'lt', value: 100 },
|
|
265
|
+
});
|
|
266
|
+
expect(items).toHaveLength(1);
|
|
267
|
+
expect(items[0].name).toBe('Cool T-Shirt');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should apply filter with lte operator', async () => {
|
|
271
|
+
const items = await service.queryItems({
|
|
272
|
+
collection: 'products',
|
|
273
|
+
filter: { field: 'price', operator: 'lte', value: 29 },
|
|
274
|
+
});
|
|
275
|
+
expect(items).toHaveLength(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should apply filter with contains operator', async () => {
|
|
279
|
+
const items = await service.queryItems({
|
|
280
|
+
collection: 'products',
|
|
281
|
+
filter: { field: 'name', operator: 'contains', value: 'Wireless' },
|
|
282
|
+
});
|
|
283
|
+
expect(items).toHaveLength(1);
|
|
284
|
+
expect(items[0].slug).toBe('headphones');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should apply filter with in operator', async () => {
|
|
288
|
+
const items = await service.queryItems({
|
|
289
|
+
collection: 'products',
|
|
290
|
+
filter: { field: 'category', operator: 'in', value: ['Electronics', 'Clothing'] },
|
|
291
|
+
});
|
|
292
|
+
expect(items).toHaveLength(3);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should apply multiple filter conditions (AND)', async () => {
|
|
296
|
+
const items = await service.queryItems({
|
|
297
|
+
collection: 'products',
|
|
298
|
+
filter: [
|
|
299
|
+
{ field: 'featured', value: true },
|
|
300
|
+
{ field: 'category', value: 'Electronics' },
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
expect(items).toHaveLength(2);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should apply ascending sort', async () => {
|
|
307
|
+
const items = await service.queryItems({
|
|
308
|
+
collection: 'products',
|
|
309
|
+
sort: { field: 'price', order: 'asc' },
|
|
310
|
+
});
|
|
311
|
+
expect(items[0].price).toBe(29);
|
|
312
|
+
expect(items[1].price).toBe(199);
|
|
313
|
+
expect(items[2].price).toBe(999);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should apply descending sort', async () => {
|
|
317
|
+
const items = await service.queryItems({
|
|
318
|
+
collection: 'products',
|
|
319
|
+
sort: { field: 'price', order: 'desc' },
|
|
320
|
+
});
|
|
321
|
+
expect(items[0].price).toBe(999);
|
|
322
|
+
expect(items[1].price).toBe(199);
|
|
323
|
+
expect(items[2].price).toBe(29);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should apply multi-field sort', async () => {
|
|
327
|
+
const items = await service.queryItems({
|
|
328
|
+
collection: 'products',
|
|
329
|
+
sort: [
|
|
330
|
+
{ field: 'featured', order: 'desc' },
|
|
331
|
+
{ field: 'price', order: 'asc' },
|
|
332
|
+
],
|
|
333
|
+
});
|
|
334
|
+
// Featured items first (true > false), then by price ascending
|
|
335
|
+
expect(items[0].slug).toBe('headphones'); // featured=true, price=199
|
|
336
|
+
expect(items[1].slug).toBe('laptop'); // featured=true, price=999
|
|
337
|
+
expect(items[2].slug).toBe('tshirt'); // featured=false, price=29
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should apply limit', async () => {
|
|
341
|
+
const items = await service.queryItems({
|
|
342
|
+
collection: 'products',
|
|
343
|
+
limit: 2,
|
|
344
|
+
});
|
|
345
|
+
expect(items).toHaveLength(2);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should apply offset', async () => {
|
|
349
|
+
const items = await service.queryItems({
|
|
350
|
+
collection: 'products',
|
|
351
|
+
offset: 1,
|
|
352
|
+
});
|
|
353
|
+
expect(items).toHaveLength(2);
|
|
354
|
+
expect(items[0].slug).toBe('tshirt');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should apply limit and offset together', async () => {
|
|
358
|
+
const items = await service.queryItems({
|
|
359
|
+
collection: 'products',
|
|
360
|
+
sort: { field: 'price', order: 'asc' },
|
|
361
|
+
offset: 1,
|
|
362
|
+
limit: 1,
|
|
363
|
+
});
|
|
364
|
+
expect(items).toHaveLength(1);
|
|
365
|
+
expect(items[0].price).toBe(199);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should return empty array for empty collection', async () => {
|
|
369
|
+
const items = await service.queryItems({ collection: 'unknown' });
|
|
370
|
+
expect(items).toEqual([]);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('patternToRegex', () => {
|
|
375
|
+
it('should handle simple slug patterns', async () => {
|
|
376
|
+
// Test via matchRoute since patternToRegex is private
|
|
377
|
+
const match = await service.matchRoute('/blog/my-test-post');
|
|
378
|
+
// Will be null because item doesn't exist, but we're testing the pattern
|
|
379
|
+
expect(match).toBeNull();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should handle special regex characters in pattern', async () => {
|
|
383
|
+
// The pattern itself shouldn't have special chars, but slugs can
|
|
384
|
+
const match = await service.matchRoute('/blog/hello-world');
|
|
385
|
+
expect(match).not.toBeNull();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMS Service
|
|
3
|
+
* Handles CMS schema extraction, route matching, and content querying
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { CMSProvider, CMSSchemaInfo } from '../../shared/interfaces/contentProvider';
|
|
7
|
+
import type {
|
|
8
|
+
CMSSchema,
|
|
9
|
+
CMSItem,
|
|
10
|
+
CMSRouteMatch,
|
|
11
|
+
CMSListQuery,
|
|
12
|
+
CMSFilterCondition,
|
|
13
|
+
CMSSortConfig,
|
|
14
|
+
} from '../../shared/types';
|
|
15
|
+
|
|
16
|
+
interface RoutePattern {
|
|
17
|
+
regex: RegExp;
|
|
18
|
+
collection: string;
|
|
19
|
+
slugGroup: number;
|
|
20
|
+
pagePath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* CMS Service
|
|
25
|
+
* Manages CMS schemas, route matching, and content querying
|
|
26
|
+
*/
|
|
27
|
+
export class CMSService {
|
|
28
|
+
private schemaCache = new Map<string, CMSSchemaInfo>();
|
|
29
|
+
private routePatterns: RoutePattern[] = [];
|
|
30
|
+
private provider?: CMSProvider;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates a new CMSService instance
|
|
34
|
+
* @param provider - Optional CMSProvider for loading data (enables DI for testing)
|
|
35
|
+
*/
|
|
36
|
+
constructor(provider?: CMSProvider) {
|
|
37
|
+
this.provider = provider;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Set the CMS provider
|
|
42
|
+
* Allows setting provider after construction for backward compatibility
|
|
43
|
+
* @param provider - CMSProvider implementation
|
|
44
|
+
*/
|
|
45
|
+
setProvider(provider: CMSProvider): void {
|
|
46
|
+
this.provider = provider;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initialize service - extract schemas from pages and build route patterns
|
|
51
|
+
*/
|
|
52
|
+
async initialize(): Promise<void> {
|
|
53
|
+
if (!this.provider) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const schemas = await this.provider.getAllSchemas();
|
|
58
|
+
|
|
59
|
+
for (const [id, schemaInfo] of schemas) {
|
|
60
|
+
this.schemaCache.set(id, schemaInfo);
|
|
61
|
+
this.routePatterns.push({
|
|
62
|
+
regex: this.patternToRegex(schemaInfo.schema.urlPattern),
|
|
63
|
+
collection: id,
|
|
64
|
+
slugGroup: 1,
|
|
65
|
+
pagePath: schemaInfo.pagePath,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convert URL pattern to regex
|
|
72
|
+
* e.g., "/blog/{{slug}}" -> /^\/blog\/([^\/]+)$/
|
|
73
|
+
*/
|
|
74
|
+
private patternToRegex(pattern: string): RegExp {
|
|
75
|
+
// Escape special regex characters except our placeholder
|
|
76
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
// Replace the escaped placeholder with a capture group
|
|
78
|
+
const withCapture = escaped.replace(/\\\{\\\{slug\\\}\\\}/g, '([^/]+)');
|
|
79
|
+
return new RegExp(`^${withCapture}$`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Match URL against CMS route patterns
|
|
84
|
+
* @param path - URL path to match
|
|
85
|
+
* @returns CMSRouteMatch if matched, null otherwise
|
|
86
|
+
*/
|
|
87
|
+
async matchRoute(path: string): Promise<CMSRouteMatch | null> {
|
|
88
|
+
if (!this.provider) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const route of this.routePatterns) {
|
|
93
|
+
const match = path.match(route.regex);
|
|
94
|
+
if (match) {
|
|
95
|
+
const slug = match[route.slugGroup];
|
|
96
|
+
const item = await this.provider.getItemBySlug(route.collection, slug);
|
|
97
|
+
|
|
98
|
+
if (item) {
|
|
99
|
+
return {
|
|
100
|
+
collection: route.collection,
|
|
101
|
+
slug,
|
|
102
|
+
item,
|
|
103
|
+
pagePath: route.pagePath,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get all URLs for a collection (for static generation)
|
|
113
|
+
* @param collection - Collection ID
|
|
114
|
+
* @returns Array of URLs
|
|
115
|
+
*/
|
|
116
|
+
async getCollectionURLs(collection: string): Promise<string[]> {
|
|
117
|
+
if (!this.provider) {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const schemaInfo = this.schemaCache.get(collection);
|
|
122
|
+
if (!schemaInfo) return [];
|
|
123
|
+
|
|
124
|
+
const items = await this.provider.getItems(collection);
|
|
125
|
+
return items.map(item =>
|
|
126
|
+
schemaInfo.schema.urlPattern.replace('{{slug}}', String(item[schemaInfo.schema.slugField]))
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get schema for collection
|
|
132
|
+
* @param collection - Collection ID
|
|
133
|
+
* @returns CMSSchema or undefined
|
|
134
|
+
*/
|
|
135
|
+
getSchema(collection: string): CMSSchema | undefined {
|
|
136
|
+
return this.schemaCache.get(collection)?.schema;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Get all schemas
|
|
141
|
+
* @returns Map of collection ID to CMSSchemaInfo
|
|
142
|
+
*/
|
|
143
|
+
getAllSchemas(): Map<string, CMSSchemaInfo> {
|
|
144
|
+
return this.schemaCache;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Refresh schemas from provider
|
|
149
|
+
* Call this after adding/removing collections to update the cache
|
|
150
|
+
*/
|
|
151
|
+
async refreshSchemas(): Promise<void> {
|
|
152
|
+
if (!this.provider) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Clear existing caches
|
|
157
|
+
this.schemaCache.clear();
|
|
158
|
+
this.routePatterns = [];
|
|
159
|
+
|
|
160
|
+
// Clear provider cache if available
|
|
161
|
+
if ('clearSchemaCache' in this.provider && typeof this.provider.clearSchemaCache === 'function') {
|
|
162
|
+
this.provider.clearSchemaCache();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Re-initialize
|
|
166
|
+
await this.initialize();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Query items with filter/sort/limit
|
|
171
|
+
* @param query - CMSListQuery with collection, filter, sort, limit, offset
|
|
172
|
+
* @returns Filtered and sorted array of CMSItems
|
|
173
|
+
*/
|
|
174
|
+
async queryItems(query: CMSListQuery): Promise<CMSItem[]> {
|
|
175
|
+
if (!this.provider) {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let items = await this.provider.getItems(query.collection);
|
|
180
|
+
|
|
181
|
+
// Apply filters
|
|
182
|
+
if (query.filter) {
|
|
183
|
+
items = this.applyFilters(items, query.filter);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Apply sorting
|
|
187
|
+
if (query.sort) {
|
|
188
|
+
items = this.applySorting(items, query.sort);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Apply offset
|
|
192
|
+
if (query.offset !== undefined && query.offset > 0) {
|
|
193
|
+
items = items.slice(query.offset);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Apply limit
|
|
197
|
+
if (query.limit !== undefined && query.limit > 0) {
|
|
198
|
+
items = items.slice(0, query.limit);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return items;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Apply filters to items
|
|
206
|
+
*/
|
|
207
|
+
private applyFilters(
|
|
208
|
+
items: CMSItem[],
|
|
209
|
+
filter: CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown>
|
|
210
|
+
): CMSItem[] {
|
|
211
|
+
// Handle simple object filter: { featured: true }
|
|
212
|
+
if (!Array.isArray(filter) && !this.isFilterCondition(filter)) {
|
|
213
|
+
return items.filter(item =>
|
|
214
|
+
Object.entries(filter).every(([key, value]) => item[key] === value)
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Handle array of conditions or single condition
|
|
219
|
+
const conditions = Array.isArray(filter) ? filter : [filter as CMSFilterCondition];
|
|
220
|
+
return items.filter(item =>
|
|
221
|
+
conditions.every(cond => this.matchCondition(item, cond))
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if object is a CMSFilterCondition
|
|
227
|
+
*/
|
|
228
|
+
private isFilterCondition(obj: unknown): obj is CMSFilterCondition {
|
|
229
|
+
return typeof obj === 'object' && obj !== null && 'field' in obj;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Match a single filter condition against an item
|
|
234
|
+
*/
|
|
235
|
+
private matchCondition(item: CMSItem, condition: CMSFilterCondition): boolean {
|
|
236
|
+
const value = item[condition.field];
|
|
237
|
+
const op = condition.operator || 'eq';
|
|
238
|
+
|
|
239
|
+
switch (op) {
|
|
240
|
+
case 'eq':
|
|
241
|
+
return value === condition.value;
|
|
242
|
+
case 'neq':
|
|
243
|
+
return value !== condition.value;
|
|
244
|
+
case 'gt':
|
|
245
|
+
return (value as number) > (condition.value as number);
|
|
246
|
+
case 'gte':
|
|
247
|
+
return (value as number) >= (condition.value as number);
|
|
248
|
+
case 'lt':
|
|
249
|
+
return (value as number) < (condition.value as number);
|
|
250
|
+
case 'lte':
|
|
251
|
+
return (value as number) <= (condition.value as number);
|
|
252
|
+
case 'contains':
|
|
253
|
+
return String(value).includes(String(condition.value));
|
|
254
|
+
case 'in':
|
|
255
|
+
return Array.isArray(condition.value) && condition.value.includes(value);
|
|
256
|
+
default:
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Apply sorting to items
|
|
263
|
+
*/
|
|
264
|
+
private applySorting(items: CMSItem[], sort: CMSSortConfig | CMSSortConfig[]): CMSItem[] {
|
|
265
|
+
const sorts = Array.isArray(sort) ? sort : [sort];
|
|
266
|
+
|
|
267
|
+
return [...items].sort((a, b) => {
|
|
268
|
+
for (const s of sorts) {
|
|
269
|
+
const aVal = a[s.field];
|
|
270
|
+
const bVal = b[s.field];
|
|
271
|
+
const isDesc = s.order === 'desc';
|
|
272
|
+
|
|
273
|
+
// Handle boolean comparison
|
|
274
|
+
if (typeof aVal === 'boolean' && typeof bVal === 'boolean') {
|
|
275
|
+
if (aVal === bVal) continue;
|
|
276
|
+
// For desc: true first (true > false), for asc: false first
|
|
277
|
+
if (isDesc) {
|
|
278
|
+
return aVal ? -1 : 1;
|
|
279
|
+
} else {
|
|
280
|
+
return aVal ? 1 : -1;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Standard comparison (compare as primitives)
|
|
285
|
+
let result = 0;
|
|
286
|
+
if ((aVal as string | number) < (bVal as string | number)) result = -1;
|
|
287
|
+
else if ((aVal as string | number) > (bVal as string | number)) result = 1;
|
|
288
|
+
|
|
289
|
+
if (result !== 0) {
|
|
290
|
+
return isDesc ? -result : result;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return 0;
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|