@kiryl.pekarski/payload-plugin-ab 1.0.0
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/README.md +583 -0
- package/dist/adapters/payloadGlobal/index.d.ts +15 -0
- package/dist/adapters/payloadGlobal/index.js +82 -0
- package/dist/adapters/payloadGlobal/index.js.map +1 -0
- package/dist/adapters/vercelEdge/index.d.ts +20 -0
- package/dist/adapters/vercelEdge/index.js +66 -0
- package/dist/adapters/vercelEdge/index.js.map +1 -0
- package/dist/config-CRUREAW_.d.ts +37 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +334 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
# payload-plugin-ab
|
|
2
|
+
|
|
3
|
+
A/B testing plugin for [Payload CMS](https://payloadcms.com/) v3. Automatically maintains a **variant manifest** — a path-keyed map of A/B variant data — by hooking into Payload collection events. The manifest is designed to be read by Next.js middleware at the edge to route users to the correct variant without an additional database round-trip.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [How It Works](#how-it-works)
|
|
8
|
+
- [Key Features](#key-features)
|
|
9
|
+
- [Installation](#installation)
|
|
10
|
+
- [Quick Start](#quick-start)
|
|
11
|
+
- [Configuration Reference](#configuration-reference)
|
|
12
|
+
- [Plugin Options](#plugin-options)
|
|
13
|
+
- [CollectionABConfig](#collectionabconfig)
|
|
14
|
+
- [Storage Adapters](#storage-adapters)
|
|
15
|
+
- [Storage Adapters In Depth](#storage-adapters-in-depth)
|
|
16
|
+
- [payloadGlobalAdapter](#payloadglobaladapter)
|
|
17
|
+
- [vercelEdgeAdapter](#verceledgeadapter)
|
|
18
|
+
- [Using the Manifest in Next.js Middleware](#using-the-manifest-in-nextjs-middleware)
|
|
19
|
+
- [Localization Support](#localization-support)
|
|
20
|
+
- [TypeScript Generics](#typescript-generics)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## How It Works
|
|
25
|
+
|
|
26
|
+
The plugin follows a **write-on-change, read-at-edge** pattern:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
Payload Admin
|
|
30
|
+
│
|
|
31
|
+
│ Editor saves/deletes a variant document
|
|
32
|
+
▼
|
|
33
|
+
afterChange / afterDelete hooks (injected by plugin)
|
|
34
|
+
│
|
|
35
|
+
│ Recompute all variants for the affected page path(s)
|
|
36
|
+
▼
|
|
37
|
+
Storage Adapter (write)
|
|
38
|
+
├─ payloadGlobalAdapter → Payload Global (JSON field, read via REST)
|
|
39
|
+
└─ vercelEdgeAdapter → Vercel Edge Config (read via @vercel/edge-config)
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
Manifest: { "/about": [variantA, variantB], "/pricing": [...] }
|
|
43
|
+
│
|
|
44
|
+
▼
|
|
45
|
+
Next.js Middleware (edge-compatible read)
|
|
46
|
+
│
|
|
47
|
+
│ storage.read(pathname) → VariantData[] | null
|
|
48
|
+
│
|
|
49
|
+
└─ Route user to original page or variant
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The **manifest** is a plain JSON object:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
type Manifest<TVariantData> = Record<string, TVariantData[]>
|
|
56
|
+
// e.g. { "/en/about": [{ bucket: "b", rewritePath: "/en/variants/b/about" }] }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
A path with no variants is absent from the manifest (no-op for middleware). Only paths that have at least one active variant are written.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Key Features
|
|
64
|
+
|
|
65
|
+
- **Zero-config hooks** — registers `afterChange` and `afterDelete` hooks on your variant collection automatically; no manual hook wiring required.
|
|
66
|
+
- **Locale-aware** — iterates over every configured Payload locale and writes a separate manifest entry per locale so multilingual A/B tests work out of the box.
|
|
67
|
+
- **Pluggable storage** — swap between the built-in adapters or implement your own `StorageAdapter`.
|
|
68
|
+
- **Edge-compatible reads** — both built-in adapters expose a `read()` method that can run inside Next.js middleware (no Node.js runtime required).
|
|
69
|
+
- **Fully typed** — the `TVariantData` generic flows through the entire plugin so your variant data is typed end-to-end.
|
|
70
|
+
- **Debug mode** — optionally expose the manifest Global in the Payload admin panel for inspection.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# pnpm (recommended)
|
|
78
|
+
pnpm add payload-plugin-ab
|
|
79
|
+
|
|
80
|
+
# npm
|
|
81
|
+
npm install payload-plugin-ab
|
|
82
|
+
|
|
83
|
+
# yarn
|
|
84
|
+
yarn add payload-plugin-ab
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**If you plan to use the Vercel Edge adapter**, also install its peer dependency:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
pnpm add @vercel/edge-config
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Peer dependency**: `payload ^3.0.0` must already be installed in your project.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Quick Start
|
|
98
|
+
|
|
99
|
+
This example wires up A/B testing for a `page` collection where variants live in a `page-variants` collection.
|
|
100
|
+
|
|
101
|
+
### Step 1 — Create a variant collection
|
|
102
|
+
|
|
103
|
+
The variant collection requires a relationship back to the parent page and a bucket identifier that distinguishes the variant in the URL and cookie:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// collections/PageVariants.ts
|
|
107
|
+
import type { CollectionConfig } from 'payload'
|
|
108
|
+
|
|
109
|
+
export const PageVariants: CollectionConfig = {
|
|
110
|
+
slug: 'page-variants',
|
|
111
|
+
fields: [
|
|
112
|
+
{
|
|
113
|
+
name: 'page',
|
|
114
|
+
type: 'relationship',
|
|
115
|
+
relationTo: 'page',
|
|
116
|
+
required: true,
|
|
117
|
+
index: true,
|
|
118
|
+
label: { en: 'Original Page', es: 'Página Original' },
|
|
119
|
+
admin: {
|
|
120
|
+
description: {
|
|
121
|
+
en: 'The page this variant belongs to.',
|
|
122
|
+
es: 'La página a la que pertenece esta variante.',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'bucketID',
|
|
128
|
+
type: 'select',
|
|
129
|
+
required: true,
|
|
130
|
+
index: true,
|
|
131
|
+
options: [
|
|
132
|
+
{ label: 'A', value: 'a' },
|
|
133
|
+
{ label: 'B', value: 'b' },
|
|
134
|
+
{ label: 'C', value: 'c' },
|
|
135
|
+
],
|
|
136
|
+
label: { en: 'Bucket ID', es: 'ID de Variante' },
|
|
137
|
+
admin: {
|
|
138
|
+
description: {
|
|
139
|
+
en: 'Variant identifier used in the URL and cookie. Each page can have at most one variant per bucket.',
|
|
140
|
+
es: 'Identificador de variante usado en la URL y cookie. Cada página puede tener como máximo una variante por bucket.',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Each page can have at most one variant per bucket (`a`, `b`, `c`). Middleware uses the bucket ID to assign users and rewrite their request to the correct variant path.
|
|
149
|
+
|
|
150
|
+
### Step 2 — Register the plugin
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// payload.config.ts
|
|
154
|
+
import { buildConfig } from 'payload'
|
|
155
|
+
import { abTestingPlugin } from 'payload-plugin-ab'
|
|
156
|
+
import { payloadGlobalAdapter } from 'payload-plugin-ab/adapters/payload-global'
|
|
157
|
+
import { Page } from './collections/Page'
|
|
158
|
+
import { PageVariants } from './collections/PageVariants'
|
|
159
|
+
|
|
160
|
+
// Shape of data stored per variant in the manifest.
|
|
161
|
+
// Your middleware reads this to decide where to rewrite the request.
|
|
162
|
+
type ABVariantData = {
|
|
163
|
+
bucket: string // 'a' | 'b' | 'c'
|
|
164
|
+
rewritePath: string // the URL this bucket should render
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const abAdapter = payloadGlobalAdapter<ABVariantData>({
|
|
168
|
+
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
export default buildConfig({
|
|
172
|
+
collections: [Page, PageVariants],
|
|
173
|
+
|
|
174
|
+
plugins: [
|
|
175
|
+
abTestingPlugin<ABVariantData>({
|
|
176
|
+
debug: true, // set false in production to hide the manifest Global in admin
|
|
177
|
+
storage: abAdapter,
|
|
178
|
+
collections: {
|
|
179
|
+
// Key = parent collection slug
|
|
180
|
+
page: {
|
|
181
|
+
variantCollectionSlug: 'page-variants',
|
|
182
|
+
|
|
183
|
+
// Return the URL path used as the manifest key.
|
|
184
|
+
// Return null to skip this document (e.g. pages without a slug).
|
|
185
|
+
generatePath: ({ doc, locale }) => {
|
|
186
|
+
const slug = doc.slug as string | undefined
|
|
187
|
+
if (!slug) return null
|
|
188
|
+
|
|
189
|
+
return `/${locale}/${slug}`
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
// Return the data stored per variant in the manifest.
|
|
193
|
+
generateVariantData: ({ doc, variantDoc, locale }): ABVariantData => {
|
|
194
|
+
const slug = doc.slug as string
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
bucket: variantDoc.bucketID as string,
|
|
198
|
+
rewritePath: `/${locale}/variants/${variantDoc.bucketID}/${slug}`,
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
],
|
|
205
|
+
})
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Step 3 — Read the manifest in middleware
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// middleware.ts (Next.js)
|
|
212
|
+
import { NextResponse } from 'next/server'
|
|
213
|
+
import type { NextRequest } from 'next/server'
|
|
214
|
+
import { payloadGlobalAdapter } from 'payload-plugin-ab/adapters/payload-global'
|
|
215
|
+
|
|
216
|
+
type ABVariantData = { bucket: string; rewritePath: string }
|
|
217
|
+
|
|
218
|
+
const storage = payloadGlobalAdapter<ABVariantData>({
|
|
219
|
+
serverURL: process.env.NEXT_PUBLIC_SERVER_URL ?? '',
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const AB_BUCKET_COOKIE = 'ab-bucket'
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Determines whether the current request should be rewritten to a variant path.
|
|
226
|
+
*
|
|
227
|
+
* - visiblePathname — the URL the user sees (used as the per-path cookie key)
|
|
228
|
+
* - manifestKey — the key to look up in the manifest (may differ from the
|
|
229
|
+
* visible path when locale prefixes or rewrites are involved)
|
|
230
|
+
* - originalRewritePath — internal path to rewrite to when the user is assigned
|
|
231
|
+
* the 'original' bucket on their first visit
|
|
232
|
+
*
|
|
233
|
+
* Returns a NextResponse with a rewrite (and sets a cookie on first visit),
|
|
234
|
+
* or null if no rewrite is needed (caller should fall through to NextResponse.next()).
|
|
235
|
+
*/
|
|
236
|
+
async function resolveAbRewrite(
|
|
237
|
+
request: NextRequest,
|
|
238
|
+
visiblePathname: string,
|
|
239
|
+
manifestKey: string,
|
|
240
|
+
originalRewritePath: string,
|
|
241
|
+
): Promise<NextResponse | null> {
|
|
242
|
+
let variants: ABVariantData[] | null = null
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
variants = await storage.read(manifestKey)
|
|
246
|
+
} catch {
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!variants?.length) return null
|
|
251
|
+
|
|
252
|
+
const cookieName = `${AB_BUCKET_COOKIE}_${encodeURIComponent(visiblePathname)}`
|
|
253
|
+
const existingBucket = request.cookies.get(cookieName)?.value
|
|
254
|
+
|
|
255
|
+
let bucket = existingBucket
|
|
256
|
+
if (!bucket) {
|
|
257
|
+
// Include the original as one of the possible outcomes so not every user
|
|
258
|
+
// is sent to a variant — e.g. with 2 variants there is a 1-in-3 chance
|
|
259
|
+
// of seeing the original page.
|
|
260
|
+
const idx = Math.floor(Math.random() * (variants.length + 1))
|
|
261
|
+
bucket = idx === variants.length ? 'original' : variants[idx]!.bucket
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (bucket === 'original') {
|
|
265
|
+
if (!existingBucket) {
|
|
266
|
+
// First visit: persist the assignment, then rewrite to the original path
|
|
267
|
+
const url = request.nextUrl.clone()
|
|
268
|
+
url.pathname = originalRewritePath
|
|
269
|
+
const res = NextResponse.rewrite(url)
|
|
270
|
+
res.cookies.set(cookieName, 'original', { path: '/', sameSite: 'lax' })
|
|
271
|
+
return res
|
|
272
|
+
}
|
|
273
|
+
// Returning user assigned to original — no rewrite needed
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const match = variants.find((v) => v.bucket === bucket)
|
|
278
|
+
if (!match) return null
|
|
279
|
+
|
|
280
|
+
const url = request.nextUrl.clone()
|
|
281
|
+
url.pathname = match.rewritePath
|
|
282
|
+
const res = NextResponse.rewrite(url)
|
|
283
|
+
if (!existingBucket) {
|
|
284
|
+
res.cookies.set(cookieName, bucket, { path: '/', sameSite: 'lax' })
|
|
285
|
+
}
|
|
286
|
+
return res
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function middleware(request: NextRequest) {
|
|
290
|
+
const { pathname } = request.nextUrl
|
|
291
|
+
|
|
292
|
+
const result = await resolveAbRewrite(
|
|
293
|
+
request,
|
|
294
|
+
pathname, // visible pathname — used as the per-path cookie key
|
|
295
|
+
pathname, // manifest key — adjust if you prepend a locale or other prefix
|
|
296
|
+
pathname, // original rewrite path — where to send the 'original' bucket
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return result ?? NextResponse.next()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const config = {
|
|
303
|
+
matcher: ['/((?!_next|api|favicon.ico).*)'],
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
## Configuration Reference
|
|
310
|
+
|
|
311
|
+
### Plugin Options
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
interface AbTestingPluginConfig<TVariantData extends object> {
|
|
315
|
+
/** Enable or disable the plugin entirely. Default: true */
|
|
316
|
+
enabled?: boolean
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* When true, the manifest Global is visible in the Payload admin panel
|
|
320
|
+
* under the "System" group. Useful for debugging.
|
|
321
|
+
* Default: false
|
|
322
|
+
*/
|
|
323
|
+
debug?: boolean
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Map of parent collection slug → A/B config for that collection.
|
|
327
|
+
* You can configure multiple parent collections simultaneously.
|
|
328
|
+
*/
|
|
329
|
+
collections: Record<string, CollectionABConfig<TVariantData>>
|
|
330
|
+
|
|
331
|
+
/** Storage adapter — payloadGlobalAdapter or vercelEdgeAdapter. */
|
|
332
|
+
storage: StorageAdapter<TVariantData>
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### CollectionABConfig
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
interface CollectionABConfig<TVariantData extends object> {
|
|
340
|
+
/**
|
|
341
|
+
* Slug of the variant collection (e.g. 'page-variants').
|
|
342
|
+
* Must be registered in your Payload config.
|
|
343
|
+
* Hooks are automatically added to this collection.
|
|
344
|
+
*/
|
|
345
|
+
variantCollectionSlug: string
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Maps a parent document to the URL path used as the manifest key.
|
|
349
|
+
*
|
|
350
|
+
* Return null to skip writing the manifest for that document
|
|
351
|
+
* (e.g. for drafts, documents without slugs, etc.).
|
|
352
|
+
*
|
|
353
|
+
* Called once per locale when localization is enabled.
|
|
354
|
+
*/
|
|
355
|
+
generatePath: (args: { doc: Record<string, unknown>; locale: string }) => string | null
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Builds the data object stored per variant in the manifest array.
|
|
359
|
+
* This is what your middleware reads — include everything it needs
|
|
360
|
+
* to make a routing decision (slug, weight, experiment ID, etc.).
|
|
361
|
+
*
|
|
362
|
+
* Called once per variant document, per locale.
|
|
363
|
+
*/
|
|
364
|
+
generateVariantData: (args: {
|
|
365
|
+
doc: Record<string, unknown> // parent document
|
|
366
|
+
variantDoc: Record<string, unknown> // variant document
|
|
367
|
+
locale: string
|
|
368
|
+
}) => TVariantData
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Storage Adapters
|
|
373
|
+
|
|
374
|
+
Both adapters implement the same `StorageAdapter` interface:
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
interface StorageAdapter<TVariantData extends object> {
|
|
378
|
+
/** Write variant data for a path. Called from afterChange hooks. */
|
|
379
|
+
write(path: string, variants: TVariantData[], payload: Payload): Promise<void>
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Read variant data for a path.
|
|
383
|
+
* Must be Edge-compatible (no Node.js-only APIs).
|
|
384
|
+
* Returns null if the path has no variants.
|
|
385
|
+
*/
|
|
386
|
+
read(path: string): Promise<TVariantData[] | null>
|
|
387
|
+
|
|
388
|
+
/** Remove all variant data for a path. Called from afterDelete hooks. */
|
|
389
|
+
clear(path: string, payload: Payload): Promise<void>
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Optional: return a GlobalConfig to register in Payload.
|
|
393
|
+
* Used by payloadGlobalAdapter to store the manifest.
|
|
394
|
+
* Not needed for vercelEdgeAdapter.
|
|
395
|
+
*/
|
|
396
|
+
createGlobal?(debug: boolean): GlobalConfig
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
You can implement this interface yourself if neither built-in adapter fits your stack (e.g. Redis, Upstash, KV, etc.).
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Storage Adapters In Depth
|
|
405
|
+
|
|
406
|
+
### payloadGlobalAdapter
|
|
407
|
+
|
|
408
|
+
Stores the manifest in a **Payload Global** as a JSON field. The manifest is then fetched from middleware via the Payload REST API.
|
|
409
|
+
|
|
410
|
+
**Import:**
|
|
411
|
+
```ts
|
|
412
|
+
import { payloadGlobalAdapter } from 'payload-plugin-ab/adapters/payload-global'
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
**Options:**
|
|
416
|
+
|
|
417
|
+
| Option | Type | Default | Description |
|
|
418
|
+
|---|---|---|---|
|
|
419
|
+
| `globalSlug` | `string` | `'_abManifest'` | Slug for the auto-created Payload Global |
|
|
420
|
+
| `serverURL` | `string` | `''` | Full origin of your Payload server (e.g. `https://cms.example.com`). Used by `read()` in middleware. |
|
|
421
|
+
| `apiRoute` | `string` | `'/api'` | Payload REST API prefix |
|
|
422
|
+
|
|
423
|
+
**Usage:**
|
|
424
|
+
```ts
|
|
425
|
+
import { payloadGlobalAdapter } from 'payload-plugin-ab/adapters/payload-global'
|
|
426
|
+
|
|
427
|
+
const storage = payloadGlobalAdapter({
|
|
428
|
+
globalSlug: '_abManifest', // optional, this is the default
|
|
429
|
+
serverURL: 'https://cms.example.com',
|
|
430
|
+
apiRoute: '/api', // optional, this is the default
|
|
431
|
+
})
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**How it reads:** The `read()` method fetches `GET {serverURL}{apiRoute}/globals/{globalSlug}` with `cache: 'no-store'` and returns `data.manifest[path]`. This REST endpoint is public (the Global has `access.read: () => true`).
|
|
435
|
+
|
|
436
|
+
**When the Global is hidden:** By default the Global is not shown in the Payload admin. Set `debug: true` in the plugin config to make it visible under the **System** group.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
### vercelEdgeAdapter
|
|
441
|
+
|
|
442
|
+
Stores the manifest in **Vercel Edge Config** — Vercel's ultra-low-latency global key-value store. Reads are served from the edge with sub-millisecond latency, making this the best option for Vercel-hosted projects.
|
|
443
|
+
|
|
444
|
+
**Import:**
|
|
445
|
+
```ts
|
|
446
|
+
import { vercelEdgeAdapter } from 'payload-plugin-ab/adapters/vercel-edge'
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
**Prerequisites:**
|
|
450
|
+
|
|
451
|
+
1. Install the Edge Config client:
|
|
452
|
+
```bash
|
|
453
|
+
pnpm add @vercel/edge-config
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
2. Create an Edge Config store in your Vercel project dashboard.
|
|
457
|
+
|
|
458
|
+
3. Set the following environment variables:
|
|
459
|
+
|
|
460
|
+
| Variable | Description |
|
|
461
|
+
|---|---|
|
|
462
|
+
| `EDGE_CONFIG` | Connection string (from Vercel dashboard, e.g. `https://edge-config.vercel.com/ecfg_xxx?token=yyy`) |
|
|
463
|
+
| `EDGE_CONFIG_ID` | Edge Config store ID (e.g. `ecfg_xxx`) — passed as `configID` |
|
|
464
|
+
| `VERCEL_REST_API_ACCESS_TOKEN` | Vercel REST API token with read/write access — passed as `vercelRestAPIAccessToken` |
|
|
465
|
+
|
|
466
|
+
**Options:**
|
|
467
|
+
|
|
468
|
+
| Option | Type | Default | Description |
|
|
469
|
+
|---|---|---|---|
|
|
470
|
+
| `configID` | `string` | required | Edge Config store ID |
|
|
471
|
+
| `configURL` | `string` | required | Full Edge Config URL (same as `EDGE_CONFIG` env var) |
|
|
472
|
+
| `vercelRestAPIAccessToken` | `string` | required | Vercel REST API access token |
|
|
473
|
+
| `teamID` | `string` | — | Vercel team ID (for team-scoped projects) |
|
|
474
|
+
| `manifestKey` | `string` | `'ab-testing'` | Top-level key in Edge Config that holds the manifest object |
|
|
475
|
+
|
|
476
|
+
**Usage:**
|
|
477
|
+
```ts
|
|
478
|
+
import { vercelEdgeAdapter } from 'payload-plugin-ab/adapters/vercel-edge'
|
|
479
|
+
|
|
480
|
+
const storage = vercelEdgeAdapter({
|
|
481
|
+
configID: process.env.EDGE_CONFIG_ID!,
|
|
482
|
+
configURL: process.env.EDGE_CONFIG!,
|
|
483
|
+
vercelRestAPIAccessToken: process.env.VERCEL_REST_API_ACCESS_TOKEN!,
|
|
484
|
+
teamID: process.env.VERCEL_TEAM_ID, // optional
|
|
485
|
+
manifestKey: 'ab-testing', // optional, this is the default
|
|
486
|
+
})
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
**How it writes:** Updates are made via the Vercel REST API (`PATCH /v1/edge-config/{configID}/items`) using an `upsert` operation. The adapter maintains a local in-memory cache of the manifest to avoid re-reading Edge Config on every write within the same server process.
|
|
490
|
+
|
|
491
|
+
**How it reads:** Uses `@vercel/edge-config`'s `get(manifestKey)` — this is edge-compatible and extremely fast.
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## Using the Manifest in Next.js Middleware
|
|
496
|
+
|
|
497
|
+
The `storage.read(path)` method is the only adapter method called from middleware. It returns:
|
|
498
|
+
|
|
499
|
+
- `TVariantData[]` — an array of variant objects for that path (at least one element)
|
|
500
|
+
- `null` — no variants configured for this path (pass through)
|
|
501
|
+
|
|
502
|
+
### resolveAbRewrite pattern
|
|
503
|
+
|
|
504
|
+
The recommended approach is a dedicated `resolveAbRewrite` helper (shown in the Quick Start above) that handles bucket assignment, sticky sessions, and rewrites in one place. Key behaviours:
|
|
505
|
+
|
|
506
|
+
| Behaviour | Detail |
|
|
507
|
+
|---|---|
|
|
508
|
+
| **Sticky sessions** | Bucket is stored in a per-path cookie (`ab-bucket_<encoded-pathname>`) so the same user always sees the same variant. |
|
|
509
|
+
| **Original bucket** | The original page is included as an outcome alongside the configured variants. With _n_ variants there is a 1-in-(_n_+1) chance of landing on the original. |
|
|
510
|
+
| **First visit** | Cookie is written and the request is rewritten in a single response — no extra redirect. |
|
|
511
|
+
| **Returning user (original)** | The function returns `null` so the caller falls through to `NextResponse.next()` — no unnecessary rewrite. |
|
|
512
|
+
| **Error safety** | `storage.read()` is wrapped in try/catch; any adapter error results in `null` (pass through). |
|
|
513
|
+
|
|
514
|
+
The three path arguments let you decouple the URL the user sees, the manifest lookup key, and the internal rewrite target — useful when locale prefixes or custom rewrite rules are involved:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
await resolveAbRewrite(
|
|
518
|
+
request,
|
|
519
|
+
visiblePathname, // used as the per-path cookie key
|
|
520
|
+
manifestKey, // key to look up in the manifest
|
|
521
|
+
originalRewritePath, // internal path for the 'original' bucket
|
|
522
|
+
)
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
---
|
|
526
|
+
|
|
527
|
+
## Localization Support
|
|
528
|
+
|
|
529
|
+
If your Payload config has `localization` enabled, the plugin automatically handles it. For every locale defined in `payload.config.localization.locales`, the hooks:
|
|
530
|
+
|
|
531
|
+
1. Fetch the parent document in that locale.
|
|
532
|
+
2. Call `generatePath({ doc, locale })` to determine the manifest key.
|
|
533
|
+
3. Call `generateVariantData({ doc, variantDoc, locale })` for each variant.
|
|
534
|
+
4. Write a separate manifest entry per locale.
|
|
535
|
+
|
|
536
|
+
This means you can generate locale-prefixed paths from any field on the document:
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
generatePath: ({ doc, locale }) => {
|
|
540
|
+
const slug = doc.slug as string | undefined
|
|
541
|
+
if (!slug) return null
|
|
542
|
+
|
|
543
|
+
return `/${locale}/${slug}` // e.g. '/en/about', '/fr/about'
|
|
544
|
+
},
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
No additional configuration is needed — locale support is automatic when `payload.config.localization` is set.
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## TypeScript Generics
|
|
552
|
+
|
|
553
|
+
The plugin is fully generic over `TVariantData extends object`. This type represents the shape of each variant entry in the manifest. Define it once and it flows through everywhere:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
// Define your variant shape — include everything middleware needs to route the request
|
|
557
|
+
type ABVariantData = {
|
|
558
|
+
bucket: string // which bucket this variant belongs to ('a' | 'b' | 'c')
|
|
559
|
+
rewritePath: string // the internal URL path middleware should rewrite to
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Pass the type to the adapter
|
|
563
|
+
const storage = payloadGlobalAdapter<ABVariantData>({ serverURL: '...' })
|
|
564
|
+
|
|
565
|
+
// Pass the same type to the plugin
|
|
566
|
+
abTestingPlugin<ABVariantData>({
|
|
567
|
+
collections: {
|
|
568
|
+
page: {
|
|
569
|
+
variantCollectionSlug: 'page-variants',
|
|
570
|
+
generatePath: ({ doc, locale }) => { /* ... */ },
|
|
571
|
+
|
|
572
|
+
// variantDoc is Record<string, unknown> — cast fields as needed
|
|
573
|
+
generateVariantData: ({ variantDoc, locale }): ABVariantData => ({
|
|
574
|
+
bucket: variantDoc.bucketID as string,
|
|
575
|
+
rewritePath: `/${locale}/variants/${variantDoc.bucketID}`,
|
|
576
|
+
}),
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
storage,
|
|
580
|
+
})
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
In middleware, `storage.read(path)` returns `ABVariantData[] | null` — fully typed.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { S as StorageAdapter } from '../../config-CRUREAW_.js';
|
|
2
|
+
import 'payload';
|
|
3
|
+
|
|
4
|
+
interface PayloadGlobalAdapterConfig {
|
|
5
|
+
/** Slug for the Payload Global that stores the manifest. Default: '_abManifest' */
|
|
6
|
+
globalSlug?: string;
|
|
7
|
+
/** Server URL used to fetch the global via REST in middleware. Example: 'https://example.com' */
|
|
8
|
+
serverURL?: string;
|
|
9
|
+
/** Payload API route prefix. Default: '/api' */
|
|
10
|
+
apiRoute?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
declare function payloadGlobalAdapter<TVariantData extends object>(config?: PayloadGlobalAdapterConfig): StorageAdapter<TVariantData>;
|
|
14
|
+
|
|
15
|
+
export { payloadGlobalAdapter };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/adapters/payloadGlobal/api/fetchManifest.ts
|
|
2
|
+
async function fetchManifest(serverURL, apiRoute, slug, path) {
|
|
3
|
+
try {
|
|
4
|
+
const res = await fetch(`${serverURL}${apiRoute}/globals/${slug}`, {
|
|
5
|
+
cache: "no-store"
|
|
6
|
+
});
|
|
7
|
+
if (!res.ok) return null;
|
|
8
|
+
const data = await res.json();
|
|
9
|
+
return data?.manifest?.[path] ?? null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/adapters/payloadGlobal/api/readManifest.ts
|
|
16
|
+
async function readManifest(payload, slug) {
|
|
17
|
+
try {
|
|
18
|
+
const doc = await payload.findGlobal({ slug, overrideAccess: true });
|
|
19
|
+
return doc?.manifest ?? {};
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/adapters/payloadGlobal/utils/createGlobal.ts
|
|
26
|
+
function createGlobal(slug, debug) {
|
|
27
|
+
return {
|
|
28
|
+
slug,
|
|
29
|
+
access: {
|
|
30
|
+
read: () => true
|
|
31
|
+
},
|
|
32
|
+
admin: {
|
|
33
|
+
hidden: !debug,
|
|
34
|
+
group: "System"
|
|
35
|
+
},
|
|
36
|
+
fields: [
|
|
37
|
+
{
|
|
38
|
+
name: "manifest",
|
|
39
|
+
type: "json",
|
|
40
|
+
admin: {
|
|
41
|
+
description: "A/B testing manifest. Managed automatically \u2014 do not edit manually."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/adapters/payloadGlobal/index.ts
|
|
49
|
+
function payloadGlobalAdapter(config) {
|
|
50
|
+
const slug = config?.globalSlug ?? "_abManifest";
|
|
51
|
+
return {
|
|
52
|
+
async write(path, variants, payload) {
|
|
53
|
+
const currentManifest = await readManifest(payload, slug);
|
|
54
|
+
await payload.updateGlobal({
|
|
55
|
+
slug,
|
|
56
|
+
data: { manifest: { ...currentManifest, [path]: variants } },
|
|
57
|
+
overrideAccess: true
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
async read(path) {
|
|
61
|
+
const serverURL = config?.serverURL ?? "";
|
|
62
|
+
const apiRoute = config?.apiRoute ?? "/api";
|
|
63
|
+
return fetchManifest(serverURL, apiRoute, slug, path);
|
|
64
|
+
},
|
|
65
|
+
async clear(path, payload) {
|
|
66
|
+
const currentManifest = await readManifest(payload, slug);
|
|
67
|
+
delete currentManifest[path];
|
|
68
|
+
await payload.updateGlobal({
|
|
69
|
+
slug,
|
|
70
|
+
data: { manifest: currentManifest },
|
|
71
|
+
overrideAccess: true
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
createGlobal(debug = false) {
|
|
75
|
+
return createGlobal(slug, debug);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
payloadGlobalAdapter
|
|
81
|
+
};
|
|
82
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/payloadGlobal/api/fetchManifest.ts","../../../src/adapters/payloadGlobal/api/readManifest.ts","../../../src/adapters/payloadGlobal/utils/createGlobal.ts","../../../src/adapters/payloadGlobal/index.ts"],"sourcesContent":["export async function fetchManifest<TVariantData extends object>(\n serverURL: string,\n apiRoute: string,\n slug: string,\n path: string,\n): Promise<TVariantData[] | null> {\n try {\n const res = await fetch(`${serverURL}${apiRoute}/globals/${slug}`, {\n cache: \"no-store\",\n });\n\n if (!res.ok) return null;\n\n const data = await res.json();\n\n return (data?.manifest?.[path] as TVariantData[]) ?? null;\n } catch {\n return null;\n }\n}\n","import type { GlobalSlug, Payload } from \"payload\";\nimport type { Manifest } from \"../../../types/manifest\";\n\nexport async function readManifest(payload: Payload, slug: string): Promise<Manifest> {\n try {\n const doc = await payload.findGlobal({ slug: slug as GlobalSlug, overrideAccess: true });\n\n return (doc?.manifest as Manifest) ?? {};\n } catch {\n return {};\n }\n}\n","import type { GlobalConfig } from \"payload\";\n\nexport function createGlobal(slug: string, debug: boolean): GlobalConfig {\n return {\n slug,\n access: {\n read: () => true,\n },\n admin: {\n hidden: !debug,\n group: \"System\",\n },\n fields: [\n {\n name: \"manifest\",\n type: \"json\",\n admin: {\n description: \"A/B testing manifest. Managed automatically — do not edit manually.\",\n },\n },\n ],\n };\n}\n","import type { GlobalSlug } from \"payload\";\nimport type { StorageAdapter } from \"../../types/config\";\nimport type { PayloadGlobalAdapterConfig } from \"./config\";\nimport { fetchManifest } from \"./api/fetchManifest\";\nimport { readManifest } from \"./api/readManifest\";\nimport { createGlobal } from \"./utils/createGlobal\";\n\nexport function payloadGlobalAdapter<TVariantData extends object>(\n config?: PayloadGlobalAdapterConfig,\n): StorageAdapter<TVariantData> {\n const slug = config?.globalSlug ?? \"_abManifest\";\n\n return {\n async write(path, variants, payload) {\n const currentManifest = await readManifest(payload, slug);\n\n await payload.updateGlobal({\n slug: slug as GlobalSlug,\n data: { manifest: { ...currentManifest, [path]: variants } },\n overrideAccess: true,\n });\n },\n\n async read(path) {\n const serverURL = config?.serverURL ?? \"\";\n const apiRoute = config?.apiRoute ?? \"/api\";\n\n return fetchManifest<TVariantData>(serverURL, apiRoute, slug, path);\n },\n\n async clear(path, payload) {\n const currentManifest = await readManifest(payload, slug);\n\n delete currentManifest[path];\n\n await payload.updateGlobal({\n slug: slug as GlobalSlug,\n data: { manifest: currentManifest },\n overrideAccess: true,\n });\n },\n\n createGlobal(debug = false) {\n return createGlobal(slug, debug);\n },\n };\n}\n"],"mappings":";AAAA,eAAsB,cACpB,WACA,UACA,MACA,MACgC;AAChC,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,SAAS,GAAG,QAAQ,YAAY,IAAI,IAAI;AAAA,MACjE,OAAO;AAAA,IACT,CAAC;AAED,QAAI,CAAC,IAAI,GAAI,QAAO;AAEpB,UAAM,OAAO,MAAM,IAAI,KAAK;AAE5B,WAAQ,MAAM,WAAW,IAAI,KAAwB;AAAA,EACvD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;AChBA,eAAsB,aAAa,SAAkB,MAAiC;AACpF,MAAI;AACF,UAAM,MAAM,MAAM,QAAQ,WAAW,EAAE,MAA0B,gBAAgB,KAAK,CAAC;AAEvF,WAAQ,KAAK,YAAyB,CAAC;AAAA,EACzC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;;;ACTO,SAAS,aAAa,MAAc,OAA8B;AACvE,SAAO;AAAA,IACL;AAAA,IACA,QAAQ;AAAA,MACN,MAAM,MAAM;AAAA,IACd;AAAA,IACA,OAAO;AAAA,MACL,QAAQ,CAAC;AAAA,MACT,OAAO;AAAA,IACT;AAAA,IACA,QAAQ;AAAA,MACN;AAAA,QACE,MAAM;AAAA,QACN,MAAM;AAAA,QACN,OAAO;AAAA,UACL,aAAa;AAAA,QACf;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;ACfO,SAAS,qBACd,QAC8B;AAC9B,QAAM,OAAO,QAAQ,cAAc;AAEnC,SAAO;AAAA,IACL,MAAM,MAAM,MAAM,UAAU,SAAS;AACnC,YAAM,kBAAkB,MAAM,aAAa,SAAS,IAAI;AAExD,YAAM,QAAQ,aAAa;AAAA,QACzB;AAAA,QACA,MAAM,EAAE,UAAU,EAAE,GAAG,iBAAiB,CAAC,IAAI,GAAG,SAAS,EAAE;AAAA,QAC3D,gBAAgB;AAAA,MAClB,CAAC;AAAA,IACH;AAAA,IAEA,MAAM,KAAK,MAAM;AACf,YAAM,YAAY,QAAQ,aAAa;AACvC,YAAM,WAAW,QAAQ,YAAY;AAErC,aAAO,cAA4B,WAAW,UAAU,MAAM,IAAI;AAAA,IACpE;AAAA,IAEA,MAAM,MAAM,MAAM,SAAS;AACzB,YAAM,kBAAkB,MAAM,aAAa,SAAS,IAAI;AAExD,aAAO,gBAAgB,IAAI;AAE3B,YAAM,QAAQ,aAAa;AAAA,QACzB;AAAA,QACA,MAAM,EAAE,UAAU,gBAAgB;AAAA,QAClC,gBAAgB;AAAA,MAClB,CAAC;AAAA,IACH;AAAA,IAEA,aAAa,QAAQ,OAAO;AAC1B,aAAO,aAAa,MAAM,KAAK;AAAA,IACjC;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { S as StorageAdapter } from '../../config-CRUREAW_.js';
|
|
2
|
+
import 'payload';
|
|
3
|
+
|
|
4
|
+
interface VercelEdgeAdapterConfig {
|
|
5
|
+
configID: string;
|
|
6
|
+
configURL: string;
|
|
7
|
+
vercelRestAPIAccessToken: string;
|
|
8
|
+
teamID?: string;
|
|
9
|
+
/** Top-level key in Edge Config that holds the manifest. Default: 'ab-testing' */
|
|
10
|
+
manifestKey?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Vercel Edge Config adapter.
|
|
15
|
+
* Requires "pnpm add \@vercel/edge-config" and the following env vars:
|
|
16
|
+
* EDGE_CONFIG, EDGE_CONFIG_ID, VERCEL_REST_API_ACCESS_TOKEN
|
|
17
|
+
*/
|
|
18
|
+
declare function vercelEdgeAdapter<TVariantData extends object>(config: VercelEdgeAdapterConfig): StorageAdapter<TVariantData>;
|
|
19
|
+
|
|
20
|
+
export { vercelEdgeAdapter };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/adapters/vercelEdge/api/readManifest.ts
|
|
2
|
+
async function readManifest(manifestKey) {
|
|
3
|
+
try {
|
|
4
|
+
const { get } = await import("@vercel/edge-config");
|
|
5
|
+
const manifest = await get(manifestKey);
|
|
6
|
+
return manifest ?? {};
|
|
7
|
+
} catch {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/adapters/vercelEdge/utils/buildHeaders.ts
|
|
13
|
+
function buildHeaders(config) {
|
|
14
|
+
return {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
Authorization: `Bearer ${config.vercelRestAPIAccessToken}`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/adapters/vercelEdge/api/updateEdgeConfig.ts
|
|
21
|
+
async function updateEdgeConfig(config, manifestKey, value) {
|
|
22
|
+
const teamQuery = config.teamID ? `?teamId=${config.teamID}` : "";
|
|
23
|
+
await fetch(`https://api.vercel.com/v1/edge-config/${config.configID}/items${teamQuery}`, {
|
|
24
|
+
method: "PATCH",
|
|
25
|
+
headers: buildHeaders(config),
|
|
26
|
+
body: JSON.stringify({ items: [{ operation: "upsert", key: manifestKey, value }] })
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/adapters/vercelEdge/index.ts
|
|
31
|
+
function vercelEdgeAdapter(config) {
|
|
32
|
+
const manifestKey = config.manifestKey ?? "ab-testing";
|
|
33
|
+
let localCache = null;
|
|
34
|
+
async function getManifest() {
|
|
35
|
+
if (localCache === null) {
|
|
36
|
+
localCache = await readManifest(manifestKey);
|
|
37
|
+
}
|
|
38
|
+
return localCache;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
async write(path, variants) {
|
|
42
|
+
const currentManifest = await getManifest();
|
|
43
|
+
localCache = { ...currentManifest, [path]: variants };
|
|
44
|
+
await updateEdgeConfig(config, manifestKey, localCache);
|
|
45
|
+
},
|
|
46
|
+
async read(path) {
|
|
47
|
+
try {
|
|
48
|
+
const manifest = await readManifest(manifestKey);
|
|
49
|
+
return manifest?.[path] ?? null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async clear(path) {
|
|
55
|
+
const currentManifest = await getManifest();
|
|
56
|
+
const updated = { ...currentManifest };
|
|
57
|
+
delete updated[path];
|
|
58
|
+
localCache = updated;
|
|
59
|
+
await updateEdgeConfig(config, manifestKey, localCache);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export {
|
|
64
|
+
vercelEdgeAdapter
|
|
65
|
+
};
|
|
66
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/vercelEdge/api/readManifest.ts","../../../src/adapters/vercelEdge/utils/buildHeaders.ts","../../../src/adapters/vercelEdge/api/updateEdgeConfig.ts","../../../src/adapters/vercelEdge/index.ts"],"sourcesContent":["import type { Manifest } from \"../../../types/manifest\";\n\nexport async function readManifest<TVariantData extends object = object>(\n manifestKey: string,\n): Promise<Manifest<TVariantData>> {\n try {\n const { get } = await import(\"@vercel/edge-config\");\n\n const manifest = await get<Manifest<TVariantData>>(manifestKey);\n\n return manifest ?? {};\n } catch {\n return {};\n }\n}\n","import type { VercelEdgeAdapterConfig } from \"../config\";\n\nexport function buildHeaders(config: VercelEdgeAdapterConfig): Record<string, string> {\n return {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${config.vercelRestAPIAccessToken}`,\n };\n}\n","import type { VercelEdgeAdapterConfig } from \"../config\";\nimport { buildHeaders } from \"../utils/buildHeaders\";\n\nexport async function updateEdgeConfig(config: VercelEdgeAdapterConfig, manifestKey: string, value: unknown) {\n const teamQuery = config.teamID ? `?teamId=${config.teamID}` : \"\";\n\n await fetch(`https://api.vercel.com/v1/edge-config/${config.configID}/items${teamQuery}`, {\n method: \"PATCH\",\n headers: buildHeaders(config),\n body: JSON.stringify({ items: [{ operation: \"upsert\", key: manifestKey, value }] }),\n });\n}\n","import type { StorageAdapter } from \"../../types/config\";\nimport type { Manifest } from \"../../types/manifest\";\nimport { readManifest } from \"./api/readManifest\";\nimport { updateEdgeConfig } from \"./api/updateEdgeConfig\";\nimport type { VercelEdgeAdapterConfig } from \"./config\";\n\n/**\n * Vercel Edge Config adapter.\n * Requires \"pnpm add \\@vercel/edge-config\" and the following env vars:\n * EDGE_CONFIG, EDGE_CONFIG_ID, VERCEL_REST_API_ACCESS_TOKEN\n */\nexport function vercelEdgeAdapter<TVariantData extends object>(\n config: VercelEdgeAdapterConfig,\n): StorageAdapter<TVariantData> {\n const manifestKey = config.manifestKey ?? \"ab-testing\";\n let localCache: Manifest<TVariantData> | null = null;\n\n async function getManifest() {\n if (localCache === null) {\n localCache = await readManifest<TVariantData>(manifestKey);\n }\n\n return localCache;\n }\n\n return {\n async write(path, variants) {\n const currentManifest = await getManifest();\n\n localCache = { ...currentManifest, [path]: variants };\n\n await updateEdgeConfig(config, manifestKey, localCache);\n },\n\n async read(path) {\n try {\n const manifest = await readManifest<TVariantData>(manifestKey);\n\n return manifest?.[path] ?? null;\n } catch {\n return null;\n }\n },\n\n async clear(path) {\n const currentManifest = await getManifest();\n const updated = { ...currentManifest };\n\n delete updated[path];\n\n localCache = updated;\n\n await updateEdgeConfig(config, manifestKey, localCache);\n },\n };\n}\n"],"mappings":";AAEA,eAAsB,aACpB,aACiC;AACjC,MAAI;AACF,UAAM,EAAE,IAAI,IAAI,MAAM,OAAO,qBAAqB;AAElD,UAAM,WAAW,MAAM,IAA4B,WAAW;AAE9D,WAAO,YAAY,CAAC;AAAA,EACtB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;;;ACZO,SAAS,aAAa,QAAyD;AACpF,SAAO;AAAA,IACL,gBAAgB;AAAA,IAChB,eAAe,UAAU,OAAO,wBAAwB;AAAA,EAC1D;AACF;;;ACJA,eAAsB,iBAAiB,QAAiC,aAAqB,OAAgB;AAC3G,QAAM,YAAY,OAAO,SAAS,WAAW,OAAO,MAAM,KAAK;AAE/D,QAAM,MAAM,yCAAyC,OAAO,QAAQ,SAAS,SAAS,IAAI;AAAA,IACxF,QAAQ;AAAA,IACR,SAAS,aAAa,MAAM;AAAA,IAC5B,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC,EAAE,WAAW,UAAU,KAAK,aAAa,MAAM,CAAC,EAAE,CAAC;AAAA,EACpF,CAAC;AACH;;;ACAO,SAAS,kBACd,QAC8B;AAC9B,QAAM,cAAc,OAAO,eAAe;AAC1C,MAAI,aAA4C;AAEhD,iBAAe,cAAc;AAC3B,QAAI,eAAe,MAAM;AACvB,mBAAa,MAAM,aAA2B,WAAW;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,MAAM,MAAM,MAAM,UAAU;AAC1B,YAAM,kBAAkB,MAAM,YAAY;AAE1C,mBAAa,EAAE,GAAG,iBAAiB,CAAC,IAAI,GAAG,SAAS;AAEpD,YAAM,iBAAiB,QAAQ,aAAa,UAAU;AAAA,IACxD;AAAA,IAEA,MAAM,KAAK,MAAM;AACf,UAAI;AACF,cAAM,WAAW,MAAM,aAA2B,WAAW;AAE7D,eAAO,WAAW,IAAI,KAAK;AAAA,MAC7B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,MAAM,MAAM;AAChB,YAAM,kBAAkB,MAAM,YAAY;AAC1C,YAAM,UAAU,EAAE,GAAG,gBAAgB;AAErC,aAAO,QAAQ,IAAI;AAEnB,mBAAa;AAEb,YAAM,iBAAiB,QAAQ,aAAa,UAAU;AAAA,IACxD;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Payload, GlobalConfig } from 'payload';
|
|
2
|
+
|
|
3
|
+
interface StorageAdapter<TVariantData extends object = object> {
|
|
4
|
+
/** Write variant data for a path. Called from Payload afterChange hooks. */
|
|
5
|
+
write(path: string, variants: TVariantData[], payload: Payload): Promise<void>;
|
|
6
|
+
/** Read variant data for a path. Called from Next.js middleware — must be Edge-compatible. */
|
|
7
|
+
read(path: string): Promise<TVariantData[] | null>;
|
|
8
|
+
/** Remove all variant data for a path. Called from Payload afterDelete hooks. */
|
|
9
|
+
clear(path: string, payload: Payload): Promise<void>;
|
|
10
|
+
/** Optional: return a Global config to register in Payload (used by payloadGlobalAdapter). */
|
|
11
|
+
createGlobal?(debug: boolean): GlobalConfig;
|
|
12
|
+
}
|
|
13
|
+
interface CollectionABConfig<TVariantData extends object = object> {
|
|
14
|
+
variantCollectionSlug: string;
|
|
15
|
+
generatePath: (args: {
|
|
16
|
+
doc: Record<string, unknown>;
|
|
17
|
+
locale: string;
|
|
18
|
+
}) => string | null;
|
|
19
|
+
/** Generate the data stored per variant in the manifest. */
|
|
20
|
+
generateVariantData: (args: {
|
|
21
|
+
doc: Record<string, unknown>;
|
|
22
|
+
variantDoc: Record<string, unknown>;
|
|
23
|
+
locale: string;
|
|
24
|
+
}) => TVariantData;
|
|
25
|
+
}
|
|
26
|
+
interface AbTestingPluginConfig<TVariantData extends object = object> {
|
|
27
|
+
/** Default: true */
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
/** If true, the manifest global is visible in the Payload admin panel. Default: false */
|
|
30
|
+
debug?: boolean;
|
|
31
|
+
/** Map of parent collection slug => A/B config for that collection. */
|
|
32
|
+
collections: Record<string, CollectionABConfig<TVariantData>>;
|
|
33
|
+
/** Storage adapter instance (payloadGlobalAdapter or vercelEdgeAdapter). */
|
|
34
|
+
storage: StorageAdapter<TVariantData>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type { AbTestingPluginConfig as A, CollectionABConfig as C, StorageAdapter as S };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Plugin } from 'payload';
|
|
2
|
+
import { A as AbTestingPluginConfig } from './config-CRUREAW_.js';
|
|
3
|
+
export { C as CollectionABConfig, S as StorageAdapter } from './config-CRUREAW_.js';
|
|
4
|
+
export { payloadGlobalAdapter } from './adapters/payloadGlobal/index.js';
|
|
5
|
+
export { vercelEdgeAdapter } from './adapters/vercelEdge/index.js';
|
|
6
|
+
|
|
7
|
+
declare const abTestingPlugin: <TVariantData extends object>(pluginConfig: AbTestingPluginConfig<TVariantData>) => Plugin;
|
|
8
|
+
|
|
9
|
+
export { AbTestingPluginConfig, abTestingPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// src/utils/buildVariantToParentCollectionSlugsMap.ts
|
|
2
|
+
function buildVariantToParentCollectionSlugsMap(collections) {
|
|
3
|
+
const variantToParent = /* @__PURE__ */ new Map();
|
|
4
|
+
for (const [parentSlug, abConfig] of Object.entries(collections)) {
|
|
5
|
+
if (abConfig) variantToParent.set(abConfig.variantCollectionSlug, parentSlug);
|
|
6
|
+
}
|
|
7
|
+
return variantToParent;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/utils/resolveId.ts
|
|
11
|
+
var isValueIsBaseDocument = (value) => {
|
|
12
|
+
return "id" in value;
|
|
13
|
+
};
|
|
14
|
+
function resolveId(value) {
|
|
15
|
+
if (!value) return null;
|
|
16
|
+
if (typeof value === "number" || typeof value === "string") return value;
|
|
17
|
+
if (typeof value === "object" && isValueIsBaseDocument(value)) {
|
|
18
|
+
return value.id;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/utils/getLocales.ts
|
|
24
|
+
function getLocales(payload) {
|
|
25
|
+
const localization = payload.config.localization;
|
|
26
|
+
const locales = localization ? localization.locales ?? [] : [];
|
|
27
|
+
return locales.map((l) => typeof l === "string" ? l : l.code ?? String(l));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/hooks/buildAfterChangeHook.ts
|
|
31
|
+
function buildAfterChangeHook(parentCollectionSlug, abConfig, pluginConfig) {
|
|
32
|
+
return async ({ doc, req, previousDoc }) => {
|
|
33
|
+
const { payload } = req;
|
|
34
|
+
if (!payload) return;
|
|
35
|
+
const pageId = resolveId(doc.page);
|
|
36
|
+
if (!pageId) return;
|
|
37
|
+
const locales = getLocales(payload);
|
|
38
|
+
const previousPageId = previousDoc ? resolveId(previousDoc.page) : null;
|
|
39
|
+
if (previousPageId && previousPageId !== pageId) {
|
|
40
|
+
for (const locale of locales) {
|
|
41
|
+
const oldPageDoc = await payload.findByID({
|
|
42
|
+
collection: parentCollectionSlug,
|
|
43
|
+
id: previousPageId,
|
|
44
|
+
depth: 2,
|
|
45
|
+
locale,
|
|
46
|
+
overrideAccess: true,
|
|
47
|
+
req
|
|
48
|
+
});
|
|
49
|
+
if (!oldPageDoc) continue;
|
|
50
|
+
const oldManifestKey = abConfig.generatePath({ doc: oldPageDoc, locale });
|
|
51
|
+
if (!oldManifestKey) continue;
|
|
52
|
+
const remainingOldVariants = await payload.find({
|
|
53
|
+
collection: abConfig.variantCollectionSlug,
|
|
54
|
+
where: {
|
|
55
|
+
and: [{ page: { equals: previousPageId } }, { id: { not_equals: doc.id } }]
|
|
56
|
+
},
|
|
57
|
+
depth: 2,
|
|
58
|
+
locale,
|
|
59
|
+
overrideAccess: true,
|
|
60
|
+
limit: 100,
|
|
61
|
+
req
|
|
62
|
+
});
|
|
63
|
+
if (remainingOldVariants.docs.length === 0) {
|
|
64
|
+
await pluginConfig.storage.clear(oldManifestKey, payload);
|
|
65
|
+
} else {
|
|
66
|
+
const oldVariantData = remainingOldVariants.docs.map(
|
|
67
|
+
(variantDoc) => abConfig.generateVariantData({ doc: oldPageDoc, variantDoc, locale })
|
|
68
|
+
);
|
|
69
|
+
await pluginConfig.storage.write(oldManifestKey, oldVariantData, payload);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const locale of locales) {
|
|
74
|
+
const pageDoc = await payload.findByID({
|
|
75
|
+
collection: parentCollectionSlug,
|
|
76
|
+
id: pageId,
|
|
77
|
+
depth: 2,
|
|
78
|
+
locale,
|
|
79
|
+
overrideAccess: true,
|
|
80
|
+
req
|
|
81
|
+
});
|
|
82
|
+
if (!pageDoc) continue;
|
|
83
|
+
const manifestKey = abConfig.generatePath({ doc: pageDoc, locale });
|
|
84
|
+
if (!manifestKey) continue;
|
|
85
|
+
const allVariants = await payload.find({
|
|
86
|
+
collection: abConfig.variantCollectionSlug,
|
|
87
|
+
where: { page: { equals: pageId } },
|
|
88
|
+
depth: 2,
|
|
89
|
+
locale,
|
|
90
|
+
overrideAccess: true,
|
|
91
|
+
limit: 100,
|
|
92
|
+
req
|
|
93
|
+
});
|
|
94
|
+
const variantData = allVariants.docs.map(
|
|
95
|
+
(variantDoc) => abConfig.generateVariantData({ doc: pageDoc, variantDoc, locale })
|
|
96
|
+
);
|
|
97
|
+
await pluginConfig.storage.write(manifestKey, variantData, payload);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// src/hooks/buildAfterDeleteHook.ts
|
|
103
|
+
function buildAfterDeleteHook(parentCollectionSlug, abConfig, pluginConfig) {
|
|
104
|
+
return async ({ doc, req, id }) => {
|
|
105
|
+
const { payload } = req;
|
|
106
|
+
if (!payload) return;
|
|
107
|
+
const pageId = resolveId(doc.page);
|
|
108
|
+
if (!pageId) return;
|
|
109
|
+
const locales = getLocales(payload);
|
|
110
|
+
for (const locale of locales) {
|
|
111
|
+
const pageDoc = await payload.findByID({
|
|
112
|
+
collection: parentCollectionSlug,
|
|
113
|
+
id: pageId,
|
|
114
|
+
depth: 2,
|
|
115
|
+
locale,
|
|
116
|
+
overrideAccess: true,
|
|
117
|
+
req
|
|
118
|
+
});
|
|
119
|
+
if (!pageDoc) continue;
|
|
120
|
+
const manifestKey = abConfig.generatePath({ doc: pageDoc, locale });
|
|
121
|
+
if (!manifestKey) continue;
|
|
122
|
+
const remainingVariants = await payload.find({
|
|
123
|
+
collection: abConfig.variantCollectionSlug,
|
|
124
|
+
where: {
|
|
125
|
+
and: [{ page: { equals: pageId } }, { id: { not_equals: id } }]
|
|
126
|
+
},
|
|
127
|
+
depth: 2,
|
|
128
|
+
locale,
|
|
129
|
+
overrideAccess: true,
|
|
130
|
+
limit: 100,
|
|
131
|
+
req
|
|
132
|
+
});
|
|
133
|
+
if (remainingVariants.docs.length === 0) {
|
|
134
|
+
await pluginConfig.storage.clear(manifestKey, payload);
|
|
135
|
+
} else {
|
|
136
|
+
const variantData = remainingVariants.docs.map(
|
|
137
|
+
(variantDoc) => abConfig.generateVariantData({ doc: pageDoc, variantDoc, locale })
|
|
138
|
+
);
|
|
139
|
+
await pluginConfig.storage.write(manifestKey, variantData, payload);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/utils/addHooksToVariantCollections.ts
|
|
146
|
+
function addHooksToVariantCollections(config, pluginConfig, variantToParent) {
|
|
147
|
+
const patchedCollections = (config.collections ?? []).map((collection) => {
|
|
148
|
+
const parentSlug = variantToParent.get(collection.slug);
|
|
149
|
+
if (!parentSlug) return collection;
|
|
150
|
+
const abConfig = pluginConfig.collections[parentSlug];
|
|
151
|
+
if (!abConfig) return collection;
|
|
152
|
+
return {
|
|
153
|
+
...collection,
|
|
154
|
+
hooks: {
|
|
155
|
+
...collection.hooks,
|
|
156
|
+
afterChange: [
|
|
157
|
+
...collection.hooks?.afterChange ?? [],
|
|
158
|
+
buildAfterChangeHook(parentSlug, abConfig, pluginConfig)
|
|
159
|
+
],
|
|
160
|
+
afterDelete: [
|
|
161
|
+
...collection.hooks?.afterDelete ?? [],
|
|
162
|
+
buildAfterDeleteHook(parentSlug, abConfig, pluginConfig)
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
return patchedCollections;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/plugin.ts
|
|
171
|
+
var abTestingPlugin = (pluginConfig) => (incomingConfig) => {
|
|
172
|
+
const { enabled = true, debug = false, collections, storage } = pluginConfig;
|
|
173
|
+
if (!enabled) return incomingConfig;
|
|
174
|
+
const extraGlobals = storage.createGlobal ? [storage.createGlobal(debug)] : [];
|
|
175
|
+
const variantToParent = buildVariantToParentCollectionSlugsMap(collections);
|
|
176
|
+
const patchedCollections = addHooksToVariantCollections(
|
|
177
|
+
incomingConfig,
|
|
178
|
+
pluginConfig,
|
|
179
|
+
variantToParent
|
|
180
|
+
);
|
|
181
|
+
return {
|
|
182
|
+
...incomingConfig,
|
|
183
|
+
collections: patchedCollections,
|
|
184
|
+
globals: [...incomingConfig.globals ?? [], ...extraGlobals]
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/adapters/payloadGlobal/api/fetchManifest.ts
|
|
189
|
+
async function fetchManifest(serverURL, apiRoute, slug, path) {
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`${serverURL}${apiRoute}/globals/${slug}`, {
|
|
192
|
+
cache: "no-store"
|
|
193
|
+
});
|
|
194
|
+
if (!res.ok) return null;
|
|
195
|
+
const data = await res.json();
|
|
196
|
+
return data?.manifest?.[path] ?? null;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// src/adapters/payloadGlobal/api/readManifest.ts
|
|
203
|
+
async function readManifest(payload, slug) {
|
|
204
|
+
try {
|
|
205
|
+
const doc = await payload.findGlobal({ slug, overrideAccess: true });
|
|
206
|
+
return doc?.manifest ?? {};
|
|
207
|
+
} catch {
|
|
208
|
+
return {};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// src/adapters/payloadGlobal/utils/createGlobal.ts
|
|
213
|
+
function createGlobal(slug, debug) {
|
|
214
|
+
return {
|
|
215
|
+
slug,
|
|
216
|
+
access: {
|
|
217
|
+
read: () => true
|
|
218
|
+
},
|
|
219
|
+
admin: {
|
|
220
|
+
hidden: !debug,
|
|
221
|
+
group: "System"
|
|
222
|
+
},
|
|
223
|
+
fields: [
|
|
224
|
+
{
|
|
225
|
+
name: "manifest",
|
|
226
|
+
type: "json",
|
|
227
|
+
admin: {
|
|
228
|
+
description: "A/B testing manifest. Managed automatically \u2014 do not edit manually."
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
]
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/adapters/payloadGlobal/index.ts
|
|
236
|
+
function payloadGlobalAdapter(config) {
|
|
237
|
+
const slug = config?.globalSlug ?? "_abManifest";
|
|
238
|
+
return {
|
|
239
|
+
async write(path, variants, payload) {
|
|
240
|
+
const currentManifest = await readManifest(payload, slug);
|
|
241
|
+
await payload.updateGlobal({
|
|
242
|
+
slug,
|
|
243
|
+
data: { manifest: { ...currentManifest, [path]: variants } },
|
|
244
|
+
overrideAccess: true
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
async read(path) {
|
|
248
|
+
const serverURL = config?.serverURL ?? "";
|
|
249
|
+
const apiRoute = config?.apiRoute ?? "/api";
|
|
250
|
+
return fetchManifest(serverURL, apiRoute, slug, path);
|
|
251
|
+
},
|
|
252
|
+
async clear(path, payload) {
|
|
253
|
+
const currentManifest = await readManifest(payload, slug);
|
|
254
|
+
delete currentManifest[path];
|
|
255
|
+
await payload.updateGlobal({
|
|
256
|
+
slug,
|
|
257
|
+
data: { manifest: currentManifest },
|
|
258
|
+
overrideAccess: true
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
createGlobal(debug = false) {
|
|
262
|
+
return createGlobal(slug, debug);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/adapters/vercelEdge/api/readManifest.ts
|
|
268
|
+
async function readManifest2(manifestKey) {
|
|
269
|
+
try {
|
|
270
|
+
const { get } = await import("@vercel/edge-config");
|
|
271
|
+
const manifest = await get(manifestKey);
|
|
272
|
+
return manifest ?? {};
|
|
273
|
+
} catch {
|
|
274
|
+
return {};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/adapters/vercelEdge/utils/buildHeaders.ts
|
|
279
|
+
function buildHeaders(config) {
|
|
280
|
+
return {
|
|
281
|
+
"Content-Type": "application/json",
|
|
282
|
+
Authorization: `Bearer ${config.vercelRestAPIAccessToken}`
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/adapters/vercelEdge/api/updateEdgeConfig.ts
|
|
287
|
+
async function updateEdgeConfig(config, manifestKey, value) {
|
|
288
|
+
const teamQuery = config.teamID ? `?teamId=${config.teamID}` : "";
|
|
289
|
+
await fetch(`https://api.vercel.com/v1/edge-config/${config.configID}/items${teamQuery}`, {
|
|
290
|
+
method: "PATCH",
|
|
291
|
+
headers: buildHeaders(config),
|
|
292
|
+
body: JSON.stringify({ items: [{ operation: "upsert", key: manifestKey, value }] })
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/adapters/vercelEdge/index.ts
|
|
297
|
+
function vercelEdgeAdapter(config) {
|
|
298
|
+
const manifestKey = config.manifestKey ?? "ab-testing";
|
|
299
|
+
let localCache = null;
|
|
300
|
+
async function getManifest() {
|
|
301
|
+
if (localCache === null) {
|
|
302
|
+
localCache = await readManifest2(manifestKey);
|
|
303
|
+
}
|
|
304
|
+
return localCache;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
async write(path, variants) {
|
|
308
|
+
const currentManifest = await getManifest();
|
|
309
|
+
localCache = { ...currentManifest, [path]: variants };
|
|
310
|
+
await updateEdgeConfig(config, manifestKey, localCache);
|
|
311
|
+
},
|
|
312
|
+
async read(path) {
|
|
313
|
+
try {
|
|
314
|
+
const manifest = await readManifest2(manifestKey);
|
|
315
|
+
return manifest?.[path] ?? null;
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
async clear(path) {
|
|
321
|
+
const currentManifest = await getManifest();
|
|
322
|
+
const updated = { ...currentManifest };
|
|
323
|
+
delete updated[path];
|
|
324
|
+
localCache = updated;
|
|
325
|
+
await updateEdgeConfig(config, manifestKey, localCache);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
export {
|
|
330
|
+
abTestingPlugin,
|
|
331
|
+
payloadGlobalAdapter,
|
|
332
|
+
vercelEdgeAdapter
|
|
333
|
+
};
|
|
334
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/utils/buildVariantToParentCollectionSlugsMap.ts","../src/utils/resolveId.ts","../src/utils/getLocales.ts","../src/hooks/buildAfterChangeHook.ts","../src/hooks/buildAfterDeleteHook.ts","../src/utils/addHooksToVariantCollections.ts","../src/plugin.ts","../src/adapters/payloadGlobal/api/fetchManifest.ts","../src/adapters/payloadGlobal/api/readManifest.ts","../src/adapters/payloadGlobal/utils/createGlobal.ts","../src/adapters/payloadGlobal/index.ts","../src/adapters/vercelEdge/api/readManifest.ts","../src/adapters/vercelEdge/utils/buildHeaders.ts","../src/adapters/vercelEdge/api/updateEdgeConfig.ts","../src/adapters/vercelEdge/index.ts"],"sourcesContent":["import type { CollectionABConfig } from \"../types/config\";\n\nexport function buildVariantToParentCollectionSlugsMap<TVariantData extends object>(\n collections: Record<string, CollectionABConfig<TVariantData>>,\n) {\n const variantToParent = new Map<string, string>();\n\n for (const [parentSlug, abConfig] of Object.entries(collections)) {\n if (abConfig) variantToParent.set(abConfig.variantCollectionSlug, parentSlug);\n }\n\n return variantToParent;\n}\n","interface BaseDocument {\n id: number | string;\n}\n\nconst isValueIsBaseDocument = (value: object): value is BaseDocument => {\n return \"id\" in value;\n};\n\nexport function resolveId(value: unknown) {\n if (!value) return null;\n\n if (typeof value === \"number\" || typeof value === \"string\") return value;\n\n if (typeof value === \"object\" && isValueIsBaseDocument(value)) {\n return value.id;\n }\n\n return null;\n}\n","import type { Payload } from \"payload\";\n\nexport function getLocales(payload: Payload): string[] {\n const localization = payload.config.localization;\n\n const locales = localization ? (localization.locales ?? []) : [];\n\n return locales.map((l) => (typeof l === \"string\" ? l : (l.code ?? String(l))));\n}\n","import type { CollectionAfterChangeHook, CollectionSlug, TypedLocale } from \"payload\";\nimport type { AbTestingPluginConfig, CollectionABConfig } from \"../types/config\";\nimport { resolveId } from \"../utils/resolveId\";\nimport { getLocales } from \"../utils/getLocales\";\n\nexport function buildAfterChangeHook<TVariantData extends object>(\n parentCollectionSlug: string,\n abConfig: CollectionABConfig<TVariantData>,\n pluginConfig: AbTestingPluginConfig<TVariantData>,\n): CollectionAfterChangeHook {\n return async ({ doc, req, previousDoc }) => {\n const { payload } = req;\n if (!payload) return;\n\n const pageId = resolveId(doc.page);\n if (!pageId) return;\n\n const locales = getLocales(payload);\n\n const previousPageId = previousDoc ? resolveId(previousDoc.page) : null;\n if (previousPageId && previousPageId !== pageId) {\n for (const locale of locales) {\n const oldPageDoc = await payload.findByID({\n collection: parentCollectionSlug as CollectionSlug,\n id: previousPageId,\n depth: 2,\n locale: locale as TypedLocale,\n overrideAccess: true,\n req,\n });\n if (!oldPageDoc) continue;\n\n const oldManifestKey = abConfig.generatePath({ doc: oldPageDoc, locale });\n if (!oldManifestKey) continue;\n\n const remainingOldVariants = await payload.find({\n collection: abConfig.variantCollectionSlug as CollectionSlug,\n where: {\n and: [{ page: { equals: previousPageId } }, { id: { not_equals: doc.id } }],\n },\n depth: 2,\n locale: locale as TypedLocale,\n overrideAccess: true,\n limit: 100,\n req,\n });\n\n if (remainingOldVariants.docs.length === 0) {\n await pluginConfig.storage.clear(oldManifestKey, payload);\n } else {\n const oldVariantData = remainingOldVariants.docs.map((variantDoc) =>\n abConfig.generateVariantData({ doc: oldPageDoc, variantDoc, locale }),\n );\n await pluginConfig.storage.write(oldManifestKey, oldVariantData, payload);\n }\n }\n }\n\n for (const locale of locales) {\n const pageDoc = await payload.findByID({\n collection: parentCollectionSlug as CollectionSlug,\n id: pageId,\n depth: 2,\n locale: locale as TypedLocale,\n overrideAccess: true,\n req,\n });\n if (!pageDoc) continue;\n\n const manifestKey = abConfig.generatePath({ doc: pageDoc, locale });\n if (!manifestKey) continue;\n\n const allVariants = await payload.find({\n collection: abConfig.variantCollectionSlug as CollectionSlug,\n where: { page: { equals: pageId } },\n depth: 2,\n locale: locale as TypedLocale,\n overrideAccess: true,\n limit: 100,\n req,\n });\n\n const variantData = allVariants.docs.map((variantDoc) =>\n abConfig.generateVariantData({ doc: pageDoc, variantDoc, locale }),\n );\n\n await pluginConfig.storage.write(manifestKey, variantData, payload);\n }\n };\n}\n","import type { CollectionAfterDeleteHook, CollectionSlug, TypedLocale } from \"payload\";\nimport type { AbTestingPluginConfig, CollectionABConfig } from \"../types/config\";\nimport { resolveId } from \"../utils/resolveId\";\nimport { getLocales } from \"../utils/getLocales\";\n\nexport function buildAfterDeleteHook<TVariantData extends object>(\n parentCollectionSlug: string,\n abConfig: CollectionABConfig<TVariantData>,\n pluginConfig: AbTestingPluginConfig<TVariantData>,\n): CollectionAfterDeleteHook {\n return async ({ doc, req, id }) => {\n const { payload } = req;\n if (!payload) return;\n\n const pageId = resolveId(doc.page);\n if (!pageId) return;\n\n const locales = getLocales(payload);\n\n for (const locale of locales) {\n const pageDoc = await payload.findByID({\n collection: parentCollectionSlug as CollectionSlug,\n id: pageId,\n depth: 2,\n locale: locale as TypedLocale,\n overrideAccess: true,\n req,\n });\n if (!pageDoc) continue;\n\n const manifestKey = abConfig.generatePath({ doc: pageDoc, locale });\n if (!manifestKey) continue;\n\n const remainingVariants = await payload.find({\n collection: abConfig.variantCollectionSlug as CollectionSlug,\n where: {\n and: [{ page: { equals: pageId } }, { id: { not_equals: id } }],\n },\n depth: 2,\n locale: locale as TypedLocale,\n overrideAccess: true,\n limit: 100,\n req,\n });\n\n if (remainingVariants.docs.length === 0) {\n await pluginConfig.storage.clear(manifestKey, payload);\n } else {\n const variantData = remainingVariants.docs.map((variantDoc) =>\n abConfig.generateVariantData({ doc: pageDoc, variantDoc, locale }),\n );\n\n await pluginConfig.storage.write(manifestKey, variantData, payload);\n }\n }\n };\n}\n","import type { CollectionConfig, Config } from \"payload\";\nimport type { AbTestingPluginConfig } from \"../types/config\";\nimport { buildAfterChangeHook } from \"../hooks/buildAfterChangeHook\";\nimport { buildAfterDeleteHook } from \"../hooks/buildAfterDeleteHook\";\n\nexport function addHooksToVariantCollections<TVariantData extends object>(\n config: Config,\n pluginConfig: AbTestingPluginConfig<TVariantData>,\n variantToParent: Map<string, string>,\n) {\n const patchedCollections = (config.collections ?? []).map((collection) => {\n const parentSlug = variantToParent.get(collection.slug);\n if (!parentSlug) return collection;\n\n const abConfig = pluginConfig.collections[parentSlug];\n if (!abConfig) return collection;\n\n return {\n ...collection,\n hooks: {\n ...collection.hooks,\n afterChange: [\n ...(collection.hooks?.afterChange ?? []),\n buildAfterChangeHook(parentSlug, abConfig, pluginConfig),\n ],\n afterDelete: [\n ...(collection.hooks?.afterDelete ?? []),\n buildAfterDeleteHook(parentSlug, abConfig, pluginConfig),\n ],\n },\n } as CollectionConfig;\n });\n\n return patchedCollections;\n}\n","import type { Config, Plugin } from \"payload\";\nimport type { AbTestingPluginConfig } from \"./types/config\";\nimport { buildVariantToParentCollectionSlugsMap } from \"./utils/buildVariantToParentCollectionSlugsMap\";\nimport { addHooksToVariantCollections } from \"./utils/addHooksToVariantCollections\";\n\nexport const abTestingPlugin =\n <TVariantData extends object>(pluginConfig: AbTestingPluginConfig<TVariantData>): Plugin =>\n (incomingConfig: Config): Config => {\n const { enabled = true, debug = false, collections, storage } = pluginConfig;\n\n if (!enabled) return incomingConfig;\n\n const extraGlobals = storage.createGlobal ? [storage.createGlobal(debug)] : [];\n\n const variantToParent = buildVariantToParentCollectionSlugsMap<TVariantData>(collections);\n\n const patchedCollections = addHooksToVariantCollections<TVariantData>(\n incomingConfig,\n pluginConfig,\n variantToParent,\n );\n\n return {\n ...incomingConfig,\n collections: patchedCollections,\n globals: [...(incomingConfig.globals ?? []), ...extraGlobals],\n };\n };\n","export async function fetchManifest<TVariantData extends object>(\n serverURL: string,\n apiRoute: string,\n slug: string,\n path: string,\n): Promise<TVariantData[] | null> {\n try {\n const res = await fetch(`${serverURL}${apiRoute}/globals/${slug}`, {\n cache: \"no-store\",\n });\n\n if (!res.ok) return null;\n\n const data = await res.json();\n\n return (data?.manifest?.[path] as TVariantData[]) ?? null;\n } catch {\n return null;\n }\n}\n","import type { GlobalSlug, Payload } from \"payload\";\nimport type { Manifest } from \"../../../types/manifest\";\n\nexport async function readManifest(payload: Payload, slug: string): Promise<Manifest> {\n try {\n const doc = await payload.findGlobal({ slug: slug as GlobalSlug, overrideAccess: true });\n\n return (doc?.manifest as Manifest) ?? {};\n } catch {\n return {};\n }\n}\n","import type { GlobalConfig } from \"payload\";\n\nexport function createGlobal(slug: string, debug: boolean): GlobalConfig {\n return {\n slug,\n access: {\n read: () => true,\n },\n admin: {\n hidden: !debug,\n group: \"System\",\n },\n fields: [\n {\n name: \"manifest\",\n type: \"json\",\n admin: {\n description: \"A/B testing manifest. Managed automatically — do not edit manually.\",\n },\n },\n ],\n };\n}\n","import type { GlobalSlug } from \"payload\";\nimport type { StorageAdapter } from \"../../types/config\";\nimport type { PayloadGlobalAdapterConfig } from \"./config\";\nimport { fetchManifest } from \"./api/fetchManifest\";\nimport { readManifest } from \"./api/readManifest\";\nimport { createGlobal } from \"./utils/createGlobal\";\n\nexport function payloadGlobalAdapter<TVariantData extends object>(\n config?: PayloadGlobalAdapterConfig,\n): StorageAdapter<TVariantData> {\n const slug = config?.globalSlug ?? \"_abManifest\";\n\n return {\n async write(path, variants, payload) {\n const currentManifest = await readManifest(payload, slug);\n\n await payload.updateGlobal({\n slug: slug as GlobalSlug,\n data: { manifest: { ...currentManifest, [path]: variants } },\n overrideAccess: true,\n });\n },\n\n async read(path) {\n const serverURL = config?.serverURL ?? \"\";\n const apiRoute = config?.apiRoute ?? \"/api\";\n\n return fetchManifest<TVariantData>(serverURL, apiRoute, slug, path);\n },\n\n async clear(path, payload) {\n const currentManifest = await readManifest(payload, slug);\n\n delete currentManifest[path];\n\n await payload.updateGlobal({\n slug: slug as GlobalSlug,\n data: { manifest: currentManifest },\n overrideAccess: true,\n });\n },\n\n createGlobal(debug = false) {\n return createGlobal(slug, debug);\n },\n };\n}\n","import type { Manifest } from \"../../../types/manifest\";\n\nexport async function readManifest<TVariantData extends object = object>(\n manifestKey: string,\n): Promise<Manifest<TVariantData>> {\n try {\n const { get } = await import(\"@vercel/edge-config\");\n\n const manifest = await get<Manifest<TVariantData>>(manifestKey);\n\n return manifest ?? {};\n } catch {\n return {};\n }\n}\n","import type { VercelEdgeAdapterConfig } from \"../config\";\n\nexport function buildHeaders(config: VercelEdgeAdapterConfig): Record<string, string> {\n return {\n \"Content-Type\": \"application/json\",\n Authorization: `Bearer ${config.vercelRestAPIAccessToken}`,\n };\n}\n","import type { VercelEdgeAdapterConfig } from \"../config\";\nimport { buildHeaders } from \"../utils/buildHeaders\";\n\nexport async function updateEdgeConfig(config: VercelEdgeAdapterConfig, manifestKey: string, value: unknown) {\n const teamQuery = config.teamID ? `?teamId=${config.teamID}` : \"\";\n\n await fetch(`https://api.vercel.com/v1/edge-config/${config.configID}/items${teamQuery}`, {\n method: \"PATCH\",\n headers: buildHeaders(config),\n body: JSON.stringify({ items: [{ operation: \"upsert\", key: manifestKey, value }] }),\n });\n}\n","import type { StorageAdapter } from \"../../types/config\";\nimport type { Manifest } from \"../../types/manifest\";\nimport { readManifest } from \"./api/readManifest\";\nimport { updateEdgeConfig } from \"./api/updateEdgeConfig\";\nimport type { VercelEdgeAdapterConfig } from \"./config\";\n\n/**\n * Vercel Edge Config adapter.\n * Requires \"pnpm add \\@vercel/edge-config\" and the following env vars:\n * EDGE_CONFIG, EDGE_CONFIG_ID, VERCEL_REST_API_ACCESS_TOKEN\n */\nexport function vercelEdgeAdapter<TVariantData extends object>(\n config: VercelEdgeAdapterConfig,\n): StorageAdapter<TVariantData> {\n const manifestKey = config.manifestKey ?? \"ab-testing\";\n let localCache: Manifest<TVariantData> | null = null;\n\n async function getManifest() {\n if (localCache === null) {\n localCache = await readManifest<TVariantData>(manifestKey);\n }\n\n return localCache;\n }\n\n return {\n async write(path, variants) {\n const currentManifest = await getManifest();\n\n localCache = { ...currentManifest, [path]: variants };\n\n await updateEdgeConfig(config, manifestKey, localCache);\n },\n\n async read(path) {\n try {\n const manifest = await readManifest<TVariantData>(manifestKey);\n\n return manifest?.[path] ?? null;\n } catch {\n return null;\n }\n },\n\n async clear(path) {\n const currentManifest = await getManifest();\n const updated = { ...currentManifest };\n\n delete updated[path];\n\n localCache = updated;\n\n await updateEdgeConfig(config, manifestKey, localCache);\n },\n };\n}\n"],"mappings":";AAEO,SAAS,uCACd,aACA;AACA,QAAM,kBAAkB,oBAAI,IAAoB;AAEhD,aAAW,CAAC,YAAY,QAAQ,KAAK,OAAO,QAAQ,WAAW,GAAG;AAChE,QAAI,SAAU,iBAAgB,IAAI,SAAS,uBAAuB,UAAU;AAAA,EAC9E;AAEA,SAAO;AACT;;;ACRA,IAAM,wBAAwB,CAAC,UAAyC;AACtE,SAAO,QAAQ;AACjB;AAEO,SAAS,UAAU,OAAgB;AACxC,MAAI,CAAC,MAAO,QAAO;AAEnB,MAAI,OAAO,UAAU,YAAY,OAAO,UAAU,SAAU,QAAO;AAEnE,MAAI,OAAO,UAAU,YAAY,sBAAsB,KAAK,GAAG;AAC7D,WAAO,MAAM;AAAA,EACf;AAEA,SAAO;AACT;;;AChBO,SAAS,WAAW,SAA4B;AACrD,QAAM,eAAe,QAAQ,OAAO;AAEpC,QAAM,UAAU,eAAgB,aAAa,WAAW,CAAC,IAAK,CAAC;AAE/D,SAAO,QAAQ,IAAI,CAAC,MAAO,OAAO,MAAM,WAAW,IAAK,EAAE,QAAQ,OAAO,CAAC,CAAG;AAC/E;;;ACHO,SAAS,qBACd,sBACA,UACA,cAC2B;AAC3B,SAAO,OAAO,EAAE,KAAK,KAAK,YAAY,MAAM;AAC1C,UAAM,EAAE,QAAQ,IAAI;AACpB,QAAI,CAAC,QAAS;AAEd,UAAM,SAAS,UAAU,IAAI,IAAI;AACjC,QAAI,CAAC,OAAQ;AAEb,UAAM,UAAU,WAAW,OAAO;AAElC,UAAM,iBAAiB,cAAc,UAAU,YAAY,IAAI,IAAI;AACnE,QAAI,kBAAkB,mBAAmB,QAAQ;AAC/C,iBAAW,UAAU,SAAS;AAC5B,cAAM,aAAa,MAAM,QAAQ,SAAS;AAAA,UACxC,YAAY;AAAA,UACZ,IAAI;AAAA,UACJ,OAAO;AAAA,UACP;AAAA,UACA,gBAAgB;AAAA,UAChB;AAAA,QACF,CAAC;AACD,YAAI,CAAC,WAAY;AAEjB,cAAM,iBAAiB,SAAS,aAAa,EAAE,KAAK,YAAY,OAAO,CAAC;AACxE,YAAI,CAAC,eAAgB;AAErB,cAAM,uBAAuB,MAAM,QAAQ,KAAK;AAAA,UAC9C,YAAY,SAAS;AAAA,UACrB,OAAO;AAAA,YACL,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,eAAe,EAAE,GAAG,EAAE,IAAI,EAAE,YAAY,IAAI,GAAG,EAAE,CAAC;AAAA,UAC5E;AAAA,UACA,OAAO;AAAA,UACP;AAAA,UACA,gBAAgB;AAAA,UAChB,OAAO;AAAA,UACP;AAAA,QACF,CAAC;AAED,YAAI,qBAAqB,KAAK,WAAW,GAAG;AAC1C,gBAAM,aAAa,QAAQ,MAAM,gBAAgB,OAAO;AAAA,QAC1D,OAAO;AACL,gBAAM,iBAAiB,qBAAqB,KAAK;AAAA,YAAI,CAAC,eACpD,SAAS,oBAAoB,EAAE,KAAK,YAAY,YAAY,OAAO,CAAC;AAAA,UACtE;AACA,gBAAM,aAAa,QAAQ,MAAM,gBAAgB,gBAAgB,OAAO;AAAA,QAC1E;AAAA,MACF;AAAA,IACF;AAEA,eAAW,UAAU,SAAS;AAC5B,YAAM,UAAU,MAAM,QAAQ,SAAS;AAAA,QACrC,YAAY;AAAA,QACZ,IAAI;AAAA,QACJ,OAAO;AAAA,QACP;AAAA,QACA,gBAAgB;AAAA,QAChB;AAAA,MACF,CAAC;AACD,UAAI,CAAC,QAAS;AAEd,YAAM,cAAc,SAAS,aAAa,EAAE,KAAK,SAAS,OAAO,CAAC;AAClE,UAAI,CAAC,YAAa;AAElB,YAAM,cAAc,MAAM,QAAQ,KAAK;AAAA,QACrC,YAAY,SAAS;AAAA,QACrB,OAAO,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE;AAAA,QAClC,OAAO;AAAA,QACP;AAAA,QACA,gBAAgB;AAAA,QAChB,OAAO;AAAA,QACP;AAAA,MACF,CAAC;AAED,YAAM,cAAc,YAAY,KAAK;AAAA,QAAI,CAAC,eACxC,SAAS,oBAAoB,EAAE,KAAK,SAAS,YAAY,OAAO,CAAC;AAAA,MACnE;AAEA,YAAM,aAAa,QAAQ,MAAM,aAAa,aAAa,OAAO;AAAA,IACpE;AAAA,EACF;AACF;;;ACpFO,SAAS,qBACd,sBACA,UACA,cAC2B;AAC3B,SAAO,OAAO,EAAE,KAAK,KAAK,GAAG,MAAM;AACjC,UAAM,EAAE,QAAQ,IAAI;AACpB,QAAI,CAAC,QAAS;AAEd,UAAM,SAAS,UAAU,IAAI,IAAI;AACjC,QAAI,CAAC,OAAQ;AAEb,UAAM,UAAU,WAAW,OAAO;AAElC,eAAW,UAAU,SAAS;AAC5B,YAAM,UAAU,MAAM,QAAQ,SAAS;AAAA,QACrC,YAAY;AAAA,QACZ,IAAI;AAAA,QACJ,OAAO;AAAA,QACP;AAAA,QACA,gBAAgB;AAAA,QAChB;AAAA,MACF,CAAC;AACD,UAAI,CAAC,QAAS;AAEd,YAAM,cAAc,SAAS,aAAa,EAAE,KAAK,SAAS,OAAO,CAAC;AAClE,UAAI,CAAC,YAAa;AAElB,YAAM,oBAAoB,MAAM,QAAQ,KAAK;AAAA,QAC3C,YAAY,SAAS;AAAA,QACrB,OAAO;AAAA,UACL,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,YAAY,GAAG,EAAE,CAAC;AAAA,QAChE;AAAA,QACA,OAAO;AAAA,QACP;AAAA,QACA,gBAAgB;AAAA,QAChB,OAAO;AAAA,QACP;AAAA,MACF,CAAC;AAED,UAAI,kBAAkB,KAAK,WAAW,GAAG;AACvC,cAAM,aAAa,QAAQ,MAAM,aAAa,OAAO;AAAA,MACvD,OAAO;AACL,cAAM,cAAc,kBAAkB,KAAK;AAAA,UAAI,CAAC,eAC9C,SAAS,oBAAoB,EAAE,KAAK,SAAS,YAAY,OAAO,CAAC;AAAA,QACnE;AAEA,cAAM,aAAa,QAAQ,MAAM,aAAa,aAAa,OAAO;AAAA,MACpE;AAAA,IACF;AAAA,EACF;AACF;;;ACnDO,SAAS,6BACd,QACA,cACA,iBACA;AACA,QAAM,sBAAsB,OAAO,eAAe,CAAC,GAAG,IAAI,CAAC,eAAe;AACxE,UAAM,aAAa,gBAAgB,IAAI,WAAW,IAAI;AACtD,QAAI,CAAC,WAAY,QAAO;AAExB,UAAM,WAAW,aAAa,YAAY,UAAU;AACpD,QAAI,CAAC,SAAU,QAAO;AAEtB,WAAO;AAAA,MACL,GAAG;AAAA,MACH,OAAO;AAAA,QACL,GAAG,WAAW;AAAA,QACd,aAAa;AAAA,UACX,GAAI,WAAW,OAAO,eAAe,CAAC;AAAA,UACtC,qBAAqB,YAAY,UAAU,YAAY;AAAA,QACzD;AAAA,QACA,aAAa;AAAA,UACX,GAAI,WAAW,OAAO,eAAe,CAAC;AAAA,UACtC,qBAAqB,YAAY,UAAU,YAAY;AAAA,QACzD;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AAED,SAAO;AACT;;;AC7BO,IAAM,kBACX,CAA8B,iBAC9B,CAAC,mBAAmC;AAClC,QAAM,EAAE,UAAU,MAAM,QAAQ,OAAO,aAAa,QAAQ,IAAI;AAEhE,MAAI,CAAC,QAAS,QAAO;AAErB,QAAM,eAAe,QAAQ,eAAe,CAAC,QAAQ,aAAa,KAAK,CAAC,IAAI,CAAC;AAE7E,QAAM,kBAAkB,uCAAqD,WAAW;AAExF,QAAM,qBAAqB;AAAA,IACzB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,SAAO;AAAA,IACL,GAAG;AAAA,IACH,aAAa;AAAA,IACb,SAAS,CAAC,GAAI,eAAe,WAAW,CAAC,GAAI,GAAG,YAAY;AAAA,EAC9D;AACF;;;AC3BF,eAAsB,cACpB,WACA,UACA,MACA,MACgC;AAChC,MAAI;AACF,UAAM,MAAM,MAAM,MAAM,GAAG,SAAS,GAAG,QAAQ,YAAY,IAAI,IAAI;AAAA,MACjE,OAAO;AAAA,IACT,CAAC;AAED,QAAI,CAAC,IAAI,GAAI,QAAO;AAEpB,UAAM,OAAO,MAAM,IAAI,KAAK;AAE5B,WAAQ,MAAM,WAAW,IAAI,KAAwB;AAAA,EACvD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;AChBA,eAAsB,aAAa,SAAkB,MAAiC;AACpF,MAAI;AACF,UAAM,MAAM,MAAM,QAAQ,WAAW,EAAE,MAA0B,gBAAgB,KAAK,CAAC;AAEvF,WAAQ,KAAK,YAAyB,CAAC;AAAA,EACzC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;;;ACTO,SAAS,aAAa,MAAc,OAA8B;AACvE,SAAO;AAAA,IACL;AAAA,IACA,QAAQ;AAAA,MACN,MAAM,MAAM;AAAA,IACd;AAAA,IACA,OAAO;AAAA,MACL,QAAQ,CAAC;AAAA,MACT,OAAO;AAAA,IACT;AAAA,IACA,QAAQ;AAAA,MACN;AAAA,QACE,MAAM;AAAA,QACN,MAAM;AAAA,QACN,OAAO;AAAA,UACL,aAAa;AAAA,QACf;AAAA,MACF;AAAA,IACF;AAAA,EACF;AACF;;;ACfO,SAAS,qBACd,QAC8B;AAC9B,QAAM,OAAO,QAAQ,cAAc;AAEnC,SAAO;AAAA,IACL,MAAM,MAAM,MAAM,UAAU,SAAS;AACnC,YAAM,kBAAkB,MAAM,aAAa,SAAS,IAAI;AAExD,YAAM,QAAQ,aAAa;AAAA,QACzB;AAAA,QACA,MAAM,EAAE,UAAU,EAAE,GAAG,iBAAiB,CAAC,IAAI,GAAG,SAAS,EAAE;AAAA,QAC3D,gBAAgB;AAAA,MAClB,CAAC;AAAA,IACH;AAAA,IAEA,MAAM,KAAK,MAAM;AACf,YAAM,YAAY,QAAQ,aAAa;AACvC,YAAM,WAAW,QAAQ,YAAY;AAErC,aAAO,cAA4B,WAAW,UAAU,MAAM,IAAI;AAAA,IACpE;AAAA,IAEA,MAAM,MAAM,MAAM,SAAS;AACzB,YAAM,kBAAkB,MAAM,aAAa,SAAS,IAAI;AAExD,aAAO,gBAAgB,IAAI;AAE3B,YAAM,QAAQ,aAAa;AAAA,QACzB;AAAA,QACA,MAAM,EAAE,UAAU,gBAAgB;AAAA,QAClC,gBAAgB;AAAA,MAClB,CAAC;AAAA,IACH;AAAA,IAEA,aAAa,QAAQ,OAAO;AAC1B,aAAO,aAAa,MAAM,KAAK;AAAA,IACjC;AAAA,EACF;AACF;;;AC5CA,eAAsBA,cACpB,aACiC;AACjC,MAAI;AACF,UAAM,EAAE,IAAI,IAAI,MAAM,OAAO,qBAAqB;AAElD,UAAM,WAAW,MAAM,IAA4B,WAAW;AAE9D,WAAO,YAAY,CAAC;AAAA,EACtB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;;;ACZO,SAAS,aAAa,QAAyD;AACpF,SAAO;AAAA,IACL,gBAAgB;AAAA,IAChB,eAAe,UAAU,OAAO,wBAAwB;AAAA,EAC1D;AACF;;;ACJA,eAAsB,iBAAiB,QAAiC,aAAqB,OAAgB;AAC3G,QAAM,YAAY,OAAO,SAAS,WAAW,OAAO,MAAM,KAAK;AAE/D,QAAM,MAAM,yCAAyC,OAAO,QAAQ,SAAS,SAAS,IAAI;AAAA,IACxF,QAAQ;AAAA,IACR,SAAS,aAAa,MAAM;AAAA,IAC5B,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC,EAAE,WAAW,UAAU,KAAK,aAAa,MAAM,CAAC,EAAE,CAAC;AAAA,EACpF,CAAC;AACH;;;ACAO,SAAS,kBACd,QAC8B;AAC9B,QAAM,cAAc,OAAO,eAAe;AAC1C,MAAI,aAA4C;AAEhD,iBAAe,cAAc;AAC3B,QAAI,eAAe,MAAM;AACvB,mBAAa,MAAMC,cAA2B,WAAW;AAAA,IAC3D;AAEA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,MAAM,MAAM,MAAM,UAAU;AAC1B,YAAM,kBAAkB,MAAM,YAAY;AAE1C,mBAAa,EAAE,GAAG,iBAAiB,CAAC,IAAI,GAAG,SAAS;AAEpD,YAAM,iBAAiB,QAAQ,aAAa,UAAU;AAAA,IACxD;AAAA,IAEA,MAAM,KAAK,MAAM;AACf,UAAI;AACF,cAAM,WAAW,MAAMA,cAA2B,WAAW;AAE7D,eAAO,WAAW,IAAI,KAAK;AAAA,MAC7B,QAAQ;AACN,eAAO;AAAA,MACT;AAAA,IACF;AAAA,IAEA,MAAM,MAAM,MAAM;AAChB,YAAM,kBAAkB,MAAM,YAAY;AAC1C,YAAM,UAAU,EAAE,GAAG,gBAAgB;AAErC,aAAO,QAAQ,IAAI;AAEnB,mBAAa;AAEb,YAAM,iBAAiB,QAAQ,aAAa,UAAU;AAAA,IACxD;AAAA,EACF;AACF;","names":["readManifest","readManifest"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kiryl.pekarski/payload-plugin-ab",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A/B testing plugin for Payload CMS",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"payload-cms",
|
|
7
|
+
"payload-plugin",
|
|
8
|
+
"ab-testing",
|
|
9
|
+
"split-testing",
|
|
10
|
+
"a-b-testing",
|
|
11
|
+
"page-variants",
|
|
12
|
+
"next.js",
|
|
13
|
+
"vercel-edge",
|
|
14
|
+
"edge-config",
|
|
15
|
+
"middleware",
|
|
16
|
+
"typescript",
|
|
17
|
+
"cms",
|
|
18
|
+
"conversion-optimization"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/focusreactive/payload-plugin-ab"
|
|
24
|
+
},
|
|
25
|
+
"author": "Kiryl Pekarski <kiryl.pekarski@fr.team>",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"files": ["dist"],
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"import": "./dist/index.js",
|
|
33
|
+
"types": "./dist/index.d.ts"
|
|
34
|
+
},
|
|
35
|
+
"./adapters/payload-global": {
|
|
36
|
+
"import": "./dist/adapters/payloadGlobal/index.js",
|
|
37
|
+
"types": "./dist/adapters/payloadGlobal/index.d.ts"
|
|
38
|
+
},
|
|
39
|
+
"./adapters/vercel-edge": {
|
|
40
|
+
"import": "./dist/adapters/vercelEdge/index.js",
|
|
41
|
+
"types": "./dist/adapters/vercelEdge/index.d.ts"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsup",
|
|
46
|
+
"lint": "eslint src/",
|
|
47
|
+
"lint:fix": "eslint src/ --fix",
|
|
48
|
+
"format": "prettier --write src/",
|
|
49
|
+
"format:check": "prettier --check src/"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"payload": "^3.0.0"
|
|
53
|
+
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"@vercel/edge-config": "^1.0.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^20.0.0",
|
|
59
|
+
"eslint": "^9.0.0",
|
|
60
|
+
"eslint-config-prettier": "^9.0.0",
|
|
61
|
+
"payload": "^3.73.0",
|
|
62
|
+
"prettier": "^3.0.0",
|
|
63
|
+
"tsup": "^8.0.0",
|
|
64
|
+
"typescript": "^5.0.0",
|
|
65
|
+
"typescript-eslint": "^8.0.0"
|
|
66
|
+
}
|
|
67
|
+
}
|