@createcms/core 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +169 -0
  2. package/dist/ab-edge/index.cjs +214 -0
  3. package/dist/ab-edge/index.d.cts +121 -0
  4. package/dist/ab-edge/index.d.ts +121 -0
  5. package/dist/ab-edge/index.js +205 -0
  6. package/dist/bin/createcms.js +3082 -0
  7. package/dist/db.cjs +496 -0
  8. package/dist/db.d.cts +128 -0
  9. package/dist/db.d.ts +128 -0
  10. package/dist/db.js +488 -0
  11. package/dist/index.cjs +13789 -0
  12. package/dist/index.d.cts +10277 -0
  13. package/dist/index.d.ts +10277 -0
  14. package/dist/index.js +13737 -0
  15. package/dist/nanoid.cjs +50 -0
  16. package/dist/nanoid.d.cts +29 -0
  17. package/dist/nanoid.d.ts +29 -0
  18. package/dist/nanoid.js +47 -0
  19. package/dist/next/index.cjs +60 -0
  20. package/dist/next/index.d.cts +141 -0
  21. package/dist/next/index.d.ts +141 -0
  22. package/dist/next/index.js +58 -0
  23. package/dist/next/middleware.cjs +113 -0
  24. package/dist/next/middleware.d.cts +77 -0
  25. package/dist/next/middleware.d.ts +77 -0
  26. package/dist/next/middleware.js +111 -0
  27. package/dist/plugins/ab-test/analytics/upstash.cjs +345 -0
  28. package/dist/plugins/ab-test/analytics/upstash.d.cts +193 -0
  29. package/dist/plugins/ab-test/analytics/upstash.d.ts +193 -0
  30. package/dist/plugins/ab-test/analytics/upstash.js +343 -0
  31. package/dist/plugins/ab-test/client.cjs +686 -0
  32. package/dist/plugins/ab-test/client.d.cts +233 -0
  33. package/dist/plugins/ab-test/client.d.ts +233 -0
  34. package/dist/plugins/ab-test/client.js +684 -0
  35. package/dist/plugins/ab-test/index.cjs +3400 -0
  36. package/dist/plugins/ab-test/index.d.cts +1131 -0
  37. package/dist/plugins/ab-test/index.d.ts +1131 -0
  38. package/dist/plugins/ab-test/index.js +3367 -0
  39. package/dist/plugins/client.cjs +20 -0
  40. package/dist/plugins/client.d.cts +3 -0
  41. package/dist/plugins/client.d.ts +3 -0
  42. package/dist/plugins/client.js +3 -0
  43. package/dist/plugins/consent/client.cjs +315 -0
  44. package/dist/plugins/consent/client.d.cts +145 -0
  45. package/dist/plugins/consent/client.d.ts +145 -0
  46. package/dist/plugins/consent/client.js +313 -0
  47. package/dist/plugins/consent/index.cjs +267 -0
  48. package/dist/plugins/consent/index.d.cts +618 -0
  49. package/dist/plugins/consent/index.d.ts +618 -0
  50. package/dist/plugins/consent/index.js +258 -0
  51. package/dist/plugins/i18n/index.cjs +2177 -0
  52. package/dist/plugins/i18n/index.d.cts +562 -0
  53. package/dist/plugins/i18n/index.d.ts +562 -0
  54. package/dist/plugins/i18n/index.js +2150 -0
  55. package/dist/plugins/media-optimize/index.cjs +315 -0
  56. package/dist/plugins/media-optimize/index.d.cts +144 -0
  57. package/dist/plugins/media-optimize/index.d.ts +144 -0
  58. package/dist/plugins/media-optimize/index.js +311 -0
  59. package/dist/plugins/multi-tenant/index.cjs +210 -0
  60. package/dist/plugins/multi-tenant/index.d.cts +431 -0
  61. package/dist/plugins/multi-tenant/index.d.ts +431 -0
  62. package/dist/plugins/multi-tenant/index.js +207 -0
  63. package/dist/plugins/server.cjs +24 -0
  64. package/dist/plugins/server.d.cts +3 -0
  65. package/dist/plugins/server.d.ts +3 -0
  66. package/dist/plugins/server.js +3 -0
  67. package/dist/react/blocks.cjs +233 -0
  68. package/dist/react/blocks.d.cts +320 -0
  69. package/dist/react/blocks.d.ts +320 -0
  70. package/dist/react/blocks.js +226 -0
  71. package/dist/react/index.cjs +901 -0
  72. package/dist/react/index.d.cts +992 -0
  73. package/dist/react/index.d.ts +992 -0
  74. package/dist/react/index.js +872 -0
  75. package/dist/react/tracking.cjs +243 -0
  76. package/dist/react/tracking.d.cts +364 -0
  77. package/dist/react/tracking.d.ts +364 -0
  78. package/dist/react/tracking.js +216 -0
  79. package/dist/react/variant.cjs +59 -0
  80. package/dist/react/variant.d.cts +26 -0
  81. package/dist/react/variant.d.ts +26 -0
  82. package/dist/react/variant.js +57 -0
  83. package/package.json +303 -0
