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,424 @@
1
+ /**
2
+ * Tests for build-static.ts - Static Site Generation Build Script
3
+ *
4
+ * Tests pure/exported functions directly. Integration tests for buildStaticPages()
5
+ * require extensive mocking of the project context and are out of scope here.
6
+ */
7
+
8
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
9
+ import { existsSync, mkdirSync, rmSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { tmpdir } from 'os';
12
+ import {
13
+ hashContent,
14
+ formatBunLog,
15
+ getLocalizedOutputPath,
16
+ getDisplayPath,
17
+ isCMSPage,
18
+ buildCMSItemPath,
19
+ injectTrackingScript,
20
+ generateRobotsTxt,
21
+ generateSitemap,
22
+ } from './build-static';
23
+ import type { JSONPage, CMSItem, I18nConfig } from './lib/shared/types';
24
+
25
+ // ============================================================================
26
+ // Test Helpers
27
+ // ============================================================================
28
+
29
+ const TEST_DIR = join(tmpdir(), `build-static-test-${Date.now()}`);
30
+
31
+ function createTestDir(): string {
32
+ const dir = join(TEST_DIR, `sub-${Math.random().toString(36).slice(2, 8)}`);
33
+ mkdirSync(dir, { recursive: true });
34
+ return dir;
35
+ }
36
+
37
+ // ============================================================================
38
+ // hashContent
39
+ // ============================================================================
40
+
41
+ describe('hashContent', () => {
42
+ test('returns an 8-character hex string', () => {
43
+ const result = hashContent('hello world');
44
+ expect(result).toMatch(/^[0-9a-f]{8}$/);
45
+ });
46
+
47
+ test('is deterministic', () => {
48
+ expect(hashContent('test')).toBe(hashContent('test'));
49
+ });
50
+
51
+ test('different inputs produce different hashes', () => {
52
+ expect(hashContent('input-a')).not.toBe(hashContent('input-b'));
53
+ });
54
+
55
+ test('handles empty string', () => {
56
+ const result = hashContent('');
57
+ expect(result).toMatch(/^[0-9a-f]{8}$/);
58
+ });
59
+
60
+ test('handles unicode content', () => {
61
+ const result = hashContent('ใ“ใ‚“ใซใกใฏไธ–็•Œ');
62
+ expect(result).toMatch(/^[0-9a-f]{8}$/);
63
+ });
64
+ });
65
+
66
+ // ============================================================================
67
+ // formatBunLog
68
+ // ============================================================================
69
+
70
+ describe('formatBunLog', () => {
71
+ test('formats log with position info', () => {
72
+ const log = {
73
+ position: { file: 'src/app.ts', line: 42, column: 10, lineText: 'const x = y;' },
74
+ message: 'Variable not found'
75
+ };
76
+ const result = formatBunLog(log);
77
+ expect(result).toContain('File: src/app.ts');
78
+ expect(result).toContain('Line 42:10');
79
+ expect(result).toContain('const x = y;');
80
+ expect(result).toContain('Variable not found');
81
+ });
82
+
83
+ test('formats log with message only', () => {
84
+ const log = { message: 'Something went wrong' };
85
+ const result = formatBunLog(log);
86
+ expect(result).toBe('Something went wrong');
87
+ });
88
+
89
+ test('formats log with text property', () => {
90
+ const log = { text: 'Warning: deprecated function' };
91
+ const result = formatBunLog(log);
92
+ expect(result).toBe('Warning: deprecated function');
93
+ });
94
+
95
+ test('falls back to JSON.stringify when no extractable fields', () => {
96
+ const log = { custom: 'data', nested: { value: 1 } };
97
+ const result = formatBunLog(log);
98
+ expect(result).toContain('"custom"');
99
+ expect(result).toContain('"data"');
100
+ });
101
+
102
+ test('falls back to String() for non-serializable', () => {
103
+ const circular: any = {};
104
+ circular.self = circular;
105
+ // Should not throw
106
+ const result = formatBunLog(circular);
107
+ expect(typeof result).toBe('string');
108
+ });
109
+
110
+ test('handles position without lineText', () => {
111
+ const log = {
112
+ position: { file: 'index.ts', line: 1 },
113
+ message: 'Error'
114
+ };
115
+ const result = formatBunLog(log);
116
+ expect(result).toContain('File: index.ts');
117
+ expect(result).toContain('Line 1:0');
118
+ expect(result).toContain('Error');
119
+ });
120
+ });
121
+
122
+ // ============================================================================
123
+ // getLocalizedOutputPath
124
+ // ============================================================================
125
+
126
+ describe('getLocalizedOutputPath', () => {
127
+ const dist = '/project/dist';
128
+
129
+ test('root path with default locale', () => {
130
+ expect(getLocalizedOutputPath('/', 'en', 'en', dist)).toBe('/project/dist/index.html');
131
+ });
132
+
133
+ test('nested path with default locale', () => {
134
+ expect(getLocalizedOutputPath('/about', 'en', 'en', dist)).toBe('/project/dist/about.html');
135
+ });
136
+
137
+ test('root path with non-default locale', () => {
138
+ expect(getLocalizedOutputPath('/', 'fr', 'en', dist)).toBe('/project/dist/fr/index.html');
139
+ });
140
+
141
+ test('nested path with non-default locale', () => {
142
+ expect(getLocalizedOutputPath('/about', 'fr', 'en', dist)).toBe('/project/dist/fr/about.html');
143
+ });
144
+
145
+ test('uses translated slug when available', () => {
146
+ const slugs = { en: 'about', fr: 'a-propos' };
147
+ expect(getLocalizedOutputPath('/about', 'fr', 'en', dist, slugs)).toBe('/project/dist/fr/a-propos.html');
148
+ });
149
+
150
+ test('translated slug for default locale', () => {
151
+ const slugs = { en: 'about-us', fr: 'a-propos' };
152
+ expect(getLocalizedOutputPath('/about', 'en', 'en', dist, slugs)).toBe('/project/dist/about-us.html');
153
+ });
154
+
155
+ test('falls back to base path when locale has no slug', () => {
156
+ const slugs = { en: 'about' };
157
+ expect(getLocalizedOutputPath('/about', 'de', 'en', dist, slugs)).toBe('/project/dist/de/about.html');
158
+ });
159
+
160
+ test('deep nested path with default locale', () => {
161
+ expect(getLocalizedOutputPath('/blog/post', 'en', 'en', dist)).toBe('/project/dist/blog/post.html');
162
+ });
163
+ });
164
+
165
+ // ============================================================================
166
+ // getDisplayPath
167
+ // ============================================================================
168
+
169
+ describe('getDisplayPath', () => {
170
+ test('root path with default locale returns /', () => {
171
+ expect(getDisplayPath('/', 'en', 'en')).toBe('/');
172
+ });
173
+
174
+ test('nested path with default locale', () => {
175
+ expect(getDisplayPath('/about', 'en', 'en')).toBe('/about');
176
+ });
177
+
178
+ test('root path with non-default locale', () => {
179
+ expect(getDisplayPath('/', 'fr', 'en')).toBe('/fr');
180
+ });
181
+
182
+ test('nested path with non-default locale', () => {
183
+ expect(getDisplayPath('/about', 'fr', 'en')).toBe('/fr/about');
184
+ });
185
+
186
+ test('uses translated slug', () => {
187
+ const slugs = { en: 'about', fr: 'a-propos' };
188
+ expect(getDisplayPath('/about', 'fr', 'en', slugs)).toBe('/fr/a-propos');
189
+ });
190
+
191
+ test('translated slug for default locale', () => {
192
+ const slugs = { en: 'about-us', fr: 'a-propos' };
193
+ expect(getDisplayPath('/about', 'en', 'en', slugs)).toBe('/about-us');
194
+ });
195
+
196
+ test('falls back to base path when slug missing for locale', () => {
197
+ const slugs = { en: 'about' };
198
+ expect(getDisplayPath('/about', 'de', 'en', slugs)).toBe('/de/about');
199
+ });
200
+ });
201
+
202
+ // ============================================================================
203
+ // isCMSPage
204
+ // ============================================================================
205
+
206
+ describe('isCMSPage', () => {
207
+ test('returns true when source is cms and cms config exists', () => {
208
+ const page = {
209
+ meta: {
210
+ source: 'cms',
211
+ cms: { id: 'blog', urlPattern: '/blog/{{slug}}', slugField: 'slug' }
212
+ },
213
+ nodes: []
214
+ } as unknown as JSONPage;
215
+ expect(isCMSPage(page)).toBe(true);
216
+ });
217
+
218
+ test('returns false when source is not cms', () => {
219
+ const page = {
220
+ meta: { title: 'About' },
221
+ nodes: []
222
+ } as unknown as JSONPage;
223
+ expect(isCMSPage(page)).toBe(false);
224
+ });
225
+
226
+ test('returns false when source is cms but cms config is missing', () => {
227
+ const page = {
228
+ meta: { source: 'cms' },
229
+ nodes: []
230
+ } as unknown as JSONPage;
231
+ expect(isCMSPage(page)).toBe(false);
232
+ });
233
+
234
+ test('returns false when meta is missing', () => {
235
+ const page = { nodes: [] } as unknown as JSONPage;
236
+ expect(isCMSPage(page)).toBe(false);
237
+ });
238
+
239
+ test('returns false when meta is undefined', () => {
240
+ const page = { meta: undefined, nodes: [] } as unknown as JSONPage;
241
+ expect(isCMSPage(page)).toBe(false);
242
+ });
243
+ });
244
+
245
+ // ============================================================================
246
+ // buildCMSItemPath
247
+ // ============================================================================
248
+
249
+ describe('buildCMSItemPath', () => {
250
+ const defaultI18nConfig: I18nConfig = {
251
+ defaultLocale: 'en',
252
+ locales: [{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' }]
253
+ };
254
+
255
+ test('replaces {{slug}} with item slug field', () => {
256
+ const item: CMSItem = { _id: '1', _slug: 'my-post', title: 'My Post' };
257
+ const result = buildCMSItemPath('/blog/{{slug}}', item, '_slug', 'en', defaultI18nConfig);
258
+ expect(result).toBe('/blog/my-post');
259
+ });
260
+
261
+ test('uses specified slugField', () => {
262
+ const item: CMSItem = { _id: '1', _slug: 'fallback', customSlug: 'custom-value', title: 'Test' };
263
+ const result = buildCMSItemPath('/posts/{{slug}}', item, 'customSlug', 'en', defaultI18nConfig);
264
+ expect(result).toBe('/posts/custom-value');
265
+ });
266
+
267
+ test('falls back to _slug when slugField is missing', () => {
268
+ const item: CMSItem = { _id: '1', _slug: 'fallback-slug', title: 'Test' };
269
+ const result = buildCMSItemPath('/posts/{{slug}}', item, 'nonexistent', 'en', defaultI18nConfig);
270
+ expect(result).toBe('/posts/fallback-slug');
271
+ });
272
+
273
+ test('falls back to _id when both slugField and _slug are missing', () => {
274
+ const item: CMSItem = { _id: 'abc123', title: 'Test' };
275
+ const result = buildCMSItemPath('/items/{{slug}}', item, 'missing', 'en', defaultI18nConfig);
276
+ expect(result).toBe('/items/abc123');
277
+ });
278
+
279
+ test('handles i18n slug values', () => {
280
+ const item: CMSItem = {
281
+ _id: '1',
282
+ _slug: { _i18n: true, en: 'hello', fr: 'bonjour' },
283
+ title: 'Test'
284
+ };
285
+ const result = buildCMSItemPath('/page/{{slug}}', item, '_slug', 'fr', defaultI18nConfig);
286
+ expect(result).toBe('/page/bonjour');
287
+ });
288
+
289
+ test('resolves i18n slug for default locale', () => {
290
+ const item: CMSItem = {
291
+ _id: '1',
292
+ _slug: { _i18n: true, en: 'hello', fr: 'bonjour' },
293
+ title: 'Test'
294
+ };
295
+ const result = buildCMSItemPath('/page/{{slug}}', item, '_slug', 'en', defaultI18nConfig);
296
+ expect(result).toBe('/page/hello');
297
+ });
298
+
299
+ test('handles numeric slug values', () => {
300
+ const item: CMSItem = { _id: '42', title: 'Test' };
301
+ const result = buildCMSItemPath('/item/{{slug}}', item, '_id', 'en', defaultI18nConfig);
302
+ expect(result).toBe('/item/42');
303
+ });
304
+ });
305
+
306
+ // ============================================================================
307
+ // injectTrackingScript
308
+ // ============================================================================
309
+
310
+ describe('injectTrackingScript', () => {
311
+ test('injects script before </head>', () => {
312
+ const html = '<html><head><title>Test</title></head><body></body></html>';
313
+ const script = 'console.log("tracking")';
314
+ const result = injectTrackingScript(html, script);
315
+ expect(result).toContain('<script>console.log("tracking")</script>\n</head>');
316
+ });
317
+
318
+ test('preserves existing head content', () => {
319
+ const html = '<html><head><meta charset="utf-8"><link rel="stylesheet" href="style.css"></head><body></body></html>';
320
+ const script = 'track()';
321
+ const result = injectTrackingScript(html, script);
322
+ expect(result).toContain('<meta charset="utf-8">');
323
+ expect(result).toContain('<link rel="stylesheet" href="style.css">');
324
+ expect(result).toContain('<script>track()</script>');
325
+ });
326
+
327
+ test('returns original html if no </head> tag', () => {
328
+ const html = '<html><body>no head</body></html>';
329
+ const script = 'track()';
330
+ const result = injectTrackingScript(html, script);
331
+ expect(result).toBe(html);
332
+ });
333
+ });
334
+
335
+ // ============================================================================
336
+ // generateRobotsTxt
337
+ // ============================================================================
338
+
339
+ describe('generateRobotsTxt', () => {
340
+ test('generates robots.txt with correct content', async () => {
341
+ const dir = createTestDir();
342
+ await generateRobotsTxt('https://example.com', dir);
343
+
344
+ const content = readFileSync(join(dir, 'robots.txt'), 'utf-8');
345
+ expect(content).toContain('User-agent: *');
346
+ expect(content).toContain('Allow: /');
347
+ expect(content).toContain('Sitemap: https://example.com/sitemap.xml');
348
+ });
349
+
350
+ test('uses provided siteUrl in sitemap reference', async () => {
351
+ const dir = createTestDir();
352
+ await generateRobotsTxt('https://mysite.dev', dir);
353
+
354
+ const content = readFileSync(join(dir, 'robots.txt'), 'utf-8');
355
+ expect(content).toContain('Sitemap: https://mysite.dev/sitemap.xml');
356
+ });
357
+ });
358
+
359
+ // ============================================================================
360
+ // generateSitemap
361
+ // ============================================================================
362
+
363
+ describe('generateSitemap', () => {
364
+ test('generates valid XML sitemap', async () => {
365
+ const dir = createTestDir();
366
+ const urls = ['/', '/about', '/contact'];
367
+ await generateSitemap(urls, 'https://example.com', dir);
368
+
369
+ const content = readFileSync(join(dir, 'sitemap.xml'), 'utf-8');
370
+ expect(content).toContain('<?xml version="1.0" encoding="UTF-8"?>');
371
+ expect(content).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">');
372
+ expect(content).toContain('<url><loc>https://example.com/</loc></url>');
373
+ expect(content).toContain('<url><loc>https://example.com/about</loc></url>');
374
+ expect(content).toContain('<url><loc>https://example.com/contact</loc></url>');
375
+ });
376
+
377
+ test('sorts URLs for deterministic output', async () => {
378
+ const dir = createTestDir();
379
+ const urls = ['/z-page', '/a-page', '/m-page'];
380
+ await generateSitemap(urls, 'https://example.com', dir);
381
+
382
+ const content = readFileSync(join(dir, 'sitemap.xml'), 'utf-8');
383
+ const aIdx = content.indexOf('/a-page');
384
+ const mIdx = content.indexOf('/m-page');
385
+ const zIdx = content.indexOf('/z-page');
386
+ expect(aIdx).toBeLessThan(mIdx);
387
+ expect(mIdx).toBeLessThan(zIdx);
388
+ });
389
+
390
+ test('handles empty URL list', async () => {
391
+ const dir = createTestDir();
392
+ await generateSitemap([], 'https://example.com', dir);
393
+
394
+ const content = readFileSync(join(dir, 'sitemap.xml'), 'utf-8');
395
+ expect(content).toContain('<urlset');
396
+ expect(content).toContain('</urlset>');
397
+ // No <url> entries
398
+ expect(content).not.toContain('<url>');
399
+ });
400
+
401
+ test('handles single URL', async () => {
402
+ const dir = createTestDir();
403
+ await generateSitemap(['/'], 'https://example.com', dir);
404
+
405
+ const content = readFileSync(join(dir, 'sitemap.xml'), 'utf-8');
406
+ expect(content).toContain('<url><loc>https://example.com/</loc></url>');
407
+ });
408
+
409
+ test('does not mutate input array', async () => {
410
+ const dir = createTestDir();
411
+ const urls = ['/c', '/a', '/b'];
412
+ const original = [...urls];
413
+ await generateSitemap(urls, 'https://example.com', dir);
414
+ expect(urls).toEqual(original);
415
+ });
416
+ });
417
+
418
+ // ============================================================================
419
+ // Cleanup
420
+ // ============================================================================
421
+
422
+ afterEach(() => {
423
+ // Cleanup is done per-test via createTestDir
424
+ });
package/build-static.ts CHANGED
@@ -25,9 +25,11 @@ import { loadProjectConfig } from "./lib/shared/fontLoader";
25
25
  import { FileSystemCMSProvider } from "./lib/server/providers/fileSystemCMSProvider";
26
26
  import { CMSService } from "./lib/server/services/cmsService";
27
27
  import { isI18nValue, resolveI18nValue } from "./lib/shared/i18n";
28
- import type { ComponentDefinition, JSONPage, CMSSchema, CMSItem, I18nConfig } from "./lib/shared/types";
28
+ import type { ComponentDefinition, JSONPage, CMSSchema, CMSItem, I18nConfig, Experiment } from "./lib/shared/types";
29
29
  import type { SlugMap } from "./lib/shared/slugTranslator";
30
30
  import { buildItemUrl } from "./lib/shared/itemTemplateUtils";
31
+ import { generateMiddleware, generateTrackFunction, generateResultsFunction } from "./lib/server/ab/generateFunctions";
32
+ import { generateTrackingScript } from "./lib/server/ab/trackingScript";
31
33
 
32
34
  /**
33
35
  * Collect build errors for error overlay
@@ -37,14 +39,14 @@ const buildErrors: BuildError[] = [];
37
39
  /**
38
40
  * Generate short hash from content for file naming
39
41
  */
40
- function hashContent(content: string): string {
42
+ export function hashContent(content: string): string {
41
43
  return createHash('sha256').update(content).digest('hex').slice(0, 8);
42
44
  }
43
45
 
44
46
  /**
45
47
  * Format a Bun build log entry to a readable string
46
48
  */
47
- function formatBunLog(log: any): string {
49
+ export function formatBunLog(log: any): string {
48
50
  const parts: string[] = [];
49
51
 
50
52
  // Try to get position info
@@ -205,7 +207,7 @@ function copyDirectory(src: string, dest: string): void {
205
207
  * Default locale files go to root, other locales to subdirectories
206
208
  * Uses translated slugs from meta.slugs if available
207
209
  */
208
- function getLocalizedOutputPath(
210
+ export function getLocalizedOutputPath(
209
211
  basePath: string,
210
212
  locale: string,
211
213
  defaultLocale: string,
@@ -234,7 +236,7 @@ function getLocalizedOutputPath(
234
236
  /**
235
237
  * Get display path for logging (the actual URL the user will visit)
236
238
  */
237
- function getDisplayPath(
239
+ export function getDisplayPath(
238
240
  basePath: string,
239
241
  locale: string,
240
242
  defaultLocale: string,
@@ -259,7 +261,7 @@ function getDisplayPath(
259
261
  /**
260
262
  * Generate robots.txt with sensible defaults
261
263
  */
262
- async function generateRobotsTxt(siteUrl: string, distDir: string): Promise<void> {
264
+ export async function generateRobotsTxt(siteUrl: string, distDir: string): Promise<void> {
263
265
  const content = `User-agent: *
264
266
  Allow: /
265
267
 
@@ -271,7 +273,7 @@ Sitemap: ${siteUrl}/sitemap.xml
271
273
  /**
272
274
  * Generate sitemap.xml from collected URLs
273
275
  */
274
- async function generateSitemap(urls: string[], siteUrl: string, distDir: string): Promise<void> {
276
+ export async function generateSitemap(urls: string[], siteUrl: string, distDir: string): Promise<void> {
275
277
  // Sort URLs for deterministic output
276
278
  const sortedUrls = [...urls].sort();
277
279
 
@@ -301,11 +303,12 @@ function cleanDist(): void {
301
303
  let cleaned = 0;
302
304
 
303
305
  for (const file of files) {
304
- // Keep fonts, images, and icons
306
+ // Keep fonts, images, icons, and assets
305
307
  if (
306
308
  file === "fonts" ||
307
309
  file === "images" ||
308
- file === "icons"
310
+ file === "icons" ||
311
+ file === "assets"
309
312
  ) {
310
313
  continue;
311
314
  }
@@ -334,7 +337,7 @@ function cleanDist(): void {
334
337
  /**
335
338
  * Check if a page is a CMS template
336
339
  */
337
- function isCMSPage(pageData: JSONPage): boolean {
340
+ export function isCMSPage(pageData: JSONPage): boolean {
338
341
  return pageData.meta?.source === 'cms' && !!pageData.meta?.cms;
339
342
  }
340
343
 
@@ -342,7 +345,7 @@ function isCMSPage(pageData: JSONPage): boolean {
342
345
  * Build URL path for a CMS item based on the URL pattern
343
346
  * Uses schema.slugField to get the slug value, supporting i18n slugs
344
347
  */
345
- function buildCMSItemPath(
348
+ export function buildCMSItemPath(
346
349
  urlPattern: string,
347
350
  item: CMSItem,
348
351
  slugField: string,
@@ -395,7 +398,8 @@ async function buildCMSTemplates(
395
398
  cmsService: CMSService,
396
399
  generatedUrls: Set<string>,
397
400
  staticCollections: Map<string, ClientDataCollection>,
398
- siteUrl?: string
401
+ siteUrl?: string,
402
+ abTrackingScript?: string | null
399
403
  ): Promise<{ success: number; errors: number }> {
400
404
  let successCount = 0;
401
405
  let errorCount = 0;
@@ -493,6 +497,11 @@ async function buildCMSTemplates(
493
497
  finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}" defer></script>\n</body>`);
494
498
  }
495
499
 
500
+ // Inject A/B tracking script if experiments are running
501
+ if (abTrackingScript) {
502
+ finalHtml = injectTrackingScript(finalHtml, abTrackingScript);
503
+ }
504
+
496
505
  const outputPath = locale === i18nConfig.defaultLocale
497
506
  ? `${distDir}${itemPath}.html`
498
507
  : `${distDir}/${locale}${itemPath}.html`;
@@ -568,6 +577,59 @@ async function buildCMSTemplates(
568
577
  return { success: successCount, errors: errorCount };
569
578
  }
570
579
 
580
+ /**
581
+ * Load running experiments from experiments.json
582
+ */
583
+ async function loadRunningExperiments(): Promise<Experiment[]> {
584
+ const experimentsPath = join(projectPaths.project, 'experiments.json');
585
+ if (!existsSync(experimentsPath)) return [];
586
+
587
+ try {
588
+ const content = await readFile(experimentsPath, 'utf-8');
589
+ const data = JSON.parse(content);
590
+ const experiments: Experiment[] = data.experiments || [];
591
+ return experiments.filter(e => e.status === 'running');
592
+ } catch {
593
+ return [];
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Inject A/B tracking script into HTML before </head>
599
+ */
600
+ export function injectTrackingScript(html: string, trackingJs: string): string {
601
+ const scriptTag = `<script>${trackingJs}</script>`;
602
+ return html.replace('</head>', `${scriptTag}\n</head>`);
603
+ }
604
+
605
+ /**
606
+ * Generate A/B testing Cloudflare Functions into dist/functions/
607
+ */
608
+ async function generateABFunctions(experiments: Experiment[], distDir: string): Promise<void> {
609
+ // Generate middleware
610
+ const middlewareContent = generateMiddleware(experiments);
611
+ if (middlewareContent) {
612
+ const middlewarePath = join(distDir, 'functions', '_middleware.ts');
613
+ const middlewareDir = join(distDir, 'functions');
614
+ if (!existsSync(middlewareDir)) {
615
+ mkdirSync(middlewareDir, { recursive: true });
616
+ }
617
+ await writeFile(middlewarePath, middlewareContent, 'utf-8');
618
+ }
619
+
620
+ // Generate tracking endpoint
621
+ const trackContent = generateTrackFunction();
622
+ const trackDir = join(distDir, 'functions', 'api');
623
+ if (!existsSync(trackDir)) {
624
+ mkdirSync(trackDir, { recursive: true });
625
+ }
626
+ await writeFile(join(trackDir, 'ab-track.ts'), trackContent, 'utf-8');
627
+
628
+ // Generate results endpoint
629
+ const resultsContent = generateResultsFunction();
630
+ await writeFile(join(trackDir, 'ab-results.ts'), resultsContent, 'utf-8');
631
+ }
632
+
571
633
  /**
572
634
  * Main build function
573
635
  */
@@ -589,6 +651,15 @@ export async function buildStaticPages(): Promise<void> {
589
651
  // Track all generated URLs for sitemap
590
652
  const generatedUrls = new Set<string>();
591
653
 
654
+ // Load running A/B experiments (if any)
655
+ const runningExperiments = await loadRunningExperiments();
656
+ let trackingScript: string | null = null;
657
+ if (runningExperiments.length > 0) {
658
+ const exp = runningExperiments[0]; // Use first running experiment
659
+ trackingScript = generateTrackingScript(exp.id, exp.conversionGoals);
660
+ console.log(`๐Ÿงช A/B experiment active: "${exp.name}" (${exp.variants.length} variant(s))\n`);
661
+ }
662
+
592
663
  // Load i18n config for multi-locale build
593
664
  const i18nConfig = await loadI18nConfig();
594
665
  console.log(`๐ŸŒ Locales: ${i18nConfig.locales.map(l => l.code).join(", ")} (default: ${i18nConfig.defaultLocale})\n`);
@@ -612,6 +683,7 @@ export async function buildStaticPages(): Promise<void> {
612
683
  copyDirectory(projectPaths.fonts(), join(distDir, "fonts"));
613
684
  copyDirectory(projectPaths.images(), join(distDir, "images"));
614
685
  copyDirectory(projectPaths.icons(), join(distDir, "icons"));
686
+ copyDirectory(projectPaths.assets(), join(distDir, "assets"));
615
687
 
616
688
  // Copy libraries folder (downloaded external JS/CSS files)
617
689
  const librariesDir = join(projectPaths.project, "libraries");
@@ -794,6 +866,11 @@ export async function buildStaticPages(): Promise<void> {
794
866
  finalHtml = finalHtml.replace('</body>', ` <script src="${scriptPath}" defer></script>\n</body>`);
795
867
  }
796
868
 
869
+ // Inject A/B tracking script if experiments are running
870
+ if (trackingScript) {
871
+ finalHtml = injectTrackingScript(finalHtml, trackingScript);
872
+ }
873
+
797
874
  // Determine locale-specific output path with translated slug
798
875
  const outputPath = getLocalizedOutputPath(basePath, locale, i18nConfig.defaultLocale, distDir, slugs);
799
876
 
@@ -882,7 +959,8 @@ export async function buildStaticPages(): Promise<void> {
882
959
  cmsService,
883
960
  generatedUrls,
884
961
  staticCollections,
885
- siteUrl
962
+ siteUrl,
963
+ trackingScript
886
964
  );
887
965
  successCount += cmsResult.success;
888
966
  errorCount += cmsResult.errors;
@@ -890,6 +968,12 @@ export async function buildStaticPages(): Promise<void> {
890
968
  // Generate static data files for collections with 'static' strategy
891
969
  await generateStaticDataFiles(staticCollections, distDir);
892
970
 
971
+ // Generate A/B testing functions if experiments are running
972
+ if (runningExperiments.length > 0) {
973
+ await generateABFunctions(runningExperiments, distDir);
974
+ console.log(`\n๐Ÿงช A/B testing functions generated (middleware, tracker, results API)`);
975
+ }
976
+
893
977
  // Generate SEO files (robots.txt and sitemap.xml)
894
978
  if (siteUrl) {
895
979
  await generateRobotsTxt(siteUrl, distDir);
@@ -916,6 +1000,9 @@ export async function buildStaticPages(): Promise<void> {
916
1000
  if (existsSync(functionsDir)) {
917
1001
  console.log(` - functions/ (Cloudflare Pages Functions)`);
918
1002
  }
1003
+ if (runningExperiments.length > 0) {
1004
+ console.log(` - A/B testing (middleware + tracking injected)`);
1005
+ }
919
1006
  if (siteUrl) {
920
1007
  console.log(` - robots.txt, sitemap.xml (SEO)`);
921
1008
  }
@@ -72,6 +72,10 @@ export function initializeClient(options: ClientInitOptions = {}): ClientService
72
72
  prefetchService,
73
73
  // Provide page name getter for interactive styles class generation
74
74
  getCurrentPageName: getPageNameFromPath,
75
+ getCurrentPagePath: () => {
76
+ if (typeof window === 'undefined') return '/';
77
+ return window.location.pathname;
78
+ },
75
79
  });
76
80
 
77
81
  const styleInjector = new StyleInjector({