@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.
@@ -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