package/README.md ADDED
@@ -0,0 +1,169 @@
1
+ # @createcms/core
2
+
3
+ A composable, block-based **headless CMS** powered by [better-call](https://github.com/bekacru/better-call) and [Drizzle ORM](https://orm.drizzle.team/) (PostgreSQL). Database-native, Git-like versioning with branches, copy-on-write drafts, visual diffs, merges, reusable blocks, nested pages, and a fully type-safe API.
4
+
5
+ > ⚠️ **Work in progress — not production-ready.** This package is **pre-1.0**, under
6
+ > active development, and has **not been tested in production**. Expect breaking
7
+ > changes, rough edges, and bugs (including possible data-loss edge cases). Use it for
8
+ > prototyping and exploration — **not** for production workloads. Pin an exact version.
9
+
10
+ ## Features
11
+
12
+ - **Block-based content** — define collections as a typed `root` plus child blocks; content is a tree of blocks.
13
+ - **Git-like versioning** — every collection entry is a `root` with branches, commits, and snapshots. Edit on a branch, open a merge request, diff, resolve conflicts, merge, publish.
14
+ - **End-to-end type safety** — collection definitions drive both the write API *and* the read responses. Autocomplete on `properties` everywhere, no manual types.
15
+ - **Type-safe client** — a proxy-based client mirrors the server API: `client.pages.listRoots()` is fully typed from `typeof cms`.
16
+ - **Plugins** — extend the server API, client, hooks, schema, and request scope. Ships with multi-tenant, A/B testing, and client-side media optimization.
17
+ - **Framework-friendly** — first-class Next.js route mounting and React rendering helpers; the core is framework-agnostic.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install @createcms/core
23
+ # peer deps
24
+ npm install drizzle-orm
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ### 0. Scaffold a project (optional)
30
+
31
+ Bootstrap the CMS config, a sample collection, and the Next.js route handler into an existing project (assumes the `src/` layout + the `@/*` path alias):
32
+
33
+ ```bash
34
+ npx createcms init # interactive preset picker
35
+ npx createcms init --preset blog # or pick directly: pages | blog | docs
36
+ ```
37
+
38
+ It writes `src/lib/cms.ts`, a collection under `src/cms/collections/`, the `src/app/api/cms/[[...rest]]/route.ts` route handler and a `.env.example`, and adds a `cms:generate` script — never overwriting existing files. Pick a starting collection with `--preset` (**pages** — marketing/content pages, the default; **blog** — posts with excerpt, date, cover image; **docs** — nested docs with callouts); the scaffolded collection is editable code you own. Provide your own Drizzle client at `src/lib/db.ts`, then continue below.
39
+
40
+ ### 1. Define collections
41
+
42
+ ```ts
43
+ import { defineCollection, defineCollections } from '@createcms/core';
44
+
45
+ const pages = defineCollection({
46
+ label: 'Pages',
47
+ slug: { enabled: true, root: '/', nested: true },
48
+ root: {
49
+ properties: {
50
+ title: { type: 'string', required: true, label: 'Title' },
51
+ },
52
+ },
53
+ blocks: {
54
+ hero: {
55
+ label: 'Hero',
56
+ properties: {
57
+ headline: { type: 'string', required: true, label: 'Headline' },
58
+ align: {
59
+ type: 'select',
60
+ label: 'Align',
61
+ options: [
62
+ { label: 'Left', value: 'left' },
63
+ { label: 'Right', value: 'right' },
64
+ ],
65
+ },
66
+ },
67
+ },
68
+ },
69
+ });
70
+
71
+ export const collections = defineCollections({ pages });
72
+ ```
73
+
74
+ ### 2. Generate the database schema
75
+
76
+ Generate a Drizzle schema for your collections + plugins, then run your migrations as usual:
77
+
78
+ ```bash
79
+ npx createcms generate
80
+ ```
81
+
82
+ ### 3. Create the CMS
83
+
84
+ ```ts
85
+ import { createCMS } from '@createcms/core';
86
+ import { db } from './db';
87
+ import { collections } from './collections';
88
+
89
+ export const cms = createCMS({
90
+ db,
91
+ collections,
92
+ media: { /* S3 / DigitalOcean config */ },
93
+ authMiddleware: async (ctx) => {
94
+ // resolve the user / permissions for this request
95
+ return { userId: '...' };
96
+ },
97
+ });
98
+ ```
99
+
100
+ ### 4. Mount the HTTP router (Next.js)
101
+
102
+ ```ts
103
+ // app/api/cms/[[...rest]]/route.ts
104
+ import { cms } from '@/lib/cms';
105
+
106
+ const { handler } = cms.router;
107
+ export const GET = handler;
108
+ export const POST = handler;
109
+ ```
110
+
111
+ ### 5. Read & render content
112
+
113
+ Server-side, call the typed API directly:
114
+
115
+ ```tsx
116
+ import { cms } from '@/lib/cms';
117
+ import { BlocksRenderer, createBlocksMap } from '@createcms/core/react';
118
+ import { pagesCollection } from '@/cms/collections/pages/definition';
119
+
120
+ // Pass the collection DEFINITION — it types the component props and carries
121
+ // each block's declared `events` for A/B goal tracking (single source of truth).
122
+ const pageBlocks = createBlocksMap(pagesCollection, {
123
+ hero: ({ properties }) => <h1>{properties.headline}</h1>,
124
+ });
125
+
126
+ export default async function Page() {
127
+ const { variants } = await cms.api.pages.getPublishedContent({
128
+ query: { slug: '/' },
129
+ });
130
+ return <BlocksRenderer blocks={pageBlocks} tree={variants[0].tree} />;
131
+ }
132
+ ```
133
+
134
+ ### 6. The type-safe client
135
+
136
+ ```ts
137
+ import { createCMSClient } from '@createcms/core';
138
+ import type { cms } from '@/lib/cms';
139
+
140
+ export const cmsClient = createCMSClient<typeof cms>({ baseURL: '/api/cms' });
141
+
142
+ const { roots } = await cmsClient.pages.listRoots();
143
+ roots[0].properties.title; // typed as `string`
144
+ ```
145
+
146
+ ## Plugins
147
+
148
+ ```ts
149
+ import { createCMS } from '@createcms/core';
150
+ import { multiTenant } from '@createcms/core/plugins/multi-tenant';
151
+ import { abTest } from '@createcms/core/plugins/ab-test';
152
+
153
+ export const cms = createCMS({
154
+ db,
155
+ collections,
156
+ media,
157
+ plugins: [multiTenant(), abTest({ /* ... */ })],
158
+ });
159
+ ```
160
+
161
+ | Plugin | Entry | What it adds |
162
+ | --- | --- | --- |
163
+ | Multi-tenant | `@createcms/core/plugins/multi-tenant` | Per-tenant data isolation via request-scoped query conditions. |
164
+ | A/B testing | `@createcms/core/plugins/ab-test` | Deterministic variant assignment, event tracking, pluggable analytics. |
165
+ | Media optimize | `@createcms/core/plugins/media-optimize` | Client-side resize/compress/WebP before upload. |
166
+
167
+ ## License
168
+
169
+ See repository.
@@ -0,0 +1,214 @@
1
+ Object.defineProperty(exports, '__esModule', { value: true });
2
+
3
+ /**
4
+ * MurmurHash3 (32-bit) for deterministic variant assignment.
5
+ * Inlined to avoid an external dependency for ~30 lines of code.
6
+ */ function murmur3(key, seed = 0) {
7
+ let h = seed >>> 0;
8
+ const len = key.length;
9
+ let i = 0;
10
+ while(i + 4 <= len){
11
+ let k = key.charCodeAt(i) & 0xff | (key.charCodeAt(i + 1) & 0xff) << 8 | (key.charCodeAt(i + 2) & 0xff) << 16 | (key.charCodeAt(i + 3) & 0xff) << 24;
12
+ k = Math.imul(k, 0xcc9e2d51);
13
+ k = k << 15 | k >>> 17;
14
+ k = Math.imul(k, 0x1b873593);
15
+ h ^= k;
16
+ h = h << 13 | h >>> 19;
17
+ h = Math.imul(h, 5) + 0xe6546b64;
18
+ i += 4;
19
+ }
20
+ let k = 0;
21
+ switch(len & 3){
22
+ case 3:
23
+ k ^= (key.charCodeAt(i + 2) & 0xff) << 16;
24
+ // falls through
25
+ case 2:
26
+ k ^= (key.charCodeAt(i + 1) & 0xff) << 8;
27
+ // falls through
28
+ case 1:
29
+ k ^= key.charCodeAt(i) & 0xff;
30
+ k = Math.imul(k, 0xcc9e2d51);
31
+ k = k << 15 | k >>> 17;
32
+ k = Math.imul(k, 0x1b873593);
33
+ h ^= k;
34
+ }
35
+ h ^= len;
36
+ h ^= h >>> 16;
37
+ h = Math.imul(h, 0x85ebca6b);
38
+ h ^= h >>> 13;
39
+ h = Math.imul(h, 0xc2b2ae35);
40
+ h ^= h >>> 16;
41
+ return h >>> 0;
42
+ }
43
+ /**
44
+ * Deterministic variant assignment.
45
+ *
46
+ * The same `contextKey + testId` always produces the same variant.
47
+ * No DB writes needed -- pure function.
48
+ *
49
+ * @param contextKey - Visitor identifier (user ID or anonymous key)
50
+ * @param testId - The A/B test ID
51
+ * @param trafficPercentage - 0-100, how much total traffic enters the test
52
+ * @param variants - Must be sorted by id for stability
53
+ */ function resolveVariant(contextKey, testId, trafficPercentage, variants) {
54
+ const hash = murmur3(contextKey + ':' + testId);
55
+ const bucket = hash % 10000;
56
+ const control = variants.find((v)=>v.isControl);
57
+ if (bucket >= trafficPercentage * 100) {
58
+ return {
59
+ variantId: control.id,
60
+ inTest: false
61
+ };
62
+ }
63
+ const sorted = [
64
+ ...variants
65
+ ].sort((a, b)=>a.id.localeCompare(b.id));
66
+ const normalizedBucket = Math.floor(bucket * 100 / (trafficPercentage * 100));
67
+ let cumulative = 0;
68
+ for (const v of sorted){
69
+ cumulative += v.weight;
70
+ if (normalizedBucket < cumulative) {
71
+ return {
72
+ variantId: v.id,
73
+ inTest: true
74
+ };
75
+ }
76
+ }
77
+ return {
78
+ variantId: control.id,
79
+ inTest: true
80
+ };
81
+ }
82
+
83
+ /**
84
+ * The control sentinel code. Pattern A ALWAYS rewrites (Vercel "precompute"
85
+ * model): a request with no running test / no consent / outside traffic is
86
+ * rewritten to `/<CONTROL_CODE><pathname>` so it still lands on the single
87
+ * `[abVariant]` route — there is no un-coded passthrough. It is never a real
88
+ * branch id (ids are prefixed), and the render route maps it to the control
89
+ * tree (pickVariant fails closed to control for any non-variant code anyway).
90
+ */ const CONTROL_CODE = 'control';
91
+ /**
92
+ * The static path prefix the render route lives under (`app/<prefix>/[abVariant]/
93
+ * [[...rest]]`). Pattern A rewrites every public request UNDER this prefix so the
94
+ * variant-coded route never sits at the URL root — otherwise a root catch-all
95
+ * would shadow sibling app routes (e.g. a `/app/*` dashboard). It is internal +
96
+ * transparent (the browser keeps the original URL), so the value only needs to
97
+ * differ from your real top-level app prefixes; with always-rewrite even a real
98
+ * page slugged `/ab` still works (it is rewritten THROUGH the prefix).
99
+ */ const DEFAULT_VARIANT_PREFIX = '/ab';
100
+ /**
101
+ * Build the variant-coded rewrite path: `<prefix>/<code><pathname>`. The variant
102
+ * code is the first segment AFTER the static prefix — the cache key — never a
103
+ * header/searchParam, so each variant is its own CDN/ISR cache entry (Vercel
104
+ * Flags precompute pattern, nested under `<prefix>` to coexist with other app
105
+ * routes). The matching render route reads the code segment back.
106
+ */ function variantRewritePath(prefix, code, pathname) {
107
+ // For the root path, omit the trailing slash (`<prefix>/<code>`, not
108
+ // `<prefix>/<code>/`) so the optional-catch-all variant route matches cleanly.
109
+ const suffix = pathname === '/' ? '' : pathname;
110
+ return `${prefix}/${encodeURIComponent(code)}${suffix}`;
111
+ }
112
+ /** Parse a first-party consent cookie value; analytics granted → true. */ function parseConsentCookie(value) {
113
+ if (!value) return false;
114
+ try {
115
+ const state = JSON.parse(decodeURIComponent(value));
116
+ return state.analytics_storage === 'granted';
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * Edge-safe random visitor id (mirrors the client `anon_` key shape). Uses the
123
+ * `crypto` global (Web Crypto), available in every edge/worker/browser runtime.
124
+ */ function generateEdgeVisitorId() {
125
+ const bytes = new Uint8Array(16);
126
+ crypto.getRandomValues(bytes);
127
+ let hex = '';
128
+ for (const b of bytes)hex += b.toString(16).padStart(2, '0');
129
+ return `anon_${hex}`;
130
+ }
131
+ /**
132
+ * PURE bucketing: given a one-shot `key`, the branch to render IN-TEST, or null =
133
+ * serve control (no running test, or the roll lands outside the test traffic).
134
+ * NO consent / NO persistent identifier — the caller passes an EPHEMERAL random
135
+ * key for a first assignment and then persists only the chosen branch (a
136
+ * variant-only cookie), so nothing identifying is stored. Reuses the same
137
+ * weighted hash as the server pick.
138
+ */ function pickEdgeVariant(opts) {
139
+ const test = opts.resolve.test;
140
+ if (!test) return {
141
+ branchId: null
142
+ };
143
+ const result = resolveVariant(opts.key, test.testId, test.trafficPercentage, test.variants.map((v)=>({
144
+ id: v.variantId,
145
+ weight: v.weight,
146
+ isControl: v.isControl
147
+ })));
148
+ if (!result.inTest) return {
149
+ branchId: null
150
+ }; // outside traffic → control
151
+ const picked = test.variants.find((v)=>v.variantId === result.variantId);
152
+ return {
153
+ branchId: picked?.branchId ?? null
154
+ };
155
+ }
156
+ /**
157
+ * The framework-agnostic edge decision (ALWAYS-rewrite / Vercel precompute).
158
+ * CONSENT-FREE by design: serving a variant + a VARIANT-ONLY cookie (no visitor
159
+ * id, no behavioural data, no third-party transmission) falls under the ePrivacy
160
+ * "strictly necessary" exemption, so fresh ad traffic gets real variants (not
161
+ * always control).
162
+ *
163
+ * - `assignedCode` is the value of the test's variant cookie (`ab_<testId>`) — a
164
+ * branch id, or the control sentinel — from a previous visit. If still valid
165
+ * it is REUSED (consistent variant across requests, no re-roll).
166
+ * - Otherwise a FIRST assignment is rolled from an ephemeral random key; the
167
+ * chosen code is returned in `assignCode` for the adapter to persist in the
168
+ * variant cookie. Out-of-traffic → the control sentinel (no impression).
169
+ *
170
+ * `rewritePath` is always non-null. Returns the `testId` so the adapter knows
171
+ * which cookie to set. No persistent identifier is ever minted here — the
172
+ * consent-gated visitor id + GA4 forwarding live in the client pipeline.
173
+ */ function decideEdgeVariant(input) {
174
+ const controlCode = input.controlCode ?? CONTROL_CODE;
175
+ const prefix = input.variantPrefix ?? DEFAULT_VARIANT_PREFIX;
176
+ const test = input.resolve.test;
177
+ if (!test) {
178
+ return {
179
+ rewritePath: variantRewritePath(prefix, controlCode, input.pathname),
180
+ assignCode: null,
181
+ testId: null
182
+ };
183
+ }
184
+ // Reuse a still-valid prior assignment (a published variant branch, or the
185
+ // control sentinel for an out-of-traffic visitor) → consistent, no re-roll.
186
+ const reusable = input.assignedCode === controlCode || input.assignedCode != null && test.variants.some((v)=>v.branchId === input.assignedCode);
187
+ if (reusable) {
188
+ return {
189
+ rewritePath: variantRewritePath(prefix, input.assignedCode, input.pathname),
190
+ assignCode: null,
191
+ testId: test.testId
192
+ };
193
+ }
194
+ // First assignment: roll once from an ephemeral (non-persisted) random key.
195
+ const { branchId } = pickEdgeVariant({
196
+ key: generateEdgeVisitorId(),
197
+ resolve: input.resolve
198
+ });
199
+ const code = branchId ?? controlCode; // out-of-traffic → control sentinel
200
+ return {
201
+ rewritePath: variantRewritePath(prefix, code, input.pathname),
202
+ assignCode: code,
203
+ testId: test.testId
204
+ };
205
+ }
206
+
207
+ exports.CONTROL_CODE = CONTROL_CODE;
208
+ exports.DEFAULT_VARIANT_PREFIX = DEFAULT_VARIANT_PREFIX;
209
+ exports.decideEdgeVariant = decideEdgeVariant;
210
+ exports.generateEdgeVisitorId = generateEdgeVisitorId;
211
+ exports.parseConsentCookie = parseConsentCookie;
212
+ exports.pickEdgeVariant = pickEdgeVariant;
213
+ exports.resolveVariant = resolveVariant;
214
+ exports.variantRewritePath = variantRewritePath;
@@ -0,0 +1,121 @@
1
+ /** One variant branch of the resolved test (enough for edge bucketing). */
2
+ type ResolvedAbVariant = {
3
+ /** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
4
+ variantId: string;
5
+ /** The published branch this variant renders. */
6
+ branchId: string;
7
+ weight: number;
8
+ isControl: boolean;
9
+ };
10
+ type AbResolveResult = {
11
+ test: {
12
+ testId: string;
13
+ /** The root the test attaches to (page root or an embedded block root). */
14
+ rootId: string;
15
+ trafficPercentage: number;
16
+ variants: ResolvedAbVariant[];
17
+ } | null;
18
+ };
19
+
20
+ type VariantInput = {
21
+ id: string;
22
+ weight: number;
23
+ isControl: boolean;
24
+ };
25
+ type AssignmentResult = {
26
+ variantId: string;
27
+ inTest: boolean;
28
+ };
29
+ /**
30
+ * Deterministic variant assignment.
31
+ *
32
+ * The same `contextKey + testId` always produces the same variant.
33
+ * No DB writes needed -- pure function.
34
+ *
35
+ * @param contextKey - Visitor identifier (user ID or anonymous key)
36
+ * @param testId - The A/B test ID
37
+ * @param trafficPercentage - 0-100, how much total traffic enters the test
38
+ * @param variants - Must be sorted by id for stability
39
+ */
40
+ declare function resolveVariant(contextKey: string, testId: string, trafficPercentage: number, variants: VariantInput[]): AssignmentResult;
41
+
42
+ /**
43
+ * The control sentinel code. Pattern A ALWAYS rewrites (Vercel "precompute"
44
+ * model): a request with no running test / no consent / outside traffic is
45
+ * rewritten to `/<CONTROL_CODE><pathname>` so it still lands on the single
46
+ * `[abVariant]` route — there is no un-coded passthrough. It is never a real
47
+ * branch id (ids are prefixed), and the render route maps it to the control
48
+ * tree (pickVariant fails closed to control for any non-variant code anyway).
49
+ */
50
+ declare const CONTROL_CODE = "control";
51
+ /**
52
+ * The static path prefix the render route lives under (`app/<prefix>/[abVariant]/
53
+ * [[...rest]]`). Pattern A rewrites every public request UNDER this prefix so the
54
+ * variant-coded route never sits at the URL root — otherwise a root catch-all
55
+ * would shadow sibling app routes (e.g. a `/app/*` dashboard). It is internal +
56
+ * transparent (the browser keeps the original URL), so the value only needs to
57
+ * differ from your real top-level app prefixes; with always-rewrite even a real
58
+ * page slugged `/ab` still works (it is rewritten THROUGH the prefix).
59
+ */
60
+ declare const DEFAULT_VARIANT_PREFIX = "/ab";
61
+ /**
62
+ * Build the variant-coded rewrite path: `<prefix>/<code><pathname>`. The variant
63
+ * code is the first segment AFTER the static prefix — the cache key — never a
64
+ * header/searchParam, so each variant is its own CDN/ISR cache entry (Vercel
65
+ * Flags precompute pattern, nested under `<prefix>` to coexist with other app
66
+ * routes). The matching render route reads the code segment back.
67
+ */
68
+ declare function variantRewritePath(prefix: string, code: string, pathname: string): string;
69
+ /** Parse a first-party consent cookie value; analytics granted → true. */
70
+ declare function parseConsentCookie(value: string | undefined | null): boolean;
71
+ /**
72
+ * Edge-safe random visitor id (mirrors the client `anon_` key shape). Uses the
73
+ * `crypto` global (Web Crypto), available in every edge/worker/browser runtime.
74
+ */
75
+ declare function generateEdgeVisitorId(): string;
76
+ /**
77
+ * PURE bucketing: given a one-shot `key`, the branch to render IN-TEST, or null =
78
+ * serve control (no running test, or the roll lands outside the test traffic).
79
+ * NO consent / NO persistent identifier — the caller passes an EPHEMERAL random
80
+ * key for a first assignment and then persists only the chosen branch (a
81
+ * variant-only cookie), so nothing identifying is stored. Reuses the same
82
+ * weighted hash as the server pick.
83
+ */
84
+ declare function pickEdgeVariant(opts: {
85
+ key: string;
86
+ resolve: AbResolveResult;
87
+ }): {
88
+ branchId: string | null;
89
+ };
90
+ /**
91
+ * The framework-agnostic edge decision (ALWAYS-rewrite / Vercel precompute).
92
+ * CONSENT-FREE by design: serving a variant + a VARIANT-ONLY cookie (no visitor
93
+ * id, no behavioural data, no third-party transmission) falls under the ePrivacy
94
+ * "strictly necessary" exemption, so fresh ad traffic gets real variants (not
95
+ * always control).
96
+ *
97
+ * - `assignedCode` is the value of the test's variant cookie (`ab_<testId>`) — a
98
+ * branch id, or the control sentinel — from a previous visit. If still valid
99
+ * it is REUSED (consistent variant across requests, no re-roll).
100
+ * - Otherwise a FIRST assignment is rolled from an ephemeral random key; the
101
+ * chosen code is returned in `assignCode` for the adapter to persist in the
102
+ * variant cookie. Out-of-traffic → the control sentinel (no impression).
103
+ *
104
+ * `rewritePath` is always non-null. Returns the `testId` so the adapter knows
105
+ * which cookie to set. No persistent identifier is ever minted here — the
106
+ * consent-gated visitor id + GA4 forwarding live in the client pipeline.
107
+ */
108
+ declare function decideEdgeVariant(input: {
109
+ pathname: string;
110
+ resolve: AbResolveResult;
111
+ assignedCode: string | null;
112
+ controlCode?: string;
113
+ variantPrefix?: string;
114
+ }): {
115
+ rewritePath: string;
116
+ assignCode: string | null;
117
+ testId: string | null;
118
+ };
119
+
120
+ export { CONTROL_CODE, DEFAULT_VARIANT_PREFIX, decideEdgeVariant, generateEdgeVisitorId, parseConsentCookie, pickEdgeVariant, resolveVariant, variantRewritePath };
121
+ export type { AbResolveResult, AssignmentResult, ResolvedAbVariant, VariantInput };
@@ -0,0 +1,121 @@
1
+ /** One variant branch of the resolved test (enough for edge bucketing). */
2
+ type ResolvedAbVariant = {
3
+ /** ab_test_variants.id — the `variantId` resolveVariant buckets to. */
4
+ variantId: string;
5
+ /** The published branch this variant renders. */
6
+ branchId: string;
7
+ weight: number;
8
+ isControl: boolean;
9
+ };
10
+ type AbResolveResult = {
11
+ test: {
12
+ testId: string;
13
+ /** The root the test attaches to (page root or an embedded block root). */
14
+ rootId: string;
15
+ trafficPercentage: number;
16
+ variants: ResolvedAbVariant[];
17
+ } | null;
18
+ };
19
+
20
+ type VariantInput = {
21
+ id: string;
22
+ weight: number;
23
+ isControl: boolean;
24
+ };
25
+ type AssignmentResult = {
26
+ variantId: string;
27
+ inTest: boolean;
28
+ };
29
+ /**
30
+ * Deterministic variant assignment.
31
+ *
32
+ * The same `contextKey + testId` always produces the same variant.
33
+ * No DB writes needed -- pure function.
34
+ *
35
+ * @param contextKey - Visitor identifier (user ID or anonymous key)
36
+ * @param testId - The A/B test ID
37
+ * @param trafficPercentage - 0-100, how much total traffic enters the test
38
+ * @param variants - Must be sorted by id for stability
39
+ */
40
+ declare function resolveVariant(contextKey: string, testId: string, trafficPercentage: number, variants: VariantInput[]): AssignmentResult;
41
+
42
+ /**
43
+ * The control sentinel code. Pattern A ALWAYS rewrites (Vercel "precompute"
44
+ * model): a request with no running test / no consent / outside traffic is
45
+ * rewritten to `/<CONTROL_CODE><pathname>` so it still lands on the single
46
+ * `[abVariant]` route — there is no un-coded passthrough. It is never a real
47
+ * branch id (ids are prefixed), and the render route maps it to the control
48
+ * tree (pickVariant fails closed to control for any non-variant code anyway).
49
+ */
50
+ declare const CONTROL_CODE = "control";
51
+ /**
52
+ * The static path prefix the render route lives under (`app/<prefix>/[abVariant]/
53
+ * [[...rest]]`). Pattern A rewrites every public request UNDER this prefix so the
54
+ * variant-coded route never sits at the URL root — otherwise a root catch-all
55
+ * would shadow sibling app routes (e.g. a `/app/*` dashboard). It is internal +
56
+ * transparent (the browser keeps the original URL), so the value only needs to
57
+ * differ from your real top-level app prefixes; with always-rewrite even a real
58
+ * page slugged `/ab` still works (it is rewritten THROUGH the prefix).
59
+ */
60
+ declare const DEFAULT_VARIANT_PREFIX = "/ab";
61
+ /**
62
+ * Build the variant-coded rewrite path: `<prefix>/<code><pathname>`. The variant
63
+ * code is the first segment AFTER the static prefix — the cache key — never a
64
+ * header/searchParam, so each variant is its own CDN/ISR cache entry (Vercel
65
+ * Flags precompute pattern, nested under `<prefix>` to coexist with other app
66
+ * routes). The matching render route reads the code segment back.
67
+ */
68
+ declare function variantRewritePath(prefix: string, code: string, pathname: string): string;
69
+ /** Parse a first-party consent cookie value; analytics granted → true. */
70
+ declare function parseConsentCookie(value: string | undefined | null): boolean;
71
+ /**
72
+ * Edge-safe random visitor id (mirrors the client `anon_` key shape). Uses the
73
+ * `crypto` global (Web Crypto), available in every edge/worker/browser runtime.
74
+ */
75
+ declare function generateEdgeVisitorId(): string;
76
+ /**
77
+ * PURE bucketing: given a one-shot `key`, the branch to render IN-TEST, or null =
78
+ * serve control (no running test, or the roll lands outside the test traffic).
79
+ * NO consent / NO persistent identifier — the caller passes an EPHEMERAL random
80
+ * key for a first assignment and then persists only the chosen branch (a
81
+ * variant-only cookie), so nothing identifying is stored. Reuses the same
82
+ * weighted hash as the server pick.
83
+ */
84
+ declare function pickEdgeVariant(opts: {
85
+ key: string;
86
+ resolve: AbResolveResult;
87
+ }): {
88
+ branchId: string | null;
89
+ };
90
+ /**
91
+ * The framework-agnostic edge decision (ALWAYS-rewrite / Vercel precompute).
92
+ * CONSENT-FREE by design: serving a variant + a VARIANT-ONLY cookie (no visitor
93
+ * id, no behavioural data, no third-party transmission) falls under the ePrivacy
94
+ * "strictly necessary" exemption, so fresh ad traffic gets real variants (not
95
+ * always control).
96
+ *
97
+ * - `assignedCode` is the value of the test's variant cookie (`ab_<testId>`) — a
98
+ * branch id, or the control sentinel — from a previous visit. If still valid
99
+ * it is REUSED (consistent variant across requests, no re-roll).
100
+ * - Otherwise a FIRST assignment is rolled from an ephemeral random key; the
101
+ * chosen code is returned in `assignCode` for the adapter to persist in the
102
+ * variant cookie. Out-of-traffic → the control sentinel (no impression).
103
+ *
104
+ * `rewritePath` is always non-null. Returns the `testId` so the adapter knows
105
+ * which cookie to set. No persistent identifier is ever minted here — the
106
+ * consent-gated visitor id + GA4 forwarding live in the client pipeline.
107
+ */
108
+ declare function decideEdgeVariant(input: {
109
+ pathname: string;
110
+ resolve: AbResolveResult;
111
+ assignedCode: string | null;
112
+ controlCode?: string;
113
+ variantPrefix?: string;
114
+ }): {
115
+ rewritePath: string;
116
+ assignCode: string | null;
117
+ testId: string | null;
118
+ };
119
+
120
+ export { CONTROL_CODE, DEFAULT_VARIANT_PREFIX, decideEdgeVariant, generateEdgeVisitorId, parseConsentCookie, pickEdgeVariant, resolveVariant, variantRewritePath };
121
+ export type { AbResolveResult, AssignmentResult, ResolvedAbVariant, VariantInput };