@variantlab/core 0.1.4 → 0.1.6
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 +1209 -39
- package/docs/API.md +692 -0
- package/docs/ARCHITECTURE.md +430 -0
- package/docs/CONTRIBUTING.md +264 -0
- package/docs/ROADMAP.md +292 -0
- package/docs/SECURITY.md +323 -0
- package/docs/design/api-philosophy.md +347 -0
- package/docs/design/config-format.md +442 -0
- package/docs/design/design-principles.md +212 -0
- package/docs/design/targeting-dsl.md +433 -0
- package/docs/features/codegen.md +351 -0
- package/docs/features/crash-rollback.md +399 -0
- package/docs/features/debug-overlay.md +328 -0
- package/docs/features/hmac-signing.md +330 -0
- package/docs/features/killer-features.md +308 -0
- package/docs/features/multivariate.md +339 -0
- package/docs/features/qr-sharing.md +372 -0
- package/docs/features/targeting.md +481 -0
- package/docs/features/time-travel.md +306 -0
- package/docs/features/value-experiments.md +487 -0
- package/docs/phases/phase-2-expansion.md +307 -0
- package/docs/phases/phase-3-ecosystem.md +289 -0
- package/docs/phases/phase-4-advanced.md +306 -0
- package/docs/phases/phase-5-v1-stable.md +350 -0
- package/docs/research/bundle-size-analysis.md +279 -0
- package/docs/research/competitors.md +327 -0
- package/docs/research/framework-ssr-quirks.md +394 -0
- package/docs/research/naming-rationale.md +238 -0
- package/docs/research/origin-story.md +179 -0
- package/docs/research/security-threats.md +312 -0
- package/package.json +2 -1
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# Codegen
|
|
2
|
+
|
|
3
|
+
Turn `experiments.json` into type-safe TypeScript. This document describes how the CLI generates types, what it generates, and how users consume them.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [The problem codegen solves](#the-problem-codegen-solves)
|
|
8
|
+
- [What gets generated](#what-gets-generated)
|
|
9
|
+
- [CLI command](#cli-command)
|
|
10
|
+
- [Output file shape](#output-file-shape)
|
|
11
|
+
- [Hook overloads](#hook-overloads)
|
|
12
|
+
- [Watch mode](#watch-mode)
|
|
13
|
+
- [Integration with bundlers](#integration-with-bundlers)
|
|
14
|
+
- [Configuration](#configuration)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## The problem codegen solves
|
|
19
|
+
|
|
20
|
+
Without codegen:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
const variant = useVariant("news-card-layot"); // typo!
|
|
24
|
+
// variant type: string
|
|
25
|
+
// runtime behavior: falls back to default, silently wrong
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
With codegen:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
const variant = useVariant("news-card-layot");
|
|
32
|
+
// ❌ Type error: Argument of type '"news-card-layot"' is not assignable
|
|
33
|
+
// to parameter of type ExperimentId
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Codegen turns stringly-typed experiments into a compile-time contract. Typos become build errors. Refactors are catchable. IDE autocomplete shows every valid ID.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## What gets generated
|
|
41
|
+
|
|
42
|
+
For this config:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"version": 1,
|
|
47
|
+
"experiments": [
|
|
48
|
+
{
|
|
49
|
+
"id": "news-card-layout",
|
|
50
|
+
"type": "render",
|
|
51
|
+
"default": "responsive",
|
|
52
|
+
"variants": [
|
|
53
|
+
{ "id": "responsive" },
|
|
54
|
+
{ "id": "scale-to-fit" },
|
|
55
|
+
{ "id": "pip-thumbnail" }
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"id": "cta-copy",
|
|
60
|
+
"type": "value",
|
|
61
|
+
"default": "buy-now",
|
|
62
|
+
"variants": [
|
|
63
|
+
{ "id": "buy-now", "value": "Buy now" },
|
|
64
|
+
{ "id": "get-started", "value": "Get started" }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The CLI emits:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// variantlab.generated.ts
|
|
75
|
+
// AUTO-GENERATED — DO NOT EDIT BY HAND
|
|
76
|
+
// Regenerate with: variantlab generate
|
|
77
|
+
|
|
78
|
+
export interface VariantLabExperiments {
|
|
79
|
+
"news-card-layout": {
|
|
80
|
+
type: "render";
|
|
81
|
+
variants: "responsive" | "scale-to-fit" | "pip-thumbnail";
|
|
82
|
+
default: "responsive";
|
|
83
|
+
};
|
|
84
|
+
"cta-copy": {
|
|
85
|
+
type: "value";
|
|
86
|
+
variants: "buy-now" | "get-started";
|
|
87
|
+
default: "buy-now";
|
|
88
|
+
value: "Buy now" | "Get started";
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
declare module "@variantlab/core" {
|
|
93
|
+
interface VariantLabRegistry extends VariantLabExperiments {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type ExperimentId = keyof VariantLabExperiments;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The magic is the `declare module` augmentation: it extends `@variantlab/core`'s registry type, which in turn makes all hooks aware of the experiments.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## CLI command
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# One-shot generate
|
|
107
|
+
variantlab generate
|
|
108
|
+
|
|
109
|
+
# Custom input/output
|
|
110
|
+
variantlab generate --config ./config/experiments.json --out ./src/variantlab.generated.ts
|
|
111
|
+
|
|
112
|
+
# Watch mode
|
|
113
|
+
variantlab generate --watch
|
|
114
|
+
|
|
115
|
+
# Verbose
|
|
116
|
+
variantlab generate --verbose
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Exit codes
|
|
120
|
+
|
|
121
|
+
- `0` — success
|
|
122
|
+
- `1` — config not found
|
|
123
|
+
- `2` — config invalid (schema violation)
|
|
124
|
+
- `3` — output path unwritable
|
|
125
|
+
|
|
126
|
+
### CI integration
|
|
127
|
+
|
|
128
|
+
In CI, codegen should run as a validation step:
|
|
129
|
+
|
|
130
|
+
```yaml
|
|
131
|
+
# .github/workflows/ci.yml
|
|
132
|
+
- name: Validate experiments
|
|
133
|
+
run: npx variantlab validate experiments.json
|
|
134
|
+
|
|
135
|
+
- name: Generate types
|
|
136
|
+
run: npx variantlab generate
|
|
137
|
+
|
|
138
|
+
- name: Check for uncommitted changes
|
|
139
|
+
run: git diff --exit-code src/variantlab.generated.ts
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
This ensures the generated file is checked in and matches the config.
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## Output file shape
|
|
147
|
+
|
|
148
|
+
The output is a `.ts` file (not `.d.ts`) because it uses `declare module` which must be in a non-ambient context.
|
|
149
|
+
|
|
150
|
+
### Header
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
// variantlab.generated.ts
|
|
154
|
+
// AUTO-GENERATED by variantlab v0.1.0 at 2026-04-10T12:00:00Z
|
|
155
|
+
// DO NOT EDIT BY HAND
|
|
156
|
+
// Regenerate with: variantlab generate
|
|
157
|
+
// Source: ./experiments.json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Interfaces
|
|
161
|
+
|
|
162
|
+
One interface per experiment, composed into `VariantLabExperiments`.
|
|
163
|
+
|
|
164
|
+
### Module augmentation
|
|
165
|
+
|
|
166
|
+
The augmentation hooks into `@variantlab/core`'s `VariantLabRegistry` interface. This is how the hooks become type-aware without the user needing to pass generics.
|
|
167
|
+
|
|
168
|
+
### Exported helpers
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
export type ExperimentId = keyof VariantLabExperiments;
|
|
172
|
+
|
|
173
|
+
export type VariantId<T extends ExperimentId> =
|
|
174
|
+
VariantLabExperiments[T]["variants"];
|
|
175
|
+
|
|
176
|
+
export type VariantValue<T extends ExperimentId> =
|
|
177
|
+
VariantLabExperiments[T] extends { value: infer V } ? V : never;
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Users can use these directly for typing props, state, etc.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Hook overloads
|
|
185
|
+
|
|
186
|
+
The core package defines hooks with overloads that look up the registry:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// In @variantlab/core (before codegen)
|
|
190
|
+
export interface VariantLabRegistry {}
|
|
191
|
+
|
|
192
|
+
export function useVariant<K extends keyof VariantLabRegistry>(
|
|
193
|
+
id: K,
|
|
194
|
+
): VariantLabRegistry[K]["variants"];
|
|
195
|
+
|
|
196
|
+
export function useVariant(id: string): string; // fallback when no codegen
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
After codegen, `VariantLabRegistry` has entries, so the first overload wins. TypeScript narrows the return to the exact variant union.
|
|
200
|
+
|
|
201
|
+
### For render experiments
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
const variant = useVariant("news-card-layout");
|
|
205
|
+
// variant: "responsive" | "scale-to-fit" | "pip-thumbnail"
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### For value experiments
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const value = useVariantValue("cta-copy");
|
|
212
|
+
// value: "Buy now" | "Get started"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### For the `<Variant>` component
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
<Variant experimentId="news-card-layout">
|
|
219
|
+
{{
|
|
220
|
+
responsive: <ResponsiveCard />,
|
|
221
|
+
"scale-to-fit": <ScaleToFitCard />,
|
|
222
|
+
// ❌ Type error: Property "pip-thumbnail" is missing
|
|
223
|
+
}}
|
|
224
|
+
</Variant>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The component's prop is `Record<VariantId<T>, ReactNode>`, so TypeScript enforces exhaustive handling.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Watch mode
|
|
232
|
+
|
|
233
|
+
`variantlab generate --watch` uses a minimal file watcher (native `fs.watch`) to regenerate on config changes.
|
|
234
|
+
|
|
235
|
+
- Debounced 100 ms to avoid thrashing on rapid edits
|
|
236
|
+
- Prints a timestamped log line on each regeneration
|
|
237
|
+
- Validates the config before writing; invalid configs do not overwrite the previous output
|
|
238
|
+
- Prints errors clearly with line/column references
|
|
239
|
+
|
|
240
|
+
### IDE workflow
|
|
241
|
+
|
|
242
|
+
With watch mode running, the developer edits `experiments.json` and the `.ts` file updates immediately. TypeScript picks up the new types on the next save.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Integration with bundlers
|
|
247
|
+
|
|
248
|
+
### Next.js
|
|
249
|
+
|
|
250
|
+
Add a `postinstall` script to regenerate after `npm install`:
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"scripts": {
|
|
255
|
+
"postinstall": "variantlab generate",
|
|
256
|
+
"predev": "variantlab generate",
|
|
257
|
+
"prebuild": "variantlab generate"
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Vite
|
|
263
|
+
|
|
264
|
+
Use a custom plugin wrapper (we ship one):
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
// vite.config.ts
|
|
268
|
+
import { defineConfig } from "vite";
|
|
269
|
+
import variantlab from "@variantlab/vite-plugin";
|
|
270
|
+
|
|
271
|
+
export default defineConfig({
|
|
272
|
+
plugins: [variantlab({ config: "./experiments.json" })],
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The plugin:
|
|
277
|
+
|
|
278
|
+
1. Regenerates the file on dev server start
|
|
279
|
+
2. Watches the config in dev
|
|
280
|
+
3. Fails the build if the config is invalid
|
|
281
|
+
|
|
282
|
+
### Webpack / Rollup / esbuild / tsup
|
|
283
|
+
|
|
284
|
+
No special plugin needed — use the npm scripts approach.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## Configuration
|
|
289
|
+
|
|
290
|
+
A project config file (`variantlab.config.ts` or `variantlab.config.json`) controls codegen:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// variantlab.config.ts
|
|
294
|
+
import { defineConfig } from "@variantlab/cli";
|
|
295
|
+
|
|
296
|
+
export default defineConfig({
|
|
297
|
+
config: "./experiments.json",
|
|
298
|
+
output: "./src/variantlab.generated.ts",
|
|
299
|
+
watch: {
|
|
300
|
+
debounce: 100,
|
|
301
|
+
ignore: ["**/.git/**", "**/node_modules/**"],
|
|
302
|
+
},
|
|
303
|
+
validate: {
|
|
304
|
+
strict: true, // fail on warnings too
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
All options have sensible defaults. The config file is only needed for non-default setups.
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## Security considerations
|
|
314
|
+
|
|
315
|
+
- The CLI reads a user-provided JSON file. We validate it against the schema before processing.
|
|
316
|
+
- The output is a plain TypeScript file with no runtime code — no injection vectors.
|
|
317
|
+
- We escape IDs and values before interpolating into the output (no arbitrary code via crafted config values).
|
|
318
|
+
- Path traversal in `--out` is blocked.
|
|
319
|
+
- The CLI rejects configs > 1 MB.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Without codegen
|
|
324
|
+
|
|
325
|
+
Codegen is **optional**. Users who don't want it can still use all the hooks, with explicit generics:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
const variant = useVariant<"responsive" | "scale-to-fit">("news-card-layout");
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Or just use strings and accept the lack of safety:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
const variant = useVariant("news-card-layout"); // type: string
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
Codegen is a DX win, not a requirement.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Bundle size impact
|
|
342
|
+
|
|
343
|
+
Zero. Codegen emits types only. No runtime code is added. The `.generated.ts` file is erased by the TypeScript compiler.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## See also
|
|
348
|
+
|
|
349
|
+
- [`API.md`](../../API.md) — hook type definitions
|
|
350
|
+
- [`config-format.md`](../design/config-format.md) — the input format
|
|
351
|
+
- [`api-philosophy.md`](../design/api-philosophy.md) — why generics over overloads
|