meno-core 1.0.21 → 1.0.23

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 (59) hide show
  1. package/build-static.test.ts +424 -0
  2. package/build-static.ts +100 -13
  3. package/lib/client/ClientInitializer.ts +4 -0
  4. package/lib/client/core/ComponentBuilder.ts +155 -16
  5. package/lib/client/core/builders/embedBuilder.ts +48 -6
  6. package/lib/client/core/builders/linkBuilder.ts +2 -2
  7. package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
  8. package/lib/client/core/builders/listBuilder.ts +12 -3
  9. package/lib/client/routing/Router.tsx +8 -1
  10. package/lib/client/templateEngine.ts +89 -98
  11. package/lib/server/__integration__/api-routes.test.ts +148 -0
  12. package/lib/server/__integration__/cms-integration.test.ts +161 -0
  13. package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
  14. package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
  15. package/lib/server/__integration__/static-assets.test.ts +80 -0
  16. package/lib/server/__integration__/test-helpers.ts +205 -0
  17. package/lib/server/ab/generateFunctions.ts +346 -0
  18. package/lib/server/ab/trackingScript.ts +45 -0
  19. package/lib/server/index.ts +2 -2
  20. package/lib/server/jsonLoader.ts +124 -46
  21. package/lib/server/routes/api/cms.ts +3 -2
  22. package/lib/server/routes/api/components.ts +13 -2
  23. package/lib/server/services/cmsService.ts +0 -5
  24. package/lib/server/services/componentService.ts +255 -29
  25. package/lib/server/services/configService.test.ts +950 -0
  26. package/lib/server/services/configService.ts +39 -0
  27. package/lib/server/services/index.ts +1 -1
  28. package/lib/server/ssr/htmlGenerator.test.ts +992 -0
  29. package/lib/server/ssr/htmlGenerator.ts +3 -3
  30. package/lib/server/ssr/imageMetadata.test.ts +168 -0
  31. package/lib/server/ssr/imageMetadata.ts +58 -0
  32. package/lib/server/ssr/jsCollector.test.ts +287 -0
  33. package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
  34. package/lib/server/ssr/ssrRenderer.ts +131 -15
  35. package/lib/shared/constants.ts +3 -0
  36. package/lib/shared/fontLoader.test.ts +335 -0
  37. package/lib/shared/i18n.test.ts +106 -0
  38. package/lib/shared/i18n.ts +17 -11
  39. package/lib/shared/index.ts +3 -0
  40. package/lib/shared/itemTemplateUtils.ts +43 -1
  41. package/lib/shared/libraryLoader.test.ts +392 -0
  42. package/lib/shared/linkUtils.ts +24 -0
  43. package/lib/shared/nodeUtils.test.ts +100 -0
  44. package/lib/shared/nodeUtils.ts +43 -0
  45. package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
  46. package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
  47. package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
  48. package/lib/shared/richtext/htmlToTiptap.ts +46 -2
  49. package/lib/shared/richtext/tiptapToHtml.ts +65 -0
  50. package/lib/shared/richtext/types.ts +4 -1
  51. package/lib/shared/types/cms.ts +2 -0
  52. package/lib/shared/types/components.ts +12 -3
  53. package/lib/shared/types/experiments.ts +55 -0
  54. package/lib/shared/types/index.ts +10 -0
  55. package/lib/shared/utils.ts +2 -6
  56. package/lib/shared/validation/propValidator.test.ts +50 -0
  57. package/lib/shared/validation/propValidator.ts +2 -2
  58. package/lib/shared/validation/schemas.ts +10 -2
  59. package/package.json +1 -1
