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
@@ -167,6 +167,71 @@ export function resolveStyleMapping(
167
167
  return mappedValue !== undefined ? mappedValue : undefined;
168
168
  }
169
169
 
170
+ /**
171
+ * Process a single style value - resolves mappings and evaluates templates.
172
+ * Skips item templates ({{item.field}}, {{varName.field}}) which are resolved later during rendering.
173
+ *
174
+ * @param styleValue - The style value to process (may be string, number, or mapping object)
175
+ * @param props - Component props for resolving mappings and templates
176
+ * @returns The processed style value, or undefined if the value should be skipped
177
+ */
178
+ function processStyleValue(
179
+ styleValue: unknown,
180
+ props: Record<string, unknown> | undefined
181
+ ): string | number | undefined {
182
+ // First try style mapping resolution
183
+ const resolved = resolveStyleMapping(styleValue, props);
184
+ if (resolved !== undefined) {
185
+ return resolved;
186
+ }
187
+
188
+ // String values - evaluate templates (but skip item templates)
189
+ if (typeof styleValue === 'string') {
190
+ if (hasTemplates(styleValue) && !hasItemTemplates(styleValue)) {
191
+ return processCodeTemplates(styleValue, props);
192
+ }
193
+ return styleValue;
194
+ }
195
+
196
+ // Number values - pass through
197
+ if (typeof styleValue === 'number') {
198
+ return styleValue;
199
+ }
200
+
201
+ return undefined;
202
+ }
203
+
204
+ /**
205
+ * Build evaluation context for template processing.
206
+ * Merges global context, props, componentDef, and optionally itemContext.
207
+ *
208
+ * @param context - The TemplateContext containing props and componentDef
209
+ * @param includeItemContext - Whether to include itemContext from the context object
210
+ * @returns A flat object suitable for template evaluation
211
+ */
212
+ function buildEvalContext(
213
+ context: TemplateContext,
214
+ includeItemContext: boolean = false
215
+ ): Record<string, unknown> {
216
+ const evalContext: Record<string, unknown> = {
217
+ ...getGlobalTemplateContext(),
218
+ ...context.props
219
+ };
220
+
221
+ if (context.componentDef && typeof context.componentDef === 'object') {
222
+ Object.assign(evalContext, context.componentDef as Record<string, unknown>);
223
+ }
224
+
225
+ if (includeItemContext) {
226
+ const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
227
+ if (itemContext) {
228
+ Object.assign(evalContext, itemContext);
229
+ }
230
+ }
231
+
232
+ return evalContext;
233
+ }
234
+
170
235
  /** Maximum allowed expression length to prevent DoS via complex expressions */
171
236
  const MAX_EXPRESSION_LENGTH = 500;
172
237
 
@@ -315,22 +380,8 @@ export function processStructure(
315
380
  }
316
381
 
