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.
- package/build-static.test.ts +424 -0
- package/build-static.ts +100 -13
- package/lib/client/ClientInitializer.ts +4 -0
- package/lib/client/core/ComponentBuilder.ts +155 -16
- package/lib/client/core/builders/embedBuilder.ts +48 -6
- package/lib/client/core/builders/linkBuilder.ts +2 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +45 -5
- package/lib/client/core/builders/listBuilder.ts +12 -3
- package/lib/client/routing/Router.tsx +8 -1
- package/lib/client/templateEngine.ts +89 -98
- package/lib/server/__integration__/api-routes.test.ts +148 -0
- package/lib/server/__integration__/cms-integration.test.ts +161 -0
- package/lib/server/__integration__/server-lifecycle.test.ts +101 -0
- package/lib/server/__integration__/ssr-rendering.test.ts +131 -0
- package/lib/server/__integration__/static-assets.test.ts +80 -0
- package/lib/server/__integration__/test-helpers.ts +205 -0
- package/lib/server/ab/generateFunctions.ts +346 -0
- package/lib/server/ab/trackingScript.ts +45 -0
- package/lib/server/index.ts +2 -2
- package/lib/server/jsonLoader.ts +124 -46
- package/lib/server/routes/api/cms.ts +3 -2
- package/lib/server/routes/api/components.ts +13 -2
- package/lib/server/services/cmsService.ts +0 -5
- package/lib/server/services/componentService.ts +255 -29
- package/lib/server/services/configService.test.ts +950 -0
- package/lib/server/services/configService.ts +39 -0
- package/lib/server/services/index.ts +1 -1
- package/lib/server/ssr/htmlGenerator.test.ts +992 -0
- package/lib/server/ssr/htmlGenerator.ts +3 -3
- package/lib/server/ssr/imageMetadata.test.ts +168 -0
- package/lib/server/ssr/imageMetadata.ts +58 -0
- package/lib/server/ssr/jsCollector.test.ts +287 -0
- package/lib/server/ssr/ssrRenderer.test.ts +3702 -0
- package/lib/server/ssr/ssrRenderer.ts +131 -15
- package/lib/shared/constants.ts +3 -0
- package/lib/shared/fontLoader.test.ts +335 -0
- package/lib/shared/i18n.test.ts +106 -0
- package/lib/shared/i18n.ts +17 -11
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.ts +43 -1
- package/lib/shared/libraryLoader.test.ts +392 -0
- package/lib/shared/linkUtils.ts +24 -0
- package/lib/shared/nodeUtils.test.ts +100 -0
- package/lib/shared/nodeUtils.ts +43 -0
- package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
- package/lib/shared/richtext/htmlToTiptap.test.ts +948 -0
- package/lib/shared/richtext/htmlToTiptap.ts +46 -2
- package/lib/shared/richtext/tiptapToHtml.ts +65 -0
- package/lib/shared/richtext/types.ts +4 -1
- package/lib/shared/types/cms.ts +2 -0
- package/lib/shared/types/components.ts +12 -3
- package/lib/shared/types/experiments.ts +55 -0
- package/lib/shared/types/index.ts +10 -0
- package/lib/shared/utils.ts +2 -6
- package/lib/shared/validation/propValidator.test.ts +50 -0
- package/lib/shared/validation/propValidator.ts +2 -2
- package/lib/shared/validation/schemas.ts +10 -2
- 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
|
|
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({
|