@@ -0,0 +1,950 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { join } from 'path';
3
+ import { mkdirSync, rmSync, writeFileSync, existsSync } from 'fs';
4
+
5
+ // We need to mock projectPaths.config() before importing ConfigService.
6
+ // Since projectPaths is a module-level export, we mock the projectContext module.
7
+ import { setProjectRoot, getProjectRoot } from '../../server/projectContext';
8
+ import { ConfigService } from './configService';
9
+ import type { IconsConfig, EnumsConfig } from './configService';
10
+ import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
11
+ import { DEFAULT_RESPONSIVE_SCALES } from '../../shared/responsiveScaling';
12
+ import { DEFAULT_I18N_CONFIG } from '../../shared/i18n';
13
+
14
+ // Use a unique temp directory for each test run
15
+ const TEST_DIR = join('/tmp', `configService-test-${process.pid}`);
16
+ const CONFIG_PATH = join(TEST_DIR, 'project.config.json');
17
+
18
+ function writeConfig(config: Record<string, unknown>): void {
19
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
20
+ }
21
+
22
+ function removeConfig(): void {
23
+ if (existsSync(CONFIG_PATH)) {
24
+ rmSync(CONFIG_PATH);
25
+ }
26
+ }
27
+
28
+ describe('ConfigService', () => {
29
+ let service: ConfigService;
30
+ let originalRoot: string;
31
+
32
+ beforeEach(() => {
33
+ // Save original project root and set to our temp dir
34
+ originalRoot = getProjectRoot();
35
+ mkdirSync(TEST_DIR, { recursive: true });
36
+ setProjectRoot(TEST_DIR);
37
+ service = new ConfigService();
38
+ });
39
+
40
+ afterEach(() => {
41
+ // Restore original project root and clean up temp files
42
+ setProjectRoot(originalRoot);
43
+ rmSync(TEST_DIR, { recursive: true, force: true });
44
+ });
45
+
46
+ // =========================================================
47
+ // load()
48
+ // =========================================================
49
+ describe('load()', () => {
50
+ test('should load config from file', async () => {
51
+ writeConfig({ icons: { favicon: '/favicon.ico' } });
52
+
53
+ await service.load();
54
+
55
+ expect(service.isLoaded()).toBe(true);
56
+ expect(service.getIcons()).toEqual({ favicon: '/favicon.ico' });
57
+ });
58
+
59
+ test('should return defaults when file does not exist', async () => {
60
+ // No config file written
61
+ removeConfig();
62
+
63
+ await service.load();
64
+
65
+ expect(service.isLoaded()).toBe(true);
66
+ // All getters should return defaults
67
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
68
+ expect(service.getIcons()).toEqual({});
69
+ expect(service.getLibraries()).toEqual({ js: [], css: [] });
70
+ });
71
+
72
+ test('should only load once (idempotent)', async () => {
73
+ writeConfig({ icons: { favicon: '/first.ico' } });
74
+ await service.load();
75
+ expect(service.getIcons()).toEqual({ favicon: '/first.ico' });
76
+
77
+ // Overwrite the file with different content
78
+ writeConfig({ icons: { favicon: '/second.ico' } });
79
+ await service.load(); // Should not reload
80
+
81
+ // Still returns the first value since it was already loaded
82
+ expect(service.getIcons()).toEqual({ favicon: '/first.ico' });
83
+ });
84
+
85
+ test('should handle invalid JSON gracefully', async () => {
86
+ writeFileSync(CONFIG_PATH, '{ not valid json !!!');
87
+
88
+ await service.load();
89
+
90
+ expect(service.isLoaded()).toBe(true);
91
+ // Falls back to defaults
92
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
93
+ expect(service.getIcons()).toEqual({});
94
+ });
95
+ });
96
+
97
+ // =========================================================
98
+ // isLoaded() / reset()
99
+ // =========================================================
100
+ describe('isLoaded()', () => {
101
+ test('should return false before load', () => {
102
+ expect(service.isLoaded()).toBe(false);
103
+ });
104
+
105
+ test('should return true after load', async () => {
106
+ removeConfig();
107
+ await service.load();
108
+ expect(service.isLoaded()).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('reset()', () => {
113
+ test('should clear loaded state', async () => {
114
+ writeConfig({ icons: { favicon: '/test.ico' } });
115
+ await service.load();
116
+ expect(service.isLoaded()).toBe(true);
117
+
118
+ service.reset();
119
+
120
+ expect(service.isLoaded()).toBe(false);
121
+ });
122
+
123
+ test('should allow reloading after reset', async () => {
124
+ writeConfig({ icons: { favicon: '/first.ico' } });
125
+ await service.load();
126
+ expect(service.getIcons()).toEqual({ favicon: '/first.ico' });
127
+
128
+ service.reset();
129
+
130
+ // Write new config and reload
131
+ writeConfig({ icons: { favicon: '/second.ico' } });
132
+ await service.load();
133
+ expect(service.getIcons()).toEqual({ favicon: '/second.ico' });
134
+ });
135
+
136
+ test('should clear config data so getters return defaults', async () => {
137
+ writeConfig({ icons: { favicon: '/test.ico' } });
138
+ await service.load();
139
+ expect(service.getIcons()).toEqual({ favicon: '/test.ico' });
140
+
141
+ service.reset();
142
+
143
+ // After reset, config is null so getters should return defaults
144
+ expect(service.getIcons()).toEqual({});
145
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
146
+ });
147
+ });
148
+
149
+ // =========================================================
150
+ // getBreakpoints()
151
+ // =========================================================
152
+ describe('getBreakpoints()', () => {
153
+ test('should return DEFAULT_BREAKPOINTS when no config', async () => {
154
+ removeConfig();
155
+ await service.load();
156
+
157
+ const result = service.getBreakpoints();
158
+ expect(result).toEqual({ ...DEFAULT_BREAKPOINTS });
159
+ });
160
+
161
+ test('should return DEFAULT_BREAKPOINTS when breakpoints is not an object', async () => {
162
+ writeConfig({ breakpoints: 'not-an-object' });
163
+ await service.load();
164
+
165
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
166
+ });
167
+
168
+ test('should return DEFAULT_BREAKPOINTS when breakpoints is null', async () => {
169
+ writeConfig({ breakpoints: null });
170
+ await service.load();
171
+
172
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
173
+ });
174
+
175
+ test('should normalize legacy number format to object format', async () => {
176
+ writeConfig({
177
+ breakpoints: {
178
+ tablet: 1024,
179
+ mobile: 540,
180
+ },
181
+ });
182
+ await service.load();
183
+
184
+ const result = service.getBreakpoints();
185
+ expect(result.tablet).toEqual({ breakpoint: 1024, previewPoint: 1024 });
186
+ expect(result.mobile).toEqual({ breakpoint: 540, previewPoint: 540 });
187
+ });
188
+
189
+ test('should return breakpoints from new object format', async () => {
190
+ writeConfig({
191
+ breakpoints: {
192
+ tablet: { breakpoint: 1024, previewPoint: 768 },
193
+ mobile: { breakpoint: 540, previewPoint: 375 },
194
+ },
195
+ });
196
+ await service.load();
197
+
198
+ const result = service.getBreakpoints();
199
+ expect(result.tablet).toEqual({ breakpoint: 1024, previewPoint: 768 });
200
+ expect(result.mobile).toEqual({ breakpoint: 540, previewPoint: 375 });
201
+ });
202
+
203
+ test('should default previewPoint to breakpoint value when not specified in object format', async () => {
204
+ writeConfig({
205
+ breakpoints: {
206
+ tablet: { breakpoint: 1024 },
207
+ },
208
+ });
209
+ await service.load();
210
+
211
+ const result = service.getBreakpoints();
212
+ expect(result.tablet.breakpoint).toBe(1024);
213
+ expect(result.tablet.previewPoint).toBe(1024);
214
+ });
215
+
216
+ test('should fall back to defaults for non-positive number values', async () => {
217
+ writeConfig({
218
+ breakpoints: {
219
+ tablet: 0,
220
+ mobile: -100,
221
+ },
222
+ });
223
+ await service.load();
224
+
225
+ // All values are invalid, so should return defaults
226
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
227
+ });
228
+
229
+ test('should fall back to defaults for non-number values in legacy format', async () => {
230
+ writeConfig({
231
+ breakpoints: {
232
+ tablet: 'not-a-number',
233
+ mobile: true,
234
+ },
235
+ });
236
+ await service.load();
237
+
238
+ // None are valid numbers or objects with valid breakpoint, so defaults
239
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
240
+ });
241
+
242
+ test('should filter out invalid entries but keep valid ones', async () => {
243
+ writeConfig({
244
+ breakpoints: {
245
+ tablet: 1024,
246
+ invalid: -5,
247
+ alsoInvalid: 'bad',
248
+ mobile: { breakpoint: 540, previewPoint: 375 },
249
+ },
250
+ });
251
+ await service.load();
252
+
253
+ const result = service.getBreakpoints();
254
+ expect(result.tablet).toEqual({ breakpoint: 1024, previewPoint: 1024 });
255
+ expect(result.mobile).toEqual({ breakpoint: 540, previewPoint: 375 });
256
+ expect(result.invalid).toBeUndefined();
257
+ expect(result.alsoInvalid).toBeUndefined();
258
+ });
259
+
260
+ test('should ignore object entries with non-positive breakpoint value', async () => {
261
+ writeConfig({
262
+ breakpoints: {
263
+ tablet: { breakpoint: -10, previewPoint: 768 },
264
+ },
265
+ });
266
+ await service.load();
267
+
268
+ // No valid entries, returns defaults
269
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
270
+ });
271
+
272
+ test('should fall back previewPoint to breakpoint when previewPoint is non-positive', async () => {
273
+ writeConfig({
274
+ breakpoints: {
275
+ tablet: { breakpoint: 1024, previewPoint: -5 },
276
+ },
277
+ });
278
+ await service.load();
279
+
280
+ const result = service.getBreakpoints();
281
+ expect(result.tablet.previewPoint).toBe(1024);
282
+ });
283
+
284
+ test('should support custom breakpoint names', async () => {
285
+ writeConfig({
286
+ breakpoints: {
287
+ tabletLandscape: 1200,
288
+ tabletPortrait: 900,
289
+ phoneLarge: 600,
290
+ phoneSmall: 375,
291
+ },
292
+ });
293
+ await service.load();
294
+
295
+ const result = service.getBreakpoints();
296
+ expect(Object.keys(result)).toHaveLength(4);
297
+ expect(result.tabletLandscape).toEqual({ breakpoint: 1200, previewPoint: 1200 });
298
+ expect(result.phoneSmall).toEqual({ breakpoint: 375, previewPoint: 375 });
299
+ });
300
+ });
301
+
302
+ // =========================================================
303
+ // getI18n()
304
+ // =========================================================
305
+ describe('getI18n()', () => {
306
+ test('should return DEFAULT_I18N_CONFIG when no config', async () => {
307
+ removeConfig();
308
+ await service.load();
309
+
310
+ expect(service.getI18n()).toEqual({ ...DEFAULT_I18N_CONFIG });
311
+ });
312
+
313
+ test('should return DEFAULT_I18N_CONFIG when i18n is not set', async () => {
314
+ writeConfig({});
315
+ await service.load();
316
+
317
+ expect(service.getI18n()).toEqual({ ...DEFAULT_I18N_CONFIG });
318
+ });
319
+
320
+ test('should migrate old string[] locale format', async () => {
321
+ writeConfig({
322
+ i18n: {
323
+ defaultLocale: 'en',
324
+ locales: ['en', 'pl'],
325
+ },
326
+ });
327
+ await service.load();
328
+
329
+ const result = service.getI18n();
330
+ expect(result.defaultLocale).toBe('en');
331
+ expect(result.locales).toHaveLength(2);
332
+ expect(result.locales[0].code).toBe('en');
333
+ expect(result.locales[1].code).toBe('pl');
334
+ // Migrated locales should have name, nativeName, langTag
335
+ expect(result.locales[1].name).toBe('PL');
336
+ expect(result.locales[1].nativeName).toBe('PL');
337
+ expect(result.locales[1].langTag).toBe('pl-PL');
338
+ });
339
+
340
+ test('should pass through new LocaleConfig[] format', async () => {
341
+ writeConfig({
342
+ i18n: {
343
+ defaultLocale: 'en',
344
+ locales: [
345
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
346
+ { code: 'de', name: 'German', nativeName: 'Deutsch', langTag: 'de-DE' },
347
+ ],
348
+ },
349
+ });
350
+ await service.load();
351
+
352
+ const result = service.getI18n();
353
+ expect(result.defaultLocale).toBe('en');
354
+ expect(result.locales).toHaveLength(2);
355
+ expect(result.locales[1]).toEqual({
356
+ code: 'de',
357
+ name: 'German',
358
+ nativeName: 'Deutsch',
359
+ langTag: 'de-DE',
360
+ });
361
+ });
362
+
363
+ test('should return default when i18n is a non-object value', async () => {
364
+ writeConfig({ i18n: 'invalid' });
365
+ await service.load();
366
+
367
+ expect(service.getI18n()).toEqual({ ...DEFAULT_I18N_CONFIG });
368
+ });
369
+ });
370
+
371
+ // =========================================================
372
+ // getResponsiveScales()
373
+ // =========================================================
374
+ describe('getResponsiveScales()', () => {
375
+ test('should return defaults when no config', async () => {
376
+ removeConfig();
377
+ await service.load();
378
+
379
+ expect(service.getResponsiveScales()).toEqual({ ...DEFAULT_RESPONSIVE_SCALES });
380
+ });
381
+
382
+ test('should return defaults when responsiveScales is not an object', async () => {
383
+ writeConfig({ responsiveScales: 'invalid' });
384
+ await service.load();
385
+
386
+ expect(service.getResponsiveScales()).toEqual({ ...DEFAULT_RESPONSIVE_SCALES });
387
+ });
388
+
389
+ test('should return defaults when responsiveScales is null', async () => {
390
+ writeConfig({ responsiveScales: null });
391
+ await service.load();
392
+
393
+ expect(service.getResponsiveScales()).toEqual({ ...DEFAULT_RESPONSIVE_SCALES });
394
+ });
395
+
396
+ test('should deep merge user scales with defaults', async () => {
397
+ writeConfig({
398
+ responsiveScales: {
399
+ enabled: true,
400
+ fontSize: {
401
+ tablet: 0.9,
402
+ // mobile should come from defaults
403
+ },
404
+ },
405
+ });
406
+ await service.load();
407
+
408
+ const result = service.getResponsiveScales();
409
+ expect(result.enabled).toBe(true);
410
+ expect(result.baseReference).toBe(DEFAULT_RESPONSIVE_SCALES.baseReference);
411
+ // User-defined tablet should override default
412
+ expect(result.fontSize!.tablet).toBe(0.9);
413
+ // Default mobile should be preserved
414
+ expect(result.fontSize!.mobile).toBe(DEFAULT_RESPONSIVE_SCALES.fontSize!.mobile);
415
+ // Padding should be fully from defaults
416
+ expect(result.padding).toEqual(DEFAULT_RESPONSIVE_SCALES.padding);
417
+ });
418
+
419
+ test('should override user baseReference', async () => {
420
+ writeConfig({
421
+ responsiveScales: {
422
+ baseReference: 20,
423
+ },
424
+ });
425
+ await service.load();
426
+
427
+ const result = service.getResponsiveScales();
428
+ expect(result.baseReference).toBe(20);
429
+ });
430
+
431
+ test('should allow user to override all scale categories', async () => {
432
+ writeConfig({
433
+ responsiveScales: {
434
+ enabled: true,
435
+ baseReference: 14,
436
+ fontSize: { small: 0.6 },
437
+ padding: { small: 0.3 },
438
+ margin: { small: 0.2 },
439
+ gap: { small: 0.1 },
440
+ },
441
+ });
442
+ await service.load();
443
+
444
+ const result = service.getResponsiveScales();
445
+ expect(result.enabled).toBe(true);
446
+ expect(result.baseReference).toBe(14);
447
+ // User custom breakpoint names should be included alongside defaults
448
+ expect(result.fontSize!.small).toBe(0.6);
449
+ expect(result.fontSize!.tablet).toBe(DEFAULT_RESPONSIVE_SCALES.fontSize!.tablet);
450
+ expect(result.padding!.small).toBe(0.3);
451
+ expect(result.margin!.small).toBe(0.2);
452
+ expect(result.gap!.small).toBe(0.1);
453
+ });
454
+
455
+ test('should use default enabled when user does not specify', async () => {
456
+ writeConfig({
457
+ responsiveScales: {
458
+ baseReference: 18,
459
+ },
460
+ });
461
+ await service.load();
462
+
463
+ const result = service.getResponsiveScales();
464
+ expect(result.enabled).toBe(DEFAULT_RESPONSIVE_SCALES.enabled);
465
+ });
466
+ });
467
+
468
+ // =========================================================
469
+ // getIcons()
470
+ // =========================================================
471
+ describe('getIcons()', () => {
472
+ test('should return empty object when no config', async () => {
473
+ removeConfig();
474
+ await service.load();
475
+
476
+ expect(service.getIcons()).toEqual({});
477
+ });
478
+
479
+ test('should return empty object when icons is not set', async () => {
480
+ writeConfig({});
481
+ await service.load();
482
+
483
+ expect(service.getIcons()).toEqual({});
484
+ });
485
+
486
+ test('should return empty object when icons is not an object', async () => {
487
+ writeConfig({ icons: 'invalid' });
488
+ await service.load();
489
+
490
+ expect(service.getIcons()).toEqual({});
491
+ });
492
+
493
+ test('should return icons object', async () => {
494
+ writeConfig({
495
+ icons: {
496
+ favicon: '/favicon.ico',
497
+ appleTouchIcon: '/apple-touch-icon.png',
498
+ },
499
+ });
500
+ await service.load();
501
+
502
+ expect(service.getIcons()).toEqual({
503
+ favicon: '/favicon.ico',
504
+ appleTouchIcon: '/apple-touch-icon.png',
505
+ });
506
+ });
507
+
508
+ test('should return partial icons config', async () => {
509
+ writeConfig({
510
+ icons: {
511
+ favicon: '/custom-favicon.svg',
512
+ },
513
+ });
514
+ await service.load();
515
+
516
+ const result = service.getIcons();
517
+ expect(result.favicon).toBe('/custom-favicon.svg');
518
+ expect(result.appleTouchIcon).toBeUndefined();
519
+ });
520
+ });
521
+
522
+ // =========================================================
523
+ // getLibraries()
524
+ // =========================================================
525
+ describe('getLibraries()', () => {
526
+ test('should return empty arrays when no config', async () => {
527
+ removeConfig();
528
+ await service.load();
529
+
530
+ expect(service.getLibraries()).toEqual({ js: [], css: [] });
531
+ });
532
+
533
+ test('should return empty arrays when libraries is not set', async () => {
534
+ writeConfig({});
535
+ await service.load();
536
+
537
+ expect(service.getLibraries()).toEqual({ js: [], css: [] });
538
+ });
539
+
540
+ test('should return empty arrays when libraries is not an object', async () => {
541
+ writeConfig({ libraries: 'invalid' });
542
+ await service.load();
543
+
544
+ expect(service.getLibraries()).toEqual({ js: [], css: [] });
545
+ });
546
+
547
+ test('should normalize string URLs to object format for JS libraries', async () => {
548
+ writeConfig({
549
+ libraries: {
550
+ js: [
551
+ 'https://cdn.example.com/lib.js',
552
+ 'https://cdn.example.com/other.js',
553
+ ],
554
+ },
555
+ });
556
+ await service.load();
557
+
558
+ const result = service.getLibraries();
559
+ expect(result.js).toEqual([
560
+ { url: 'https://cdn.example.com/lib.js' },
561
+ { url: 'https://cdn.example.com/other.js' },
562
+ ]);
563
+ });
564
+
565
+ test('should normalize string URLs to object format for CSS libraries', async () => {
566
+ writeConfig({
567
+ libraries: {
568
+ css: [
569
+ 'https://fonts.googleapis.com/css2?family=Inter',
570
+ ],
571
+ },
572
+ });
573
+ await service.load();
574
+
575
+ const result = service.getLibraries();
576
+ expect(result.css).toEqual([
577
+ { url: 'https://fonts.googleapis.com/css2?family=Inter' },
578
+ ]);
579
+ });
580
+
581
+ test('should pass through object format for JS libraries', async () => {
582
+ writeConfig({
583
+ libraries: {
584
+ js: [
585
+ { url: 'https://cdn.example.com/lib.js', mode: 'async', position: 'head' },
586
+ ],
587
+ },
588
+ });
589
+ await service.load();
590
+
591
+ const result = service.getLibraries();
592
+ expect(result.js).toEqual([
593
+ { url: 'https://cdn.example.com/lib.js', mode: 'async', position: 'head' },
594
+ ]);
595
+ });
596
+
597
+ test('should pass through object format for CSS libraries', async () => {
598
+ writeConfig({
599
+ libraries: {
600
+ css: [
601
+ { url: 'https://fonts.googleapis.com/style.css', media: 'screen' },
602
+ ],
603
+ },
604
+ });
605
+ await service.load();
606
+
607
+ const result = service.getLibraries();
608
+ expect(result.css).toEqual([
609
+ { url: 'https://fonts.googleapis.com/style.css', media: 'screen' },
610
+ ]);
611
+ });
612
+
613
+ test('should handle mixed string and object formats', async () => {
614
+ writeConfig({
615
+ libraries: {
616
+ js: [
617
+ 'https://cdn.example.com/simple.js',
618
+ { url: 'https://cdn.example.com/complex.js', mode: 'defer' },
619
+ ],
620
+ css: [
621
+ 'https://cdn.example.com/simple.css',
622
+ { url: 'https://cdn.example.com/complex.css', media: 'print' },
623
+ ],
624
+ },
625
+ });
626
+ await service.load();
627
+
628
+ const result = service.getLibraries();
629
+ expect(result.js).toHaveLength(2);
630
+ expect(result.js![0]).toEqual({ url: 'https://cdn.example.com/simple.js' });
631
+ expect(result.js![1]).toEqual({ url: 'https://cdn.example.com/complex.js', mode: 'defer' });
632
+ expect(result.css).toHaveLength(2);
633
+ expect(result.css![0]).toEqual({ url: 'https://cdn.example.com/simple.css' });
634
+ expect(result.css![1]).toEqual({ url: 'https://cdn.example.com/complex.css', media: 'print' });
635
+ });
636
+
637
+ test('should return empty array when js or css is not an array', async () => {
638
+ writeConfig({
639
+ libraries: {
640
+ js: 'not-an-array',
641
+ css: 42,
642
+ },
643
+ });
644
+ await service.load();
645
+
646
+ const result = service.getLibraries();
647
+ expect(result.js).toEqual([]);
648
+ expect(result.css).toEqual([]);
649
+ });
650
+
651
+ test('should handle missing js or css keys', async () => {
652
+ writeConfig({
653
+ libraries: {
654
+ js: [{ url: 'https://example.com/lib.js' }],
655
+ // css not specified
656
+ },
657
+ });
658
+ await service.load();
659
+
660
+ const result = service.getLibraries();
661
+ expect(result.js).toHaveLength(1);
662
+ expect(result.css).toEqual([]);
663
+ });
664
+ });
665
+
666
+ // =========================================================
667
+ // getCSP()
668
+ // =========================================================
669
+ describe('getCSP()', () => {
670
+ test('should return empty object when no config', async () => {
671
+ removeConfig();
672
+ await service.load();
673
+
674
+ expect(service.getCSP()).toEqual({});
675
+ });
676
+
677
+ test('should return empty object when csp is not set', async () => {
678
+ writeConfig({});
679
+ await service.load();
680
+
681
+ expect(service.getCSP()).toEqual({});
682
+ });
683
+
684
+ test('should return empty object when csp is not an object', async () => {
685
+ writeConfig({ csp: 'invalid' });
686
+ await service.load();
687
+
688
+ expect(service.getCSP()).toEqual({});
689
+ });
690
+
691
+ test('should return CSP config', async () => {
692
+ writeConfig({
693
+ csp: {
694
+ scriptSrc: ['https://cdnjs.cloudflare.com', 'https://unpkg.com'],
695
+ styleSrc: ['https://fonts.googleapis.com'],
696
+ connectSrc: ['https://api.example.com'],
697
+ },
698
+ });
699
+ await service.load();
700
+
701
+ const result = service.getCSP();
702
+ expect(result.scriptSrc).toEqual(['https://cdnjs.cloudflare.com', 'https://unpkg.com']);
703
+ expect(result.styleSrc).toEqual(['https://fonts.googleapis.com']);
704
+ expect(result.connectSrc).toEqual(['https://api.example.com']);
705
+ });
706
+
707
+ test('should return partial CSP config', async () => {
708
+ writeConfig({
709
+ csp: {
710
+ imgSrc: ['https://images.example.com'],
711
+ },
712
+ });
713
+ await service.load();
714
+
715
+ const result = service.getCSP();
716
+ expect(result.imgSrc).toEqual(['https://images.example.com']);
717
+ expect(result.scriptSrc).toBeUndefined();
718
+ });
719
+ });
720
+
721
+ // =========================================================
722
+ // getEnums()
723
+ // =========================================================
724
+ describe('getEnums()', () => {
725
+ test('should return empty object when no config', async () => {
726
+ removeConfig();
727
+ await service.load();
728
+
729
+ expect(service.getEnums()).toEqual({});
730
+ });
731
+
732
+ test('should return empty object when enums is not set', async () => {
733
+ writeConfig({});
734
+ await service.load();
735
+
736
+ expect(service.getEnums()).toEqual({});
737
+ });
738
+
739
+ test('should return empty object when enums is not an object', async () => {
740
+ writeConfig({ enums: 'invalid' });
741
+ await service.load();
742
+
743
+ expect(service.getEnums()).toEqual({});
744
+ });
745
+
746
+ test('should return valid string array enums', async () => {
747
+ writeConfig({
748
+ enums: {
749
+ colors: ['red', 'green', 'blue'],
750
+ sizes: ['sm', 'md', 'lg', 'xl'],
751
+ },
752
+ });
753
+ await service.load();
754
+
755
+ const result = service.getEnums();
756
+ expect(result.colors).toEqual(['red', 'green', 'blue']);
757
+ expect(result.sizes).toEqual(['sm', 'md', 'lg', 'xl']);
758
+ });
759
+
760
+ test('should filter out non-string-array values', async () => {
761
+ writeConfig({
762
+ enums: {
763
+ validEnum: ['a', 'b', 'c'],
764
+ numberArray: [1, 2, 3],
765
+ mixedArray: ['a', 1, 'b'],
766
+ notAnArray: 'just a string',
767
+ nullValue: null,
768
+ objectValue: { key: 'value' },
769
+ },
770
+ });
771
+ await service.load();
772
+
773
+ const result = service.getEnums();
774
+ expect(result.validEnum).toEqual(['a', 'b', 'c']);
775
+ expect(result.numberArray).toBeUndefined();
776
+ expect(result.mixedArray).toBeUndefined();
777
+ expect(result.notAnArray).toBeUndefined();
778
+ expect(result.nullValue).toBeUndefined();
779
+ expect(result.objectValue).toBeUndefined();
780
+ });
781
+
782
+ test('should return empty object when all enum values are invalid', async () => {
783
+ writeConfig({
784
+ enums: {
785
+ numbers: [1, 2, 3],
786
+ booleans: [true, false],
787
+ },
788
+ });
789
+ await service.load();
790
+
791
+ expect(service.getEnums()).toEqual({});
792
+ });
793
+
794
+ test('should handle empty string arrays', async () => {
795
+ writeConfig({
796
+ enums: {
797
+ emptyEnum: [],
798
+ },
799
+ });
800
+ await service.load();
801
+
802
+ const result = service.getEnums();
803
+ // Empty array is a valid string array (vacuously true)
804
+ expect(result.emptyEnum).toEqual([]);
805
+ });
806
+ });
807
+
808
+ // =========================================================
809
+ // getRaw()
810
+ // =========================================================
811
+ describe('getRaw()', () => {
812
+ test('should return undefined when config not loaded', () => {
813
+ // Service not loaded yet
814
+ expect(service.getRaw('anything')).toBeUndefined();
815
+ });
816
+
817
+ test('should return undefined when config file does not exist', async () => {
818
+ removeConfig();
819
+ await service.load();
820
+
821
+ expect(service.getRaw('anything')).toBeUndefined();
822
+ });
823
+
824
+ test('should return undefined for non-existent key', async () => {
825
+ writeConfig({ icons: { favicon: '/test.ico' } });
826
+ await service.load();
827
+
828
+ expect(service.getRaw('nonExistentKey')).toBeUndefined();
829
+ });
830
+
831
+ test('should return raw value by key', async () => {
832
+ writeConfig({
833
+ icons: { favicon: '/test.ico' },
834
+ customKey: 'customValue',
835
+ nestedConfig: { deep: { value: 42 } },
836
+ });
837
+ await service.load();
838
+
839
+ expect(service.getRaw<string>('customKey')).toBe('customValue');
840
+ expect(service.getRaw<{ deep: { value: number } }>('nestedConfig')).toEqual({
841
+ deep: { value: 42 },
842
+ });
843
+ });
844
+
845
+ test('should return raw breakpoints without normalization', async () => {
846
+ writeConfig({
847
+ breakpoints: {
848
+ tablet: 1024,
849
+ },
850
+ });
851
+ await service.load();
852
+
853
+ // getRaw returns the raw value, not the normalized one
854
+ const raw = service.getRaw<Record<string, number>>('breakpoints');
855
+ expect(raw).toEqual({ tablet: 1024 });
856
+ });
857
+ });
858
+
859
+ // =========================================================
860
+ // Integration / combined behavior
861
+ // =========================================================
862
+ describe('integration', () => {
863
+ test('should handle a full project.config.json', async () => {
864
+ writeConfig({
865
+ breakpoints: {
866
+ tablet: { breakpoint: 1024, previewPoint: 768 },
867
+ mobile: { breakpoint: 540, previewPoint: 375 },
868
+ },
869
+ responsiveScales: {
870
+ enabled: true,
871
+ baseReference: 16,
872
+ fontSize: { tablet: 0.9, mobile: 0.8 },
873
+ },
874
+ i18n: {
875
+ defaultLocale: 'en',
876
+ locales: [
877
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
878
+ { code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL' },
879
+ ],
880
+ },
881
+ icons: {
882
+ favicon: '/favicon.svg',
883
+ appleTouchIcon: '/apple-icon.png',
884
+ },
885
+ libraries: {
886
+ js: [{ url: 'https://cdn.example.com/app.js', mode: 'defer' }],
887
+ css: ['https://fonts.googleapis.com/css?family=Inter'],
888
+ },
889
+ csp: {
890
+ scriptSrc: ['https://cdn.example.com'],
891
+ },
892
+ enums: {
893
+ status: ['active', 'inactive', 'pending'],
894
+ },
895
+ });
896
+ await service.load();
897
+
898
+ // Verify all getters work correctly together
899
+ const breakpoints = service.getBreakpoints();
900
+ expect(breakpoints.tablet.breakpoint).toBe(1024);
901
+ expect(breakpoints.mobile.previewPoint).toBe(375);
902
+
903
+ const scales = service.getResponsiveScales();
904
+ expect(scales.enabled).toBe(true);
905
+ expect(scales.fontSize!.tablet).toBe(0.9);
906
+
907
+ const i18n = service.getI18n();
908
+ expect(i18n.locales).toHaveLength(2);
909
+
910
+ const icons = service.getIcons();
911
+ expect(icons.favicon).toBe('/favicon.svg');
912
+
913
+ const libs = service.getLibraries();
914
+ expect(libs.js).toHaveLength(1);
915
+ expect(libs.css).toHaveLength(1);
916
+ expect(libs.css![0]).toEqual({ url: 'https://fonts.googleapis.com/css?family=Inter' });
917
+
918
+ const csp = service.getCSP();
919
+ expect(csp.scriptSrc).toEqual(['https://cdn.example.com']);
920
+
921
+ const enums = service.getEnums();
922
+ expect(enums.status).toEqual(['active', 'inactive', 'pending']);
923
+ });
924
+
925
+ test('should handle empty config object', async () => {
926
+ writeConfig({});
927
+ await service.load();
928
+
929
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
930
+ expect(service.getI18n()).toEqual({ ...DEFAULT_I18N_CONFIG });
931
+ expect(service.getResponsiveScales()).toEqual({ ...DEFAULT_RESPONSIVE_SCALES });
932
+ expect(service.getIcons()).toEqual({});
933
+ expect(service.getLibraries()).toEqual({ js: [], css: [] });
934
+ expect(service.getCSP()).toEqual({});
935
+ expect(service.getEnums()).toEqual({});
936
+ });
937
+
938
+ test('getters should work before load (returning defaults)', () => {
939
+ // Without calling load(), config is null
940
+ expect(service.getBreakpoints()).toEqual({ ...DEFAULT_BREAKPOINTS });
941
+ expect(service.getI18n()).toEqual({ ...DEFAULT_I18N_CONFIG });
942
+ expect(service.getResponsiveScales()).toEqual({ ...DEFAULT_RESPONSIVE_SCALES });
943
+ expect(service.getIcons()).toEqual({});
944
+ expect(service.getLibraries()).toEqual({ js: [], css: [] });
945
+ expect(service.getCSP()).toEqual({});
946
+ expect(service.getEnums()).toEqual({});
947
+ expect(service.getRaw('anything')).toBeUndefined();
948
+ });
949
+ });
950
+ });