317
382
  if (typeof structure === 'string') {
318
- // Build evaluation context from TemplateContext
319
- // Start with global context, then merge props (props take precedence)
320
- const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
321
-
322
- // Add componentDef properties to context (for backward compatibility)
323
- if (context.componentDef && typeof context.componentDef === 'object') {
324
- Object.assign(evalContext, context.componentDef as Record<string, unknown>);
325
- }
326
-
327
- // Add parent cms-list item context for nested template resolution
328
- // This enables {{item.field}} in components rendered inside cms-list
329
- const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
330
- if (itemContext) {
331
- Object.assign(evalContext, itemContext);
332
- }
333
-
383
+ // Build evaluation context with item context for nested template resolution
384
+ const evalContext = buildEvalContext(context, true);
334
385
 
335
386
  // Check if entire string is a complete template {{expr}}
336
387
  // Use evaluateTemplate to preserve type (objects, arrays, numbers)
@@ -504,12 +555,12 @@ export function processStructure(
504
555
  if (isHtmlNode(processed) || isListNode(processed)) {
505
556
  if (typeof value === 'string') {
506
557
  // Process template in tag (e.g., "h{{size}}" -> "h1")
507
- const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
508
- if (context.componentDef && typeof context.componentDef === 'object') {
509
- Object.assign(evalContext, context.componentDef as Record<string, unknown>);
510
- }
558
+ const evalContext = buildEvalContext(context, false);
511
559
  // Use processCodeTemplates to handle partial templates like "h{{size}}"
512
560
  (processed as any).tag = processCodeTemplates(value, evalContext);
561
+ } else if (value === false) {
562
+ // Preserve boolean false for fragment mode (no container)
563
+ (processed as any).tag = false;
513
564
  } else {
514
565
  (processed as any).tag = String(value);
515
566
  }
@@ -526,16 +577,8 @@ export function processStructure(
526
577
  // Handle html property for embed nodes - process templates like {{propName}}
527
578
  if (preservedType === NODE_TYPE.EMBED) {
528
579
  if (typeof value === 'string') {
529
- // Build evaluation context from props (same as tag processing)
530
- const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
531
- if (context.componentDef && typeof context.componentDef === 'object') {
532
- Object.assign(evalContext, context.componentDef as Record<string, unknown>);
533
- }
534
- // Add parent cms-list item context for nested template resolution
535
- const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
536
- if (itemContext) {
537
- Object.assign(evalContext, itemContext);
538
- }
580
+ // Build evaluation context with item context for nested template resolution
581
+ const evalContext = buildEvalContext(context, true);
539
582
  // Check if entire string is a complete template {{expr}}
540
583
  if (/^\{\{.+\}\}$/.test(value) && !hasItemTemplates(value)) {
541
584
  const result = evaluateTemplate(value, evalContext);
@@ -583,78 +626,29 @@ export function processStructure(
583
626
  if (processedStyle && typeof processedStyle === 'object' && !Array.isArray(processedStyle)) {
584
627
  // Check if it's a responsive style object
585
628
  if (isResponsiveStyle(processedStyle as StyleValue)) {
586
- if (preserveResponsiveStyles) {
587
- // Preserve responsive styles as-is (for SSR)
588
- // Just resolve templates and mappings within each breakpoint
589
- const resolvedResponsive: ResponsiveStyleObject = {};
590
- for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
591
- if (typeof bkeyValue === 'object' && bkeyValue !== null) {
592
- resolvedResponsive[bkeyName] = {};
593
- for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
594
- const resolved = resolveStyleMapping(styleValue, context.props);
595
- if (resolved !== undefined) {
596
- resolvedResponsive[bkeyName]![styleKey] = resolved;
597
- } else if (typeof styleValue === 'string') {
598
- if (hasTemplates(styleValue)) {
599
- const evaluated = processCodeTemplates(styleValue, context.props);
600
- resolvedResponsive[bkeyName]![styleKey] = evaluated;
601
- } else {
602
- resolvedResponsive[bkeyName]![styleKey] = styleValue;
603
- }
604
- } else if (typeof styleValue === 'number') {
605
- resolvedResponsive[bkeyName]![styleKey] = styleValue;
606
- }
607
- }
608
- }
609
- }
610
- resolvedStyle = resolvedResponsive;
611
- } else {
612
- // Preserve responsive styles for editor (same as SSR)
613
- // responsiveStylesToClasses will generate prefixed classes (t-, mob-)
614
- // CSS media queries will handle displaying the correct styles based on viewport
615
- const resolvedResponsive: ResponsiveStyleObject = {};
616
- for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
617
- if (typeof bkeyValue === 'object' && bkeyValue !== null) {
618
- resolvedResponsive[bkeyName] = {};
619
- for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
620
- const resolved = resolveStyleMapping(styleValue, context.props);
621
- if (resolved !== undefined) {
622
- resolvedResponsive[bkeyName]![styleKey] = resolved;
623
- } else if (typeof styleValue === 'string') {
624
- if (hasTemplates(styleValue)) {
625
- const evaluated = processCodeTemplates(styleValue, context.props);
626
- resolvedResponsive[bkeyName]![styleKey] = evaluated;
627
- } else {
628
- resolvedResponsive[bkeyName]![styleKey] = styleValue;
629
- }
630
- } else if (typeof styleValue === 'number') {
631
- resolvedResponsive[bkeyName]![styleKey] = styleValue;
632
- }
629
+ // Preserve responsive styles (for both SSR and editor)
630
+ // responsiveStylesToClasses will generate prefixed classes (t-, mob-)
631
+ // CSS media queries will handle displaying the correct styles based on viewport
632
+ const resolvedResponsive: ResponsiveStyleObject = {};
633
+ for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
634
+ if (typeof bkeyValue === 'object' && bkeyValue !== null) {
635
+ resolvedResponsive[bkeyName] = {};
636
+ for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
637
+ const processedValue = processStyleValue(styleValue, context.props);
638
+ if (processedValue !== undefined) {
639
+ resolvedResponsive[bkeyName]![styleKey] = processedValue;
633
640
  }
634
641
  }
635
642
  }
636
- resolvedStyle = resolvedResponsive;
637
643
  }
644
+ resolvedStyle = resolvedResponsive;
638
645
  } else {
639
646
  // Legacy flat style object - resolve mappings and evaluate templates
640
647
  resolvedStyle = {};
641
648
  for (const [styleKey, styleValue] of Object.entries(processedStyle)) {
642
- // First resolve style mappings
643
- const resolved = resolveStyleMapping(styleValue, context.props);
644
- if (resolved !== undefined) {
645
- resolvedStyle[styleKey] = resolved;
646
- } else if (typeof styleValue === 'string') {
647
- // Evaluate template strings in style values (supports partial templates like "{{size}}px")
648
- // Use processCodeTemplates to handle templates with text before/after
649
- if (hasTemplates(styleValue)) {
650
- const evaluated = processCodeTemplates(styleValue, context.props);
651
- resolvedStyle[styleKey] = evaluated;
652
- } else {
653
- // No templates, keep original value
654
- resolvedStyle[styleKey] = styleValue;
655
- }
656
- } else if (typeof styleValue === 'number') {
657
- resolvedStyle[styleKey] = styleValue;
649
+ const processedValue = processStyleValue(styleValue, context.props);
650
+ if (processedValue !== undefined) {
651
+ resolvedStyle[styleKey] = processedValue;
658
652
  }
659
653
  }
660
654
  }
@@ -712,10 +706,7 @@ export function processStructure(
712
706
  // Special handling for attributes - process templates but don't treat as node structure
713
707
  // This preserves type="checkbox" etc. which would otherwise be caught by node type handling
714
708
  const processedAttributes: Record<string, unknown> = {};
715
- const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
716
- if (context.componentDef && typeof context.componentDef === 'object') {
717
- Object.assign(evalContext, context.componentDef as Record<string, unknown>);
718
- }
709
+ const evalContext = buildEvalContext(context, false);
719
710
  for (const [attrKey, attrValue] of Object.entries(value)) {
720
711
  if (typeof attrValue === 'string' && hasTemplates(attrValue)) {
721
712
  // Check if entire string is a complete template {{expr}} - preserve type (boolean, number)
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Integration Tests: API Routes
3
+ * Tests core API endpoints through real HTTP requests
4
+ */
5
+
6
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
7
+ import { createTestServer, fetch, TEST_PAGE_JSON, TEST_COMPONENT_DEF } from './test-helpers';
8
+
9
+ let ctx: Awaited<ReturnType<typeof createTestServer>>;
10
+
11
+ beforeAll(async () => {
12
+ const components = new Map();
13
+ components.set('TestButton', TEST_COMPONENT_DEF);
14
+
15
+ ctx = await createTestServer({
16
+ pages: {
17
+ '/': TEST_PAGE_JSON,
18
+ '/about': JSON.stringify({
19
+ meta: { title: 'About', description: 'About page' },
20
+ root: { type: 'node', tag: 'div', children: 'About content' },
21
+ }, null, 2),
22
+ },
23
+ components,
24
+ });
25
+ });
26
+
27
+ afterAll(() => {
28
+ ctx?.cleanup();
29
+ });
30
+
31
+ describe('API Routes', () => {
32
+ // ========== Pages ==========
33
+
34
+ test('GET /api/pages returns page list', async () => {
35
+ const res = await fetch(`${ctx.baseUrl}/api/pages`);
36
+ expect(res.status).toBe(200);
37
+
38
+ const data = await res.json();
39
+ expect(data.pages).toBeArray();
40
+ expect(data.pages.length).toBe(2);
41
+
42
+ const paths = data.pages.map((p: any) => p.path);
43
+ expect(paths).toContain('/');
44
+ expect(paths).toContain('/about');
45
+ });
46
+
47
+ test('GET /api/page-data/:path returns page JSON with line map', async () => {
48
+ const res = await fetch(`${ctx.baseUrl}/api/page-data/`);
49
+ expect(res.status).toBe(200);
50
+
51
+ const data = await res.json();
52
+ expect(data.meta).toBeDefined();
53
+ expect(data.meta.title).toBe('Test Page');
54
+ expect(data.root).toBeDefined();
55
+ expect(data._lineMap).toBeDefined();
56
+ });
57
+
58
+ test('GET /api/page-data/nonexistent returns 404', async () => {
59
+ const res = await fetch(`${ctx.baseUrl}/api/page-data/nonexistent`);
60
+ expect(res.status).toBe(404);
61
+
62
+ const data = await res.json();
63
+ expect(data.error).toBeDefined();
64
+ });
65
+
66
+ // ========== Components ==========
67
+
68
+ test('GET /api/components returns component list', async () => {
69
+ const res = await fetch(`${ctx.baseUrl}/api/components`);
70
+ expect(res.status).toBe(200);
71
+
72
+ const data = await res.json();
73
+ expect(typeof data).toBe('object');
74
+ // Components loaded via mock loader - verify response structure
75
+ expect(data).toBeDefined();
76
+ });
77
+
78
+ test('GET /api/component-data/:name returns 404 for missing component', async () => {
79
+ const res = await fetch(`${ctx.baseUrl}/api/component-data/NonExistentComponent`);
80
+ expect(res.status).toBe(404);
81
+
82
+ const data = await res.json();
83
+ expect(data.error).toBeDefined();
84
+ });
85
+
86
+ // ========== Config ==========
87
+
88
+ test('GET /api/config returns project config', async () => {
89
+ const res = await fetch(`${ctx.baseUrl}/api/config`);
90
+ expect(res.status).toBe(200);
91
+
92
+ const data = await res.json();
93
+ // Returns default config when no project.config.json exists
94
+ expect(data).toBeDefined();
95
+ });
96
+
97
+ // ========== Colors ==========
98
+
99
+ test('GET /api/colors returns color variables', async () => {
100
+ const res = await fetch(`${ctx.baseUrl}/api/colors`);
101
+ expect(res.status).toBe(200);
102
+
103
+ const data = await res.json();
104
+ expect(data).toBeDefined();
105
+ });
106
+
107
+ // ========== Usage ==========
108
+
109
+ test('GET /api/usage returns usage stats', async () => {
110
+ const res = await fetch(`${ctx.baseUrl}/api/usage`);
111
+ expect(res.status).toBe(200);
112
+
113
+ const data = await res.json();
114
+ expect(typeof data.pages).toBe('number');
115
+ expect(typeof data.locales).toBe('number');
116
+ expect(typeof data.cmsCollections).toBe('number');
117
+ });
118
+
119
+ // ========== Slug Mappings ==========
120
+
121
+ test('GET /api/slug-mappings returns slug translations', async () => {
122
+ const res = await fetch(`${ctx.baseUrl}/api/slug-mappings`);
123
+ expect(res.status).toBe(200);
124
+
125
+ const data = await res.json();
126
+ expect(data.mappings).toBeArray();
127
+ });
128
+
129
+ // ========== Response Headers ==========
130
+
131
+ test('API responses have JSON content-type', async () => {
132
+ const res = await fetch(`${ctx.baseUrl}/api/pages`);
133
+ const contentType = res.headers.get('Content-Type');
134
+ expect(contentType).toContain('application/json');
135
+ });
136
+
137
+ test('API responses have cache-control: no-store', async () => {
138
+ const res = await fetch(`${ctx.baseUrl}/api/pages`);
139
+ const cacheControl = res.headers.get('Cache-Control');
140
+ expect(cacheControl).toContain('no-store');
141
+ });
142
+
143
+ test('API responses include CORS headers', async () => {
144
+ const res = await fetch(`${ctx.baseUrl}/api/pages`);
145
+ const origin = res.headers.get('Access-Control-Allow-Origin');
146
+ expect(origin).toBeTruthy();
147
+ });
148
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Integration Tests: CMS Integration
3
+ * Tests CMS CRUD flow and route matching through HTTP
4
+ */
5
+
6
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
7
+ import { createTestServer, fetch, createTempProject, TEST_CMS_TEMPLATE } from './test-helpers';
8
+ import { FileSystemCMSProvider } from '../providers/fileSystemCMSProvider';
9
+ import { CMSService } from '../services/cmsService';
10
+ import { setProjectRoot } from '../projectContext';
11
+
12
+ let ctx: Awaited<ReturnType<typeof createTestServer>>;
13
+ let tempProject: ReturnType<typeof createTempProject>;
14
+ let originalProjectRoot: string;
15
+
16
+ beforeAll(async () => {
17
+ // Save original project root to restore later
18
+ const { getProjectRoot } = await import('../projectContext');
19
+ originalProjectRoot = getProjectRoot();
20
+
21
+ // Create temp project with CMS template and items
22
+ tempProject = createTempProject({
23
+ 'pages/templates/posts.json': TEST_CMS_TEMPLATE,
24
+ 'cms/posts/hello-world.json': JSON.stringify({
25
+ _id: 'hello-world',
26
+ title: 'Hello World',
27
+ slug: 'hello-world',
28
+ body: 'This is a blog post about hello world.',
29
+ }),
30
+ 'cms/posts/second-post.json': JSON.stringify({
31
+ _id: 'second-post',
32
+ title: 'Second Post',
33
+ slug: 'second-post',
34
+ body: 'This is the second blog post.',
35
+ }),
36
+ 'project.config.json': JSON.stringify({ siteUrl: 'https://example.com' }),
37
+ });
38
+
39
+ // Point project context to temp directory
40
+ setProjectRoot(tempProject.projectDir);
41
+
42
+ // Create CMS provider and service with real file system
43
+ const cmsProvider = new FileSystemCMSProvider(
44
+ `${tempProject.projectDir}/pages`,
45
+ `${tempProject.projectDir}/cms`
46
+ );
47
+ const cmsService = new CMSService(cmsProvider);
48
+ await cmsService.initialize();
49
+
50
+ ctx = await createTestServer({
51
+ pages: {},
52
+ cmsService,
53
+ cmsProvider,
54
+ });
55
+ });
56
+
57
+ afterAll(() => {
58
+ ctx?.cleanup();
59
+ tempProject?.cleanup();
60
+ // Restore original project root
61
+ setProjectRoot(originalProjectRoot);
62
+ });
63
+
64
+ describe('CMS Integration', () => {
65
+ // ========== Collections API ==========
66
+
67
+ test('GET /api/cms/collections returns collection list', async () => {
68
+ const res = await fetch(`${ctx.baseUrl}/api/cms/collections`);
69
+ expect(res.status).toBe(200);
70
+
71
+ const data = await res.json();
72
+ expect(data.collections).toBeArray();
73
+ expect(data.collections.length).toBeGreaterThanOrEqual(1);
74
+
75
+ const postCollection = data.collections.find((c: any) => c.collection === 'posts');
76
+ expect(postCollection).toBeDefined();
77
+ expect(postCollection.urlPattern).toBe('/blog/{{slug}}');
78
+ });
79
+
80
+ test('GET /api/cms/collections/:id returns schema for specific collection', async () => {
81
+ const res = await fetch(`${ctx.baseUrl}/api/cms/collections/posts`);
82
+ expect(res.status).toBe(200);
83
+
84
+ const data = await res.json();
85
+ expect(data.schema).toBeDefined();
86
+ expect(data.schema.fields).toBeDefined();
87
+ expect(data.schema.fields.title).toBeDefined();
88
+ expect(data.schema.fields.slug).toBeDefined();
89
+ });
90
+
91
+ // ========== Items API ==========
92
+
93
+ test('GET /api/cms/:collection returns item list', async () => {
94
+ const res = await fetch(`${ctx.baseUrl}/api/cms/posts`);
95
+ expect(res.status).toBe(200);
96
+
97
+ const data = await res.json();
98
+ expect(data.items).toBeArray();
99
+ expect(data.items.length).toBe(2);
100
+ expect(data.total).toBe(2);
101
+
102
+ const titles = data.items.map((i: any) => i.title);
103
+ expect(titles).toContain('Hello World');
104
+ expect(titles).toContain('Second Post');
105
+ });
106
+
107
+ test('GET /api/cms/:collection?search=term returns filtered items', async () => {
108
+ const res = await fetch(`${ctx.baseUrl}/api/cms/posts?search=hello`);
109
+ expect(res.status).toBe(200);
110
+
111
+ const data = await res.json();
112
+ expect(data.items.length).toBe(1);
113
+ expect(data.items[0].title).toBe('Hello World');
114
+ });
115
+
116
+ test('GET /api/cms/:collection/:slug returns single item', async () => {
117
+ const res = await fetch(`${ctx.baseUrl}/api/cms/posts/hello-world`);
118
+ expect(res.status).toBe(200);
119
+
120
+ const data = await res.json();
121
+ expect(data.item).toBeDefined();
122
+ expect(data.item.title).toBe('Hello World');
123
+ expect(data.item.slug).toBe('hello-world');
124
+ });
125
+
126
+ // ========== Client Data Endpoint ==========
127
+
128
+ test('GET /data/:collection/index.json returns items as JSON', async () => {
129
+ const res = await fetch(`${ctx.baseUrl}/data/posts/index.json`);
130
+ expect(res.status).toBe(200);
131
+
132
+ const contentType = res.headers.get('Content-Type');
133
+ expect(contentType).toContain('application/json');
134
+
135
+ const data = await res.json();
136
+ expect(data).toBeArray();
137
+ expect(data.length).toBe(2);
138
+ });
139
+
140
+ // ========== CMS Route Matching ==========
141
+
142
+ test('GET /blog/:slug renders CMS template with item data', async () => {
143
+ const res = await fetch(`${ctx.baseUrl}/blog/hello-world`);
144
+ expect(res.status).toBe(200);
145
+
146
+ const html = await res.text();
147
+ expect(html).toContain('<!DOCTYPE html>');
148
+ // The template interpolates {{cms.title}} and {{cms.body}}
149
+ expect(html).toContain('Hello World');
150
+ expect(html).toContain('hello world');
151
+ });
152
+
153
+ test('CMS page template interpolates {{cms.field}} values', async () => {
154
+ const res = await fetch(`${ctx.baseUrl}/blog/second-post`);
155
+ expect(res.status).toBe(200);
156
+
157
+ const html = await res.text();
158
+ expect(html).toContain('Second Post');
159
+ expect(html).toContain('second blog post');
160
+ });
161
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Integration Tests: Server Lifecycle
3
+ * Tests server start, stop, port handling, and CORS
4
+ */
5
+
6
+ import { describe, test, expect, afterAll } from 'bun:test';
7
+ import { createTestServer, fetch, TEST_PAGE_JSON } from './test-helpers';
8
+
9
+ // Track servers for cleanup
10
+ const servers: Array<{ cleanup: () => void }> = [];
11
+ afterAll(() => {
12
+ servers.forEach(s => s.cleanup());
13
+ });
14
+
15
+ describe('Server Lifecycle', () => {
16
+ test('starts on specified port and responds to requests', async () => {
17
+ const ctx = await createTestServer({
18
+ pages: { '/': TEST_PAGE_JSON },
19
+ });
20
+ servers.push(ctx);
21
+
22
+ const res = await fetch(`${ctx.baseUrl}/api/pages`);
23
+ expect(res.status).toBe(200);
24
+ expect(res.ok).toBe(true);
25
+ });
26
+
27
+ test('returns 404 for unknown file requests', async () => {
28
+ const ctx = await createTestServer({
29
+ pages: { '/': TEST_PAGE_JSON },
30
+ });
31
+ servers.push(ctx);
32
+
33
+ // Unknown file paths (with extension) return 404
34
+ const res = await fetch(`${ctx.baseUrl}/nonexistent.json`);
35
+ expect(res.status).toBe(404);
36
+ });
37
+
38
+ test('handles CORS preflight (OPTIONS) requests', async () => {
39
+ const ctx = await createTestServer();
40
+ servers.push(ctx);
41
+
42
+ const res = await fetch(`${ctx.baseUrl}/api/pages`, {
43
+ method: 'OPTIONS',
44
+ });
45
+ expect(res.status).toBe(204);
46
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBeTruthy();
47
+ expect(res.headers.get('Access-Control-Allow-Methods')).toBeTruthy();
48
+ });
49
+
50
+ test('gracefully shuts down with server.stop()', async () => {
51
+ const ctx = await createTestServer({
52
+ pages: { '/': TEST_PAGE_JSON },
53
+ });
54
+
55
+ // Verify it's running
56
+ const res = await fetch(`${ctx.baseUrl}/api/pages`);
57
+ expect(res.status).toBe(200);
58
+
59
+ // Stop it
60
+ ctx.cleanup();
61
+
62
+ // After stop, fetch should fail
63
+ try {
64
+ await fetch(`${ctx.baseUrl}/api/pages`);
65
+ // If we reach here, the server is still up (shouldn't happen)
66
+ expect(false).toBe(true);
67
+ } catch (error) {
68
+ // Expected - connection refused
69
+ expect(error).toBeTruthy();
70
+ }
71
+ });
72
+
73
+ test('multiple servers can run in parallel on different ports', async () => {
74
+ const ctx1 = await createTestServer({
75
+ pages: { '/': TEST_PAGE_JSON },
76
+ });
77
+ servers.push(ctx1);
78
+
79
+ const ctx2 = await createTestServer({
80
+ pages: { '/about': TEST_PAGE_JSON },
81
+ });
82
+ servers.push(ctx2);
83
+
84
+ expect(ctx1.port).not.toBe(ctx2.port);
85
+
86
+ const [res1, res2] = await Promise.all([
87
+ fetch(`${ctx1.baseUrl}/api/pages`),
88
+ fetch(`${ctx2.baseUrl}/api/pages`),
89
+ ]);
90
+
91
+ expect(res1.status).toBe(200);
92
+ expect(res2.status).toBe(200);
93
+
94
+ const data1 = await res1.json();
95
+ const data2 = await res2.json();
96
+
97
+ // First server has '/' page, second has '/about'
98
+ expect(data1.pages.some((p: any) => p.path === '/')).toBe(true);
99
+ expect(data2.pages.some((p: any) => p.path === '/about')).toBe(true);
100
+ });
101
+ });