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.
Files changed (231) hide show
  1. package/bin/cli.ts +281 -0
  2. package/build-static.ts +298 -0
  3. package/bunfig.toml +39 -0
  4. package/entries/client-router.tsx +111 -0
  5. package/entries/server-router.tsx +71 -0
  6. package/lib/client/ClientInitializer.test.ts +9 -0
  7. package/lib/client/ClientInitializer.test.ts.skip +92 -0
  8. package/lib/client/ClientInitializer.ts +60 -0
  9. package/lib/client/ErrorBoundary.test.tsx +595 -0
  10. package/lib/client/ErrorBoundary.tsx +230 -0
  11. package/lib/client/componentRegistry.test.ts +165 -0
  12. package/lib/client/componentRegistry.ts +18 -0
  13. package/lib/client/contexts/ThemeContext.tsx +73 -0
  14. package/lib/client/core/ComponentBuilder.test.ts +677 -0
  15. package/lib/client/core/ComponentBuilder.ts +660 -0
  16. package/lib/client/core/ComponentRenderer.test.tsx +176 -0
  17. package/lib/client/core/ComponentRenderer.tsx +83 -0
  18. package/lib/client/core/cmsTemplateProcessor.ts +129 -0
  19. package/lib/client/elementRegistry.ts +81 -0
  20. package/lib/client/hmr/HMRManager.tsx +179 -0
  21. package/lib/client/hmr/index.ts +5 -0
  22. package/lib/client/hmrWebSocket.test.ts +9 -0
  23. package/lib/client/hmrWebSocket.ts +250 -0
  24. package/lib/client/hooks/useColorVariables.test.ts +166 -0
  25. package/lib/client/hooks/useColorVariables.ts +249 -0
  26. package/lib/client/hooks/usePropertyAutocomplete.test.ts +9 -0
  27. package/lib/client/hooks/usePropertyAutocomplete.ts +40 -0
  28. package/lib/client/hydration/HydrationUtils.test.ts +154 -0
  29. package/lib/client/hydration/HydrationUtils.ts +35 -0
  30. package/lib/client/i18nConfigService.test.ts +74 -0
  31. package/lib/client/i18nConfigService.ts +78 -0
  32. package/lib/client/index.ts +56 -0
  33. package/lib/client/navigation.test.ts +441 -0
  34. package/lib/client/navigation.ts +23 -0
  35. package/lib/client/responsiveStyleResolver.test.ts +491 -0
  36. package/lib/client/responsiveStyleResolver.ts +184 -0
  37. package/lib/client/routing/RouteLoader.test.ts +635 -0
  38. package/lib/client/routing/RouteLoader.ts +347 -0
  39. package/lib/client/routing/Router.tsx +382 -0
  40. package/lib/client/scripts/ScriptExecutor.test.ts +489 -0
  41. package/lib/client/scripts/ScriptExecutor.ts +171 -0
  42. package/lib/client/scripts/formHandler.ts +103 -0
  43. package/lib/client/styleProcessor.test.ts +126 -0
  44. package/lib/client/styleProcessor.ts +92 -0
  45. package/lib/client/styles/StyleInjector.test.ts +354 -0
  46. package/lib/client/styles/StyleInjector.ts +154 -0
  47. package/lib/client/templateEngine.test.ts +660 -0
  48. package/lib/client/templateEngine.ts +667 -0
  49. package/lib/client/theme.test.ts +173 -0
  50. package/lib/client/theme.ts +159 -0
  51. package/lib/client/utils/toast.ts +46 -0
  52. package/lib/server/createServer.ts +170 -0
  53. package/lib/server/cssGenerator.test.ts +172 -0
  54. package/lib/server/cssGenerator.ts +58 -0
  55. package/lib/server/fileWatcher.ts +134 -0
  56. package/lib/server/index.ts +55 -0
  57. package/lib/server/jsonLoader.test.ts +103 -0
  58. package/lib/server/jsonLoader.ts +350 -0
  59. package/lib/server/middleware/cors.test.ts +177 -0
  60. package/lib/server/middleware/cors.ts +69 -0
  61. package/lib/server/middleware/errorHandler.test.ts +208 -0
  62. package/lib/server/middleware/errorHandler.ts +63 -0
  63. package/lib/server/middleware/index.ts +9 -0
  64. package/lib/server/middleware/logger.test.ts +233 -0
  65. package/lib/server/middleware/logger.ts +99 -0
  66. package/lib/server/pageCache.test.ts +167 -0
  67. package/lib/server/pageCache.ts +97 -0
  68. package/lib/server/projectContext.ts +51 -0
  69. package/lib/server/providers/fileSystemCMSProvider.test.ts +292 -0
  70. package/lib/server/providers/fileSystemCMSProvider.ts +227 -0
  71. package/lib/server/providers/fileSystemPageProvider.ts +83 -0
  72. package/lib/server/routes/api/cms.test.ts +177 -0
  73. package/lib/server/routes/api/cms.ts +82 -0
  74. package/lib/server/routes/api/colors.ts +59 -0
  75. package/lib/server/routes/api/components.ts +70 -0
  76. package/lib/server/routes/api/config.test.ts +9 -0
  77. package/lib/server/routes/api/config.ts +28 -0
  78. package/lib/server/routes/api/core-routes.ts +182 -0
  79. package/lib/server/routes/api/functions.ts +170 -0
  80. package/lib/server/routes/api/index.ts +69 -0
  81. package/lib/server/routes/api/pages.ts +95 -0
  82. package/lib/server/routes/api/shared.test.ts +81 -0
  83. package/lib/server/routes/api/shared.ts +31 -0
  84. package/lib/server/routes/editor.test.ts +9 -0
  85. package/lib/server/routes/index.ts +104 -0
  86. package/lib/server/routes/pages.ts +161 -0
  87. package/lib/server/routes/static.ts +107 -0
  88. package/lib/server/services/ColorService.ts +193 -0
  89. package/lib/server/services/cmsService.test.ts +388 -0
  90. package/lib/server/services/cmsService.ts +296 -0
  91. package/lib/server/services/componentService.test.ts +276 -0
  92. package/lib/server/services/componentService.ts +346 -0
  93. package/lib/server/services/configService.ts +156 -0
  94. package/lib/server/services/fileWatcherService.ts +67 -0
  95. package/lib/server/services/index.ts +10 -0
  96. package/lib/server/services/pageService.test.ts +258 -0
  97. package/lib/server/services/pageService.ts +240 -0
  98. package/lib/server/ssrRenderer.test.ts +1005 -0
  99. package/lib/server/ssrRenderer.ts +878 -0
  100. package/lib/server/utilityClassGenerator.ts +11 -0
  101. package/lib/server/utils/index.ts +5 -0
  102. package/lib/server/utils/jsonLineMapper.test.ts +100 -0
  103. package/lib/server/utils/jsonLineMapper.ts +166 -0
  104. package/lib/server/validateStyleCoverage.test.ts +9 -0
  105. package/lib/server/validateStyleCoverage.ts +167 -0
  106. package/lib/server/websocketManager.test.ts +9 -0
  107. package/lib/server/websocketManager.ts +95 -0
  108. package/lib/shared/attributeNodeUtils.test.ts +152 -0
  109. package/lib/shared/attributeNodeUtils.ts +50 -0
  110. package/lib/shared/breakpoints.test.ts +166 -0
  111. package/lib/shared/breakpoints.ts +65 -0
  112. package/lib/shared/colorProperties.test.ts +111 -0
  113. package/lib/shared/colorProperties.ts +40 -0
  114. package/lib/shared/colorVariableUtils.test.ts +319 -0
  115. package/lib/shared/colorVariableUtils.ts +97 -0
  116. package/lib/shared/constants.test.ts +175 -0
  117. package/lib/shared/constants.ts +116 -0
  118. package/lib/shared/cssGeneration.ts +481 -0
  119. package/lib/shared/cssProperties.test.ts +252 -0
  120. package/lib/shared/cssProperties.ts +338 -0
  121. package/lib/shared/elementUtils.test.ts +245 -0
  122. package/lib/shared/elementUtils.ts +90 -0
  123. package/lib/shared/fontLoader.ts +97 -0
  124. package/lib/shared/i18n.test.ts +313 -0
  125. package/lib/shared/i18n.ts +286 -0
  126. package/lib/shared/index.ts +50 -0
  127. package/lib/shared/interfaces/contentProvider.test.ts +9 -0
  128. package/lib/shared/interfaces/contentProvider.ts +121 -0
  129. package/lib/shared/nodeUtils.test.ts +320 -0
  130. package/lib/shared/nodeUtils.ts +220 -0
  131. package/lib/shared/pathArrayUtils.test.ts +315 -0
  132. package/lib/shared/pathArrayUtils.ts +17 -0
  133. package/lib/shared/pathUtils.test.ts +260 -0
  134. package/lib/shared/pathUtils.ts +244 -0
  135. package/lib/shared/paths/Path.test.ts +74 -0
  136. package/lib/shared/paths/Path.ts +23 -0
  137. package/lib/shared/paths/PathConverter.test.ts +232 -0
  138. package/lib/shared/paths/PathConverter.ts +141 -0
  139. package/lib/shared/paths/PathUtils.ts +290 -0
  140. package/lib/shared/paths/PathValidator.test.ts +193 -0
  141. package/lib/shared/paths/PathValidator.ts +53 -0
  142. package/lib/shared/paths/index.ts +48 -0
  143. package/lib/shared/propResolver.test.ts +639 -0
  144. package/lib/shared/propResolver.ts +124 -0
  145. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +190 -0
  146. package/lib/shared/registry/BaseNodeTypeRegistry.ts +200 -0
  147. package/lib/shared/registry/ClientNodeTypeRegistry.ts +34 -0
  148. package/lib/shared/registry/ClientRegistry.test.ts +26 -0
  149. package/lib/shared/registry/ClientRegistry.ts +15 -0
  150. package/lib/shared/registry/ComponentRegistry.test.ts +293 -0
  151. package/lib/shared/registry/ComponentRegistry.ts +100 -0
  152. package/lib/shared/registry/NodeTypeDefinition.ts +198 -0
  153. package/lib/shared/registry/NodeTypeManager.ts +94 -0
  154. package/lib/shared/registry/RegistryManager.test.ts +58 -0
  155. package/lib/shared/registry/RegistryManager.ts +60 -0
  156. package/lib/shared/registry/SSRNodeTypeRegistry.ts +33 -0
  157. package/lib/shared/registry/SSRRegistry.test.ts +26 -0
  158. package/lib/shared/registry/SSRRegistry.ts +15 -0
  159. package/lib/shared/registry/createNodeType.ts +175 -0
  160. package/lib/shared/registry/defineNodeType.ts +73 -0
  161. package/lib/shared/registry/fieldPresets.ts +109 -0
  162. package/lib/shared/registry/index.ts +50 -0
  163. package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +71 -0
  164. package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +61 -0
  165. package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +88 -0
  166. package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +66 -0
  167. package/lib/shared/registry/nodeTypes/ObjectLinkNodeType.ts +75 -0
  168. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +49 -0
  169. package/lib/shared/registry/nodeTypes/TextNodeType.ts +52 -0
  170. package/lib/shared/registry/nodeTypes/index.ts +75 -0
  171. package/lib/shared/responsiveScaling.test.ts +268 -0
  172. package/lib/shared/responsiveScaling.ts +194 -0
  173. package/lib/shared/responsiveStyleUtils.test.ts +300 -0
  174. package/lib/shared/responsiveStyleUtils.ts +139 -0
  175. package/lib/shared/slugTranslator.test.ts +325 -0
  176. package/lib/shared/slugTranslator.ts +177 -0
  177. package/lib/shared/styleNodeUtils.test.ts +132 -0
  178. package/lib/shared/styleNodeUtils.ts +102 -0
  179. package/lib/shared/styleUtils.test.ts +238 -0
  180. package/lib/shared/styleUtils.ts +63 -0
  181. package/lib/shared/themeDefaults.test.ts +113 -0
  182. package/lib/shared/themeDefaults.ts +103 -0
  183. package/lib/shared/tree/PathBuilder.ts +383 -0
  184. package/lib/shared/treePathUtils.test.ts +539 -0
  185. package/lib/shared/treePathUtils.ts +339 -0
  186. package/lib/shared/types/api.ts +58 -0
  187. package/lib/shared/types/cms.ts +95 -0
  188. package/lib/shared/types/colors.ts +45 -0
  189. package/lib/shared/types/components.ts +121 -0
  190. package/lib/shared/types/errors.test.ts +103 -0
  191. package/lib/shared/types/errors.ts +69 -0
  192. package/lib/shared/types/index.ts +96 -0
  193. package/lib/shared/types/nodes.ts +20 -0
  194. package/lib/shared/types/rendering.ts +61 -0
  195. package/lib/shared/types/styles.ts +38 -0
  196. package/lib/shared/types.ts +11 -0
  197. package/lib/shared/utilityClassConfig.ts +287 -0
  198. package/lib/shared/utilityClassMapper.test.ts +140 -0
  199. package/lib/shared/utilityClassMapper.ts +229 -0
  200. package/lib/shared/utils/fileUtils.test.ts +99 -0
  201. package/lib/shared/utils/fileUtils.ts +56 -0
  202. package/lib/shared/utils.test.ts +261 -0
  203. package/lib/shared/utils.ts +84 -0
  204. package/lib/shared/validation/index.ts +7 -0
  205. package/lib/shared/validation/propValidator.test.ts +178 -0
  206. package/lib/shared/validation/propValidator.ts +238 -0
  207. package/lib/shared/validation/schemas.test.ts +177 -0
  208. package/lib/shared/validation/schemas.ts +401 -0
  209. package/lib/shared/validation/validators.test.ts +109 -0
  210. package/lib/shared/validation/validators.ts +304 -0
  211. package/lib/test-utils/dom-setup.ts +55 -0
  212. package/lib/test-utils/factories/ConsoleMockFactory.ts +200 -0
  213. package/lib/test-utils/factories/DomMockFactory.ts +487 -0
  214. package/lib/test-utils/factories/EventMockFactory.ts +244 -0
  215. package/lib/test-utils/factories/FetchMockFactory.ts +210 -0
  216. package/lib/test-utils/factories/ServerMockFactory.ts +223 -0
  217. package/lib/test-utils/factories/StoreMockFactory.ts +370 -0
  218. package/lib/test-utils/factories/index.ts +11 -0
  219. package/lib/test-utils/fixtures.ts +134 -0
  220. package/lib/test-utils/helpers/asyncHelpers.test.ts +112 -0
  221. package/lib/test-utils/helpers/asyncHelpers.ts +196 -0
  222. package/lib/test-utils/helpers/index.ts +6 -0
  223. package/lib/test-utils/helpers.test.ts +73 -0
  224. package/lib/test-utils/helpers.ts +90 -0
  225. package/lib/test-utils/index.ts +17 -0
  226. package/lib/test-utils/mockFactories.ts +92 -0
  227. package/lib/test-utils/mocks.ts +341 -0
  228. package/package.json +38 -0
  229. package/templates/index-router.html +34 -0
  230. package/tsconfig.json +14 -0
  231. 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
+ }