@se-studio/ab-testing 0.0.0-next16-20260822103555
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/CHANGELOG.md +1125 -0
- package/README.md +412 -0
- package/dist/codegen/cli.d.ts +3 -0
- package/dist/codegen/cli.d.ts.map +1 -0
- package/dist/codegen/cli.js +121 -0
- package/dist/codegen/cli.js.map +1 -0
- package/dist/codegen/generator.d.ts +42 -0
- package/dist/codegen/generator.d.ts.map +1 -0
- package/dist/codegen/generator.js +56 -0
- package/dist/codegen/generator.js.map +1 -0
- package/dist/codegen/index.d.ts +9 -0
- package/dist/codegen/index.d.ts.map +1 -0
- package/dist/codegen/index.js +8 -0
- package/dist/codegen/index.js.map +1 -0
- package/dist/components/AbTestReporter.d.ts +2 -0
- package/dist/components/AbTestReporter.d.ts.map +1 -0
- package/dist/components/AbTestReporter.js +19 -0
- package/dist/components/AbTestReporter.js.map +1 -0
- package/dist/components/AbTestUtmScript.d.ts +17 -0
- package/dist/components/AbTestUtmScript.d.ts.map +1 -0
- package/dist/components/AbTestUtmScript.js +43 -0
- package/dist/components/AbTestUtmScript.js.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +3 -0
- package/dist/components/index.js.map +1 -0
- package/dist/hooks/index.d.ts +8 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAbTestAssignments.d.ts +56 -0
- package/dist/hooks/useAbTestAssignments.d.ts.map +1 -0
- package/dist/hooks/useAbTestAssignments.js +87 -0
- package/dist/hooks/useAbTestAssignments.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/assignment.d.ts +31 -0
- package/dist/middleware/assignment.d.ts.map +1 -0
- package/dist/middleware/assignment.js +125 -0
- package/dist/middleware/assignment.js.map +1 -0
- package/dist/middleware/cache.d.ts +30 -0
- package/dist/middleware/cache.d.ts.map +1 -0
- package/dist/middleware/cache.js +140 -0
- package/dist/middleware/cache.js.map +1 -0
- package/dist/middleware/cookies.d.ts +44 -0
- package/dist/middleware/cookies.d.ts.map +1 -0
- package/dist/middleware/cookies.js +66 -0
- package/dist/middleware/cookies.js.map +1 -0
- package/dist/middleware/handler.d.ts +53 -0
- package/dist/middleware/handler.d.ts.map +1 -0
- package/dist/middleware/handler.js +201 -0
- package/dist/middleware/handler.js.map +1 -0
- package/dist/middleware/index.d.ts +15 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +13 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/prune.d.ts +13 -0
- package/dist/middleware/prune.d.ts.map +1 -0
- package/dist/middleware/prune.js +29 -0
- package/dist/middleware/prune.js.map +1 -0
- package/dist/middleware/types.d.ts +103 -0
- package/dist/middleware/types.d.ts.map +1 -0
- package/dist/middleware/types.js +2 -0
- package/dist/middleware/types.js.map +1 -0
- package/dist/middleware/urlParams.d.ts +7 -0
- package/dist/middleware/urlParams.d.ts.map +1 -0
- package/dist/middleware/urlParams.js +20 -0
- package/dist/middleware/urlParams.js.map +1 -0
- package/dist/middleware/variantId.d.ts +9 -0
- package/dist/middleware/variantId.d.ts.map +1 -0
- package/dist/middleware/variantId.js +12 -0
- package/dist/middleware/variantId.js.map +1 -0
- package/dist/reporting/buildExperimentImpression.d.ts +6 -0
- package/dist/reporting/buildExperimentImpression.d.ts.map +1 -0
- package/dist/reporting/buildExperimentImpression.js +22 -0
- package/dist/reporting/buildExperimentImpression.js.map +1 -0
- package/dist/types.d.ts +144 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +12 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +20 -0
- package/dist/utils.js.map +1 -0
- package/dist/webhook/handler.d.ts +109 -0
- package/dist/webhook/handler.d.ts.map +1 -0
- package/dist/webhook/handler.js +128 -0
- package/dist/webhook/handler.js.map +1 -0
- package/dist/webhook/index.d.ts +8 -0
- package/dist/webhook/index.d.ts.map +1 -0
- package/dist/webhook/index.js +7 -0
- package/dist/webhook/index.js.map +1 -0
- package/docs/llms.md +106 -0
- package/package.json +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# @se-studio/ab-testing
|
|
2
|
+
|
|
3
|
+
Server-side A/B testing framework for Next.js applications with Contentful CMS.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Server-side rendering**: No flicker or layout shift - variants are rendered on the server
|
|
8
|
+
- **Edge-optimized**: Middleware runs at the edge for minimal latency
|
|
9
|
+
- **CMS-driven**: Test configuration managed entirely in Contentful
|
|
10
|
+
- **Provider-agnostic**: Bring your own blob storage (Vercel KV, Netlify Blobs, etc.)
|
|
11
|
+
- **Flexible analytics**: Use the `useAbTestAssignments` hook to integrate with any analytics platform
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
17
|
+
│ Contentful │────▶│ Webhook Handler │────▶│ Blob Store │
|
|
18
|
+
│ PageTest │ │ (API Route) │ │ (Project- │
|
|
19
|
+
│ Entries │ │ │ │ specific) │
|
|
20
|
+
└─────────────────┘ └──────────────────┘ └────────┬────────┘
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
24
|
+
│ User Request │────▶│ Middleware │────▶│ Variant Page │
|
|
25
|
+
│ /pricing │ │ (Edge) │ │ (SSR) │
|
|
26
|
+
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
┌──────────────────┐
|
|
30
|
+
│ Cookie Set │
|
|
31
|
+
│ ab-test-info │
|
|
32
|
+
└────────┬─────────┘
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
┌──────────────────┐
|
|
36
|
+
│ useAbTestAssign- │
|
|
37
|
+
│ ments Hook │
|
|
38
|
+
│ → Your Analytics │
|
|
39
|
+
└──────────────────┘
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm add @se-studio/ab-testing
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### 1. Implement Your Blob Store
|
|
51
|
+
|
|
52
|
+
The package requires you to implement the `IBlobStore<AbTest>` interface for your hosting platform.
|
|
53
|
+
|
|
54
|
+
**Vercel KV Example:**
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// src/server/abTestStore.ts
|
|
58
|
+
import { kv } from '@vercel/kv';
|
|
59
|
+
import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
|
|
60
|
+
|
|
61
|
+
const STORE_KEY = 'ab-tests';
|
|
62
|
+
|
|
63
|
+
export function getAbTestStore(): IBlobStore<AbTest> {
|
|
64
|
+
return {
|
|
65
|
+
async get(key) {
|
|
66
|
+
const data = await kv.hget<AbTest>(STORE_KEY, key);
|
|
67
|
+
return data ?? undefined;
|
|
68
|
+
},
|
|
69
|
+
async set(key, value) {
|
|
70
|
+
await kv.hset(STORE_KEY, { [key]: value });
|
|
71
|
+
},
|
|
72
|
+
async bulkWrite(entries) {
|
|
73
|
+
await kv.del(STORE_KEY);
|
|
74
|
+
if (entries.length > 0) {
|
|
75
|
+
const data = Object.fromEntries(entries);
|
|
76
|
+
await kv.hset(STORE_KEY, data);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async size() {
|
|
80
|
+
return kv.hlen(STORE_KEY);
|
|
81
|
+
},
|
|
82
|
+
async values() {
|
|
83
|
+
const data = await kv.hgetall<Record<string, AbTest>>(STORE_KEY);
|
|
84
|
+
return data ? Object.values(data) : [];
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Netlify Blobs Example:**
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// src/server/abTestStore.ts
|
|
94
|
+
import { getStore } from '@netlify/blobs';
|
|
95
|
+
import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
|
|
96
|
+
|
|
97
|
+
export function getAbTestStore(): IBlobStore<AbTest> {
|
|
98
|
+
const store = getStore('ab-testing');
|
|
99
|
+
const BLOB_NAME = 'config';
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
async get(key) {
|
|
103
|
+
const data = await store.get(BLOB_NAME, { type: 'json' });
|
|
104
|
+
return data?.[key];
|
|
105
|
+
},
|
|
106
|
+
async set(key, value) {
|
|
107
|
+
const data = (await store.get(BLOB_NAME, { type: 'json' })) ?? {};
|
|
108
|
+
data[key] = value;
|
|
109
|
+
await store.setJSON(BLOB_NAME, data);
|
|
110
|
+
},
|
|
111
|
+
async bulkWrite(entries) {
|
|
112
|
+
const data = Object.fromEntries(entries);
|
|
113
|
+
await store.setJSON(BLOB_NAME, data);
|
|
114
|
+
},
|
|
115
|
+
async size() {
|
|
116
|
+
const data = await store.get(BLOB_NAME, { type: 'json' });
|
|
117
|
+
return data ? Object.keys(data).length : 0;
|
|
118
|
+
},
|
|
119
|
+
async values() {
|
|
120
|
+
const data = await store.get(BLOB_NAME, { type: 'json' });
|
|
121
|
+
return data ? Object.values(data) : [];
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2. Set Up the Middleware
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// src/proxy.ts (Next 16) or src/middleware.ts (Next 15)
|
|
131
|
+
import { createAbTestMiddleware } from '@se-studio/ab-testing/middleware';
|
|
132
|
+
import { getAbTestStore } from './server/abTestStore';
|
|
133
|
+
import { NextResponse } from 'next/server';
|
|
134
|
+
import type { NextRequest } from 'next/server';
|
|
135
|
+
|
|
136
|
+
const abTestHandler = createAbTestMiddleware({
|
|
137
|
+
getStore: getAbTestStore,
|
|
138
|
+
cacheTtlMs: 60000, // 60 second cache
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export async function proxy(request: NextRequest) {
|
|
142
|
+
// Skip static assets, API routes, etc.
|
|
143
|
+
if (request.nextUrl.pathname.startsWith('/_next') ||
|
|
144
|
+
request.nextUrl.pathname.startsWith('/api')) {
|
|
145
|
+
return NextResponse.next();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Process A/B tests
|
|
149
|
+
const abResponse = await abTestHandler(request);
|
|
150
|
+
if (abResponse) return abResponse;
|
|
151
|
+
|
|
152
|
+
return NextResponse.next();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const config = {
|
|
156
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
|
157
|
+
};
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
#### Page-test route (static rebuild pattern)
|
|
161
|
+
|
|
162
|
+
When using `createStaticAbTestMiddleware` with `variantPathPrefix: '/page-test'`, add a `/page-test/[...slugs]` route that rewrites serve variant content on the control URL. Use `findCanonicalPath(testsByPath, variantSlug)` to resolve the user-facing path, then call the paired handlers from `@se-studio/core-ui/server`:
|
|
163
|
+
|
|
164
|
+
- `generatePageTestMetadata` — control `indexed` and canonical path in `<head>`
|
|
165
|
+
- `generatePageTest` — variant body with control identity for breadcrumbs, `href`, and JSON-LD
|
|
166
|
+
|
|
167
|
+
See the `se-marketing-sites-create-page` skill for the full route template. Direct visits to a variant's own CMS URL (normal page route) should keep using `generatePage`.
|
|
168
|
+
|
|
169
|
+
### 3. Create the Webhook Handler
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
// src/app/api/webhooks/ab-test/route.ts
|
|
173
|
+
import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
|
|
174
|
+
import { getAbTestStore } from '@/server/abTestStore';
|
|
175
|
+
import { revalidateTag } from 'next/cache';
|
|
176
|
+
|
|
177
|
+
// Your Contentful fetch function
|
|
178
|
+
async function fetchPageTests() {
|
|
179
|
+
// Fetch all PageTest entries from Contentful
|
|
180
|
+
// Return array of RawPageTest objects
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const dynamic = 'force-dynamic';
|
|
184
|
+
|
|
185
|
+
export const POST = createWebhookHandler({
|
|
186
|
+
fetchPageTests,
|
|
187
|
+
getStore: getAbTestStore,
|
|
188
|
+
webhookSecret: process.env.CONTENTFUL_WEBHOOK_SECRET,
|
|
189
|
+
revalidate: () => revalidateTag('PageTests', { expire: 0 }),
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### 4. Create Your Analytics Reporter (Project-Specific)
|
|
194
|
+
|
|
195
|
+
The package provides a `useAbTestAssignments` hook that returns test assignments for the current page. You create your own reporter component with your project's specific analytics integrations.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
// src/components/AbTestReporter.tsx (project-specific)
|
|
199
|
+
'use client';
|
|
200
|
+
|
|
201
|
+
import { useEffect } from 'react';
|
|
202
|
+
import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
203
|
+
import { sendEvent } from '@/lib/analytics';
|
|
204
|
+
|
|
205
|
+
export function AbTestReporter() {
|
|
206
|
+
const assignments = useAbTestAssignments();
|
|
207
|
+
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
for (const assignment of assignments) {
|
|
210
|
+
// Send to GTM/GA4
|
|
211
|
+
sendEvent('experiment_impression', {
|
|
212
|
+
experiment_id: assignment.testId,
|
|
213
|
+
experiment_name: assignment.test_label,
|
|
214
|
+
variant_id: assignment.test_path,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}, [assignments]);
|
|
218
|
+
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**With HubSpot Integration (HSD-style):**
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
// src/components/AbTestReporter.tsx
|
|
227
|
+
'use client';
|
|
228
|
+
|
|
229
|
+
import { useEffect } from 'react';
|
|
230
|
+
import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
231
|
+
import { sendEvent } from '@/lib/analytics';
|
|
232
|
+
import { sendHubspotCustomEvent } from '@/lib/hubspotCustomEvents';
|
|
233
|
+
|
|
234
|
+
export function AbTestReporter() {
|
|
235
|
+
const assignments = useAbTestAssignments();
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
for (const assignment of assignments) {
|
|
239
|
+
// Send to GTM/GA4
|
|
240
|
+
sendEvent('experiment_impression', {
|
|
241
|
+
experiment_id: assignment.testId,
|
|
242
|
+
experiment_name: assignment.test_label,
|
|
243
|
+
variant_id: assignment.test_path,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Send to HubSpot (if configured)
|
|
247
|
+
if (assignment.hubspot_event) {
|
|
248
|
+
sendHubspotCustomEvent(assignment.hubspot_event, {
|
|
249
|
+
experiment_name: assignment.test_label,
|
|
250
|
+
experiment_id: assignment.testId,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}, [assignments]);
|
|
255
|
+
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
**Add to your layout:**
|
|
261
|
+
|
|
262
|
+
```tsx
|
|
263
|
+
// src/app/layout.tsx
|
|
264
|
+
import { AbTestReporter } from '@/components/AbTestReporter';
|
|
265
|
+
|
|
266
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
267
|
+
return (
|
|
268
|
+
<html>
|
|
269
|
+
<body>
|
|
270
|
+
{children}
|
|
271
|
+
<AbTestReporter />
|
|
272
|
+
</body>
|
|
273
|
+
</html>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Contentful Content Model
|
|
279
|
+
|
|
280
|
+
### PageTest
|
|
281
|
+
|
|
282
|
+
| Field | Type | Description |
|
|
283
|
+
|-------|------|-------------|
|
|
284
|
+
| cmsLabel | Symbol | Internal label for the test |
|
|
285
|
+
| control | Reference (Page/PageVariant) | The control page to test against |
|
|
286
|
+
| enabled | Boolean | Whether the test is active |
|
|
287
|
+
| trackingLabel | Symbol (optional) | Override for analytics label |
|
|
288
|
+
| searchParameters | Symbol (optional) | URL params to match (e.g., "utm_source=google") |
|
|
289
|
+
| configuration | JSON | Array of `{weight, hubspot_event_name?}` |
|
|
290
|
+
| variants | References (PageVariant[]) | Variants to test |
|
|
291
|
+
|
|
292
|
+
### PageVariant
|
|
293
|
+
|
|
294
|
+
Existing content type that references an original page and defines component swaps.
|
|
295
|
+
|
|
296
|
+
## API Reference
|
|
297
|
+
|
|
298
|
+
### Types
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
interface AbTest {
|
|
302
|
+
id: string;
|
|
303
|
+
cmsLabel: string;
|
|
304
|
+
controlSlug: string;
|
|
305
|
+
searchParameters?: string;
|
|
306
|
+
trackingLabel?: string;
|
|
307
|
+
enabled: boolean;
|
|
308
|
+
configuration: AbTestVariantConfig[];
|
|
309
|
+
variants: AbTestVariant[];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface IBlobStore<T> {
|
|
313
|
+
get(key: string): Promise<T | undefined>;
|
|
314
|
+
set(key: string, value: T): Promise<void>;
|
|
315
|
+
bulkWrite(entries: [string, T][]): Promise<void>;
|
|
316
|
+
size(): Promise<number>;
|
|
317
|
+
values(): Promise<T[]>;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface ActiveAbTestAssignment {
|
|
321
|
+
testId: string;
|
|
322
|
+
test_label: string;
|
|
323
|
+
test_path: string;
|
|
324
|
+
hubspot_event?: string;
|
|
325
|
+
original_path?: string;
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Middleware
|
|
330
|
+
|
|
331
|
+
```typescript
|
|
332
|
+
import { createAbTestMiddleware } from '@se-studio/ab-testing/middleware';
|
|
333
|
+
|
|
334
|
+
const handler = createAbTestMiddleware({
|
|
335
|
+
getStore: () => store, // Required: blob store factory
|
|
336
|
+
cacheTtlMs: 60000, // Optional: cache TTL (default: 60s)
|
|
337
|
+
cookieName: 'ab-test-info', // Optional: cookie name
|
|
338
|
+
cookieMaxAge: 2592000, // Optional: cookie max age (default: 30 days)
|
|
339
|
+
shouldProcess: (path) => true, // Optional: filter requests
|
|
340
|
+
devTestData: [], // Optional: test data for development
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### Webhook
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
|
|
348
|
+
|
|
349
|
+
export const POST = createWebhookHandler({
|
|
350
|
+
fetchPageTests: () => Promise<RawPageTest[]>, // Required
|
|
351
|
+
getStore: () => store, // Required
|
|
352
|
+
webhookSecret: 'secret', // Optional
|
|
353
|
+
revalidate: () => void, // Optional
|
|
354
|
+
onSkippedTest: (id, reason) => void, // Optional
|
|
355
|
+
});
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Hook
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
362
|
+
|
|
363
|
+
// Returns array of assignments for the current page
|
|
364
|
+
const assignments = useAbTestAssignments({
|
|
365
|
+
cookieName: 'ab-test-info', // Optional: custom cookie name
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Each assignment contains:
|
|
369
|
+
// - testId: string
|
|
370
|
+
// - test_label: string
|
|
371
|
+
// - test_path: string (variant slug or "control")
|
|
372
|
+
// - hubspot_event?: string
|
|
373
|
+
// - original_path?: string
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Analytics Integration
|
|
377
|
+
|
|
378
|
+
### Google Tag Manager (GTM)
|
|
379
|
+
|
|
380
|
+
Push events to `window.dataLayer`:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
window.dataLayer.push({
|
|
384
|
+
event: 'experiment_impression',
|
|
385
|
+
experiment_id: assignment.testId,
|
|
386
|
+
experiment_name: assignment.test_label,
|
|
387
|
+
variant_id: assignment.test_path,
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
### Google Analytics 4 (GA4)
|
|
392
|
+
|
|
393
|
+
Register custom dimensions in GA4:
|
|
394
|
+
1. Go to Admin > Data display > Custom definitions
|
|
395
|
+
2. Create event-scoped dimensions for:
|
|
396
|
+
- `experiment_id`
|
|
397
|
+
- `experiment_name`
|
|
398
|
+
- `variant_id`
|
|
399
|
+
|
|
400
|
+
## URL parameter injection
|
|
401
|
+
|
|
402
|
+
Page tests may define per-bucket `url_params` (or bare `utm_content` in configuration). Those values are stored on the assignment cookie for analytics.
|
|
403
|
+
|
|
404
|
+
**Address-bar injection is opt-in.** Both middleware and `AbTestUtmScript` apply `url_params` to the URL **only when** the test has `injectUrlParams: true` (stored on the cookie assignment as `injectUrlParams: true`). Default is **false**.
|
|
405
|
+
|
|
406
|
+
- Legacy cookies without `injectUrlParams` do **not** inject.
|
|
407
|
+
- Keep `<AbTestUtmScript />` in the root layout so opt-in tests still surface params for GTM before hydration.
|
|
408
|
+
|
|
409
|
+
## License
|
|
410
|
+
|
|
411
|
+
MIT
|
|
412
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/codegen/cli.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** biome-ignore-all lint/suspicious/noConsole: CLI script */
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { transformPageTest } from '../webhook/handler.js';
|
|
6
|
+
import { buildStaticTestsByPath, generateAbTestsFileContent } from './generator.js';
|
|
7
|
+
async function loadEnvFile(path) {
|
|
8
|
+
try {
|
|
9
|
+
const content = await readFile(path, 'utf-8');
|
|
10
|
+
for (const line of content.split('\n')) {
|
|
11
|
+
const trimmed = line.trim();
|
|
12
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
13
|
+
continue;
|
|
14
|
+
const eq = trimmed.indexOf('=');
|
|
15
|
+
if (eq === -1)
|
|
16
|
+
continue;
|
|
17
|
+
const key = trimmed.slice(0, eq).trim();
|
|
18
|
+
const val = trimmed
|
|
19
|
+
.slice(eq + 1)
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^["']|["']$/g, '');
|
|
22
|
+
if (key && !(key in process.env))
|
|
23
|
+
process.env[key] = val;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// .env.local not present — rely on process.env
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function getOutputPath() {
|
|
31
|
+
const outputArg = process.argv.find((a) => a.startsWith('--output='));
|
|
32
|
+
if (outputArg)
|
|
33
|
+
return outputArg.slice('--output='.length);
|
|
34
|
+
return process.env['AB_TESTS_OUTPUT'] ?? './src/generated/abTests.ts';
|
|
35
|
+
}
|
|
36
|
+
async function main() {
|
|
37
|
+
await loadEnvFile('.env.local');
|
|
38
|
+
const draftOnly = process.env['DRAFT_ONLY'] === 'true';
|
|
39
|
+
const spaceId = process.env['CONTENTFUL_SPACE_ID'];
|
|
40
|
+
const accessToken = draftOnly
|
|
41
|
+
? process.env['CONTENTFUL_PREVIEW_ACCESS_TOKEN']
|
|
42
|
+
: process.env['CONTENTFUL_ACCESS_TOKEN'];
|
|
43
|
+
const environment = process.env['CONTENTFUL_ENVIRONMENT'] ?? process.env['CONTENTFUL_ENVIRONMENT_NAME'] ?? 'master';
|
|
44
|
+
if (!spaceId || !accessToken) {
|
|
45
|
+
console.error(draftOnly
|
|
46
|
+
? 'Missing CONTENTFUL_SPACE_ID or CONTENTFUL_PREVIEW_ACCESS_TOKEN'
|
|
47
|
+
: 'Missing CONTENTFUL_SPACE_ID or CONTENTFUL_ACCESS_TOKEN');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const host = draftOnly ? 'preview.contentful.com' : 'cdn.contentful.com';
|
|
51
|
+
const url = `https://${host}/spaces/${spaceId}/environments/${environment}/entries?content_type=pageTest&include=1`;
|
|
52
|
+
console.log(`Fetching A/B tests from Contentful (environment: ${environment}, preview: ${String(draftOnly)})...`);
|
|
53
|
+
const response = await fetch(url, {
|
|
54
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
console.error(`Contentful API error: ${response.status} ${response.statusText}`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
const data = (await response.json());
|
|
61
|
+
const rawCount = data.items?.length ?? 0;
|
|
62
|
+
const includesCount = data.includes?.Entry?.length ?? 0;
|
|
63
|
+
console.log(` → ${rawCount} pageTest entry(s) returned, ${includesCount} linked entry(s) in includes`);
|
|
64
|
+
const includesMap = new Map();
|
|
65
|
+
for (const entry of data.includes?.Entry ?? []) {
|
|
66
|
+
includesMap.set(entry.sys.id, entry);
|
|
67
|
+
}
|
|
68
|
+
const rawTests = (data.items ?? []).map((item) => {
|
|
69
|
+
const { fields, sys } = item;
|
|
70
|
+
const controlRef = fields['control'];
|
|
71
|
+
const controlEntry = controlRef ? includesMap.get(controlRef.sys.id) : undefined;
|
|
72
|
+
if (controlRef && !controlEntry) {
|
|
73
|
+
console.warn(` → Test ${sys.id} ("${fields['cmsLabel']}"): control entry ${controlRef.sys.id} not found in includes (not published?)`);
|
|
74
|
+
}
|
|
75
|
+
const variantRefs = fields['variants'] ?? [];
|
|
76
|
+
const variantItems = variantRefs
|
|
77
|
+
.map((ref) => {
|
|
78
|
+
const entry = includesMap.get(ref.sys.id);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
console.warn(` → Test ${sys.id}: variant entry ${ref.sys.id} not found in includes (not published?)`);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return { sys: ref.sys, slug: entry.fields['slug'] };
|
|
84
|
+
})
|
|
85
|
+
.filter((v) => v !== null);
|
|
86
|
+
console.log(` → Test "${fields['cmsLabel']}" (${sys.id}): enabled=${String(fields['enabled'])}, control slug="${controlEntry?.fields['slug'] ?? 'MISSING'}", ${variantItems.length} variant(s)`);
|
|
87
|
+
return {
|
|
88
|
+
sys,
|
|
89
|
+
cmsLabel: fields['cmsLabel'],
|
|
90
|
+
trackingLabel: fields['trackingLabel'],
|
|
91
|
+
enabled: fields['enabled'],
|
|
92
|
+
searchParameters: fields['searchParameters'],
|
|
93
|
+
injectUrlParams: fields['injectUrlParams'],
|
|
94
|
+
configuration: fields['configuration'],
|
|
95
|
+
control: controlRef && controlEntry
|
|
96
|
+
? { sys: controlRef.sys, slug: controlEntry.fields['slug'] }
|
|
97
|
+
: null,
|
|
98
|
+
variantsCollection: { items: variantItems },
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const tests = rawTests
|
|
102
|
+
.map((raw) => transformPageTest(raw, (id, reason) => console.warn(` → Skipping test ${id}: ${reason}`)))
|
|
103
|
+
.filter((t) => t !== null);
|
|
104
|
+
console.log(`Found ${tests.length} active test(s)`);
|
|
105
|
+
for (const test of tests) {
|
|
106
|
+
console.log(` → "${test.cmsLabel}" on /${test.controlSlug} with ${test.variants.length} variant(s)`);
|
|
107
|
+
}
|
|
108
|
+
const testsByPath = buildStaticTestsByPath(tests);
|
|
109
|
+
const pathCount = Object.keys(testsByPath).length;
|
|
110
|
+
console.log(`Mapped to ${pathCount} path(s): ${Object.keys(testsByPath).join(', ')}`);
|
|
111
|
+
const content = generateAbTestsFileContent(testsByPath);
|
|
112
|
+
const outputPath = resolve(getOutputPath());
|
|
113
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
114
|
+
await writeFile(outputPath, content);
|
|
115
|
+
console.log(`Written to ${outputPath}`);
|
|
116
|
+
}
|
|
117
|
+
main().catch((error) => {
|
|
118
|
+
console.error('Failed to generate A/B test config:', error);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
});
|
|
121
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/codegen/cli.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CachedAbTest } from '../middleware/types';
|
|
2
|
+
import type { AbTest } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Build a plain-object testsByPath map suitable for static generation.
|
|
5
|
+
*
|
|
6
|
+
* This is the Record-based equivalent of the internal Map used at runtime.
|
|
7
|
+
* The result is JSON-serialisable and can be written to a generated TypeScript file
|
|
8
|
+
* with `generateAbTestsFileContent`.
|
|
9
|
+
*
|
|
10
|
+
* @param tests - Array of AbTest entries (e.g. from `transformPageTest`)
|
|
11
|
+
* @returns Record of normalised control path → CachedAbTest[]
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildStaticTestsByPath(tests: AbTest[]): Record<string, CachedAbTest[]>;
|
|
14
|
+
export interface GenerateAbTestsFileOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The package to import `CachedAbTest` from in the generated file.
|
|
17
|
+
* @default "@se-studio/ab-testing"
|
|
18
|
+
*/
|
|
19
|
+
importPath?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Generate the content of a static TypeScript file exporting a `testsByPath` constant.
|
|
23
|
+
*
|
|
24
|
+
* Apps write this content to e.g. `src/lib/__generated/abTests.ts` at build time,
|
|
25
|
+
* then import `testsByPath` directly in their middleware — zero async overhead, no KV store needed.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* // management/generateAbTests.ts
|
|
30
|
+
* import { writeFile } from 'node:fs/promises';
|
|
31
|
+
* import { transformPageTest } from '@se-studio/ab-testing/webhook';
|
|
32
|
+
* import { buildStaticTestsByPath, generateAbTestsFileContent } from '@se-studio/ab-testing/codegen';
|
|
33
|
+
* import { fetchRawPageTests } from '../src/cms/pageTests';
|
|
34
|
+
*
|
|
35
|
+
* const raw = await fetchRawPageTests();
|
|
36
|
+
* const tests = raw.map(r => transformPageTest(r)).filter(t => t !== null);
|
|
37
|
+
* const testsByPath = buildStaticTestsByPath(tests);
|
|
38
|
+
* await writeFile('./src/lib/__generated/abTests.ts', generateAbTestsFileContent(testsByPath));
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export declare function generateAbTestsFileContent(testsByPath: Record<string, CachedAbTest[]>, options?: GenerateAbTestsFileOptions): string;
|
|
42
|
+
//# sourceMappingURL=generator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/codegen/generator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,CAetF;AAED,MAAM,WAAW,0BAA0B;IACzC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,EAAE,CAAC,EAC3C,OAAO,GAAE,0BAA+B,GACvC,MAAM,CAWR"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { computeCachedTest, normalizePath } from '../middleware/cache.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build a plain-object testsByPath map suitable for static generation.
|
|
4
|
+
*
|
|
5
|
+
* This is the Record-based equivalent of the internal Map used at runtime.
|
|
6
|
+
* The result is JSON-serialisable and can be written to a generated TypeScript file
|
|
7
|
+
* with `generateAbTestsFileContent`.
|
|
8
|
+
*
|
|
9
|
+
* @param tests - Array of AbTest entries (e.g. from `transformPageTest`)
|
|
10
|
+
* @returns Record of normalised control path → CachedAbTest[]
|
|
11
|
+
*/
|
|
12
|
+
export function buildStaticTestsByPath(tests) {
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const test of tests) {
|
|
15
|
+
if (!test.enabled)
|
|
16
|
+
continue;
|
|
17
|
+
const path = normalizePath(test.controlSlug);
|
|
18
|
+
const cached = computeCachedTest(test);
|
|
19
|
+
const existing = result[path] ?? [];
|
|
20
|
+
existing.push(cached);
|
|
21
|
+
result[path] = existing;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Generate the content of a static TypeScript file exporting a `testsByPath` constant.
|
|
27
|
+
*
|
|
28
|
+
* Apps write this content to e.g. `src/lib/__generated/abTests.ts` at build time,
|
|
29
|
+
* then import `testsByPath` directly in their middleware — zero async overhead, no KV store needed.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* // management/generateAbTests.ts
|
|
34
|
+
* import { writeFile } from 'node:fs/promises';
|
|
35
|
+
* import { transformPageTest } from '@se-studio/ab-testing/webhook';
|
|
36
|
+
* import { buildStaticTestsByPath, generateAbTestsFileContent } from '@se-studio/ab-testing/codegen';
|
|
37
|
+
* import { fetchRawPageTests } from '../src/cms/pageTests';
|
|
38
|
+
*
|
|
39
|
+
* const raw = await fetchRawPageTests();
|
|
40
|
+
* const tests = raw.map(r => transformPageTest(r)).filter(t => t !== null);
|
|
41
|
+
* const testsByPath = buildStaticTestsByPath(tests);
|
|
42
|
+
* await writeFile('./src/lib/__generated/abTests.ts', generateAbTestsFileContent(testsByPath));
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function generateAbTestsFileContent(testsByPath, options = {}) {
|
|
46
|
+
const { importPath = '@se-studio/ab-testing' } = options;
|
|
47
|
+
const json = JSON.stringify(testsByPath, null, 2);
|
|
48
|
+
return `// This file is automatically generated from Contentful CMS — do not edit manually.
|
|
49
|
+
// Re-generate: pnpm generate:ab-tests (runs automatically via predev/prebuild)
|
|
50
|
+
|
|
51
|
+
import type { CachedAbTest } from '${importPath}';
|
|
52
|
+
|
|
53
|
+
export const testsByPath: Record<string, CachedAbTest[]> = ${json};
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=generator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generator.js","sourceRoot":"","sources":["../../src/codegen/generator.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAI1E;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CAAC,KAAe;IACpD,MAAM,MAAM,GAAmC,EAAE,CAAC;IAElD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,CAAC,IAAI,CAAC,OAAO;YAAE,SAAS;QAE5B,MAAM,IAAI,GAAG,aAAa,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC7C,MAAM,MAAM,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAEvC,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACpC,QAAQ,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACtB,MAAM,CAAC,IAAI,CAAC,GAAG,QAAQ,CAAC;IAC1B,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAUD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,0BAA0B,CACxC,WAA2C,EAC3C,UAAsC,EAAE;IAExC,MAAM,EAAE,UAAU,GAAG,uBAAuB,EAAE,GAAG,OAAO,CAAC;IACzD,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAElD,OAAO;;;qCAG4B,UAAU;;6DAEc,IAAI;CAChE,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @se-studio/ab-testing/codegen
|
|
3
|
+
*
|
|
4
|
+
* Build-time utilities for generating a static A/B test configuration file.
|
|
5
|
+
* Import these in a Node.js management script, not in edge/browser code.
|
|
6
|
+
*/
|
|
7
|
+
export type { GenerateAbTestsFileOptions } from './generator';
|
|
8
|
+
export { buildStaticTestsByPath, generateAbTestsFileContent, } from './generator';
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/codegen/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AAC9D,OAAO,EACL,sBAAsB,EACtB,0BAA0B,GAC3B,MAAM,aAAa,CAAC"}
|