@funnelsgrove/cli 0.1.14 → 0.1.15

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/dist/localSync.js CHANGED
@@ -198,12 +198,12 @@ function formatByteSize(byteCount) {
198
198
  async function readSourceFiles(rootDir, manifestFiles) {
199
199
  const files = await Promise.all(manifestFiles.map(async (file) => {
200
200
  const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
201
- const imageContentType = inferImageContentType(file.path);
201
+ const binaryContentType = inferBinaryContentType(file.path);
202
202
  const buffer = await readFile(absolutePath);
203
203
  return {
204
204
  path: file.path,
205
- content: imageContentType ? `data:${imageContentType};base64,${buffer.toString('base64')}` : buffer.toString('utf8'),
206
- contentType: imageContentType || 'text/plain',
205
+ content: binaryContentType ? `data:${binaryContentType};base64,${buffer.toString('base64')}` : buffer.toString('utf8'),
206
+ contentType: binaryContentType || 'text/plain',
207
207
  };
208
208
  }));
209
209
  return files.sort((left, right) => left.path.localeCompare(right.path));
@@ -242,27 +242,31 @@ async function collectSyncFiles(rootDir, dir) {
242
242
  async function hashFile(filePath) {
243
243
  return createHash('sha256').update(await readFile(filePath)).digest('hex');
244
244
  }
245
- function inferImageContentType(filePath) {
246
- const extension = path.extname(filePath).toLowerCase();
247
- if (extension === '.png') {
248
- return 'image/png';
249
- }
250
- if (extension === '.jpg' || extension === '.jpeg') {
251
- return 'image/jpeg';
252
- }
253
- if (extension === '.webp') {
254
- return 'image/webp';
255
- }
256
- if (extension === '.gif') {
257
- return 'image/gif';
258
- }
259
- if (extension === '.svg') {
260
- return 'image/svg+xml';
261
- }
262
- if (extension === '.avif') {
263
- return 'image/avif';
264
- }
265
- return null;
245
+ const BINARY_CONTENT_TYPE_BY_EXTENSION = {
246
+ '.aac': 'audio/aac',
247
+ '.avif': 'image/avif',
248
+ '.flac': 'audio/flac',
249
+ '.gif': 'image/gif',
250
+ '.jpeg': 'image/jpeg',
251
+ '.jpg': 'image/jpeg',
252
+ '.m4a': 'audio/mp4',
253
+ '.m4v': 'video/mp4',
254
+ '.mov': 'video/quicktime',
255
+ '.mp3': 'audio/mpeg',
256
+ '.mp4': 'video/mp4',
257
+ '.oga': 'audio/ogg',
258
+ '.ogg': 'audio/ogg',
259
+ '.ogv': 'video/ogg',
260
+ '.opus': 'audio/ogg',
261
+ '.png': 'image/png',
262
+ '.svg': 'image/svg+xml',
263
+ '.wav': 'audio/wav',
264
+ '.weba': 'audio/webm',
265
+ '.webm': 'video/webm',
266
+ '.webp': 'image/webp',
267
+ };
268
+ function inferBinaryContentType(filePath) {
269
+ return BINARY_CONTENT_TYPE_BY_EXTENSION[path.extname(filePath).toLowerCase()] || null;
266
270
  }
267
271
  function decodeDataUrl(content) {
268
272
  const match = content.trim().match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s_-]+)$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@ Source-of-truth guide for editing this synced FunnelsGrove funnel. Read the matc
8
8
  | --- | --- | --- | --- |
9
9
  | Add or edit a step | [docs/editing-step.md](docs/editing-step.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) | `src/steps/*` + registries + manifest | < 3 min to first preview |
10
10
  | Change step order / branching | [docs/editing-flow.md](docs/editing-flow.md) | `src/config/funnel.manifest.ts` | < 2 min |
11
- | Set up an A/B experiment | [docs/ab-experiments.md](docs/ab-experiments.md) | `src/config/experiments.ts` | < 1 min when variant step exists |
11
+ | Set up an A/B experiment | [docs/ab-experiments.md](docs/ab-experiments.md) | FunnelsGrove UI/API + `src/config/experiments.generated.ts`; manifest steps/edges | < 1 min when variant step exists |
12
12
  | Edit copy or images | [docs/editor-and-content.md](docs/editor-and-content.md) | `src/steps/content/*.content.ts` | < 2 min |
13
13
  | Edit image loading/performance | [docs/editing-flow.md](docs/editing-flow.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) + [docs/publishing-and-versioning.md](docs/publishing-and-versioning.md) | `src/config/funnel.manifest.ts`, `src/components/FunnelFlow.tsx`, image assets | careful, QA required |
14
14
  | Change plans, prices, discounts | [docs/payment-plans-and-discounts.md](docs/payment-plans-and-discounts.md) | `src/config/billing.plans.ts` | careful, read doc fully |
@@ -63,7 +63,7 @@ with the same local source diff.
63
63
  4. **Goal-driven execution.** Define the success check before editing ("step renders at all default breakpoints and Continue advances to step-X"), then loop until it passes.
64
64
  5. **Image performance locked.** Keep build-time raster compression plus AVIF/WebP variants enabled. For funnel step images, use `funnelManifest.assets` + step `assetIds`, priority/preload only for first-viewport images, and low-priority next-step warming from the shell. Do not preload the whole funnel image set.
65
65
  6. **Meaningful URLs.** New step `path` values are public product routes, so use readable slugs like `/motivation`, `/fitness-goal`, or `/email-capture`. Sequential ids and `step-NN-*` filenames are okay for ordering, but do not create public routes like `/step-1`.
66
- 7. **Flow labeling hygiene.** When changing `edgesByStepId`, keep manifest `branches` current for conditional paths that own steps before reconverging. Keep builder metadata ClaimBee-style and source-readable: `edgesByStepId` keys/targets and `branches: [...]` must use inline string literals, not `someStep.id` variables or a `branches: flowBranches` indirection. Each branch needs a readable `name`, answer-derived `label` such as `yes-branch`, useful `tags`, and the owned `stepIds`. Running experiment variants need labels/tags like `paywall-test-control` and `paywall-test-variant-b`; stopped A/B variants or other inactive screens should keep stable tags and remain unreachable from the default flow so builder marks them `unused`.
66
+ 7. **Flow labeling hygiene.** When changing `edgesByStepId`, keep manifest `branches` current for conditional paths that own steps before reconverging. Keep builder metadata ClaimBee-style and source-readable: `edgesByStepId` keys/targets and `branches: [...]` must use inline string literals, not `someStep.id` variables or indirection like `branches: flowBranches`. Each branch needs a readable `name`, answer-derived `label` such as `yes-branch`, useful `tags`, and the owned `stepIds`. Every experiment control or variant route target must be a real manifest step with normal outgoing edges. Prefer creating/updating experiments through the FunnelsGrove UI/API so the platform owns the database row, PostHog flag, and generated `src/config/experiments.generated.ts`; keep `src/config/experiments.ts` as the generated compatibility wrapper when possible. If a code-authored experiment is necessary, keep `sourceStepId`/`stepId`, variant labels, traffic percentages, and route target ids as explicit literals so deploy sync and analytics can import it. Running experiment variants need labels/tags like `paywall-test-control` and `paywall-test-variant-b`; stopped A/B variants or other inactive screens should keep stable tags and remain unreachable from the default flow so builder marks them `unused`.
67
67
 
68
68
  Architecture docs are part of the change: if you change routing, runtime state, SDK contracts, URL handoff parameters, checkout behavior, analytics events, or shared package boundaries, update the matching doc in the same change.
69
69
 
@@ -1,10 +1,53 @@
1
1
  # A/B Experiments
2
2
 
3
- Experiments are config, not code: declared in `src/config/experiments.ts`, exported into the manifest, resolved by the shared runtime. Never write variant conditionals inside step components. If the variant step already exists, setting up the experiment is a one-file edit that takes under a minute.
3
+ Experiments are config, not component code. Prefer creating or updating the
4
+ experiment through the FunnelsGrove UI/API, which owns the database row,
5
+ PostHog flag, and generated `src/config/experiments.generated.ts` file. Keep
6
+ `src/config/experiments.ts` as the generated compatibility wrapper when
7
+ possible. Make sure every control or variant route target is a real manifest
8
+ step with normal outgoing edges. Never write variant conditionals inside step
9
+ components. If the variant step already exists, setting up the experiment is
10
+ mostly config plus publish sync.
4
11
 
5
12
  ## Recipe: Step or Paywall Test
6
13
 
7
- Open `src/config/experiments.ts` and add one entry inside `defineFunnelExperiments([...])`:
14
+ Open `src/config/funnel.manifest.ts` and make sure every control and variant
15
+ screen exists in `steps` with readable tags:
16
+
17
+ ```ts
18
+ steps: [
19
+ { id: 'paywall', path: '/offer', tags: ['paywall-ab-control'], /* ... */ },
20
+ { id: 'paywall-b', path: '/offer-b', tags: ['paywall-ab-variant-b'], /* ... */ },
21
+ ]
22
+ ```
23
+
24
+ Add normal outgoing edges for both routes:
25
+
26
+ ```ts
27
+ edgesByStepId: {
28
+ paywall: [{ toStepId: 'checkout' }],
29
+ 'paywall-b': [{ toStepId: 'checkout' }],
30
+ }
31
+ ```
32
+
33
+ Then create the experiment in the FunnelsGrove UI/API and pull the generated
34
+ config. The synced source should export it from `src/config/experiments.generated.ts`
35
+ and keep `src/config/experiments.ts` as a wrapper:
36
+
37
+ ```ts
38
+ import {
39
+ defineFunnelExperiments,
40
+ toManifestExperiments,
41
+ } from '@funnelsgrove/runtime';
42
+ import { generatedExperiments } from './experiments.generated';
43
+
44
+ export const experiments = defineFunnelExperiments(generatedExperiments);
45
+
46
+ export const manifestExperiments = toManifestExperiments(experiments);
47
+ ```
48
+
49
+ If a code-authored experiment is necessary, add one explicit object inside the
50
+ local experiment array or `defineFunnelExperiments([...])` call:
8
51
 
9
52
  ```ts
10
53
  export const experiments = defineFunnelExperiments([
@@ -12,18 +55,30 @@ export const experiments = defineFunnelExperiments([
12
55
  id: 'paywall-ab', // durable, readable, never reused
13
56
  name: 'Paywall copy test',
14
57
  type: 'paywall', // 'step' for quiz steps, 'paywall' for paywall tests
15
- status: 'running', // 'paused' | 'stopped' removes it from the manifest
58
+ status: 'running', // non-running entries stay out of active routing
16
59
  launchDate: '2026-06-12T00:00:00.000Z',
17
- control: { stepId: 'paywall', label: 'paywall-ab-control', trafficPercent: 50 },
18
- variant: { stepId: 'paywall-b', label: 'paywall-ab-variant-b', trafficPercent: 50 },
60
+ stepId: 'paywall',
61
+ variants: [
62
+ { variantKey: 'control', stepId: 'paywall', label: 'paywall-ab-control', trafficPercent: 50 },
63
+ { variantKey: 'variant_b', stepId: 'paywall-b', label: 'paywall-ab-variant-b', trafficPercent: 50 },
64
+ ],
19
65
  },
20
66
  ] as const);
21
67
  ```
22
68
 
23
- That's it — `toManifestExperiments(experiments)` in the same file feeds the manifest, traffic percents are validated to sum to 100, and non-`running` entries are filtered out automatically. (Older funnels may declare a plain array and map it manually — follow the local file's existing shape there.) Preconditions (each is its own task if missing):
69
+ Preconditions:
24
70
 
25
- 1. Both `control.stepId` and `variant.stepId` exist in the manifest with normal `edgesByStepId` exits ([editing-step.md](editing-step.md) to create a variant step — usually a copy of the control step with one deliberate change).
71
+ 1. Every control or variant `stepId` exists in the manifest `steps` array.
26
72
  2. Both steps route to the same next step, unless the experiment is explicitly about the flow.
73
+ 3. `steps`, `edgesByStepId`, experiment `stepId`, and experiment `label` values use explicit string literals that analytics can read.
74
+
75
+ The manifest may keep `experiments: manifestExperiments` and import the
76
+ runnable experiment shape from `src/config/experiments.ts`. That is fine as
77
+ long as the separate file exports a generated wrapper or a source-readable
78
+ `experiments` array. Do not hide step ids or labels behind computed variables.
79
+ For route experiments where the source step differs from the rendered control
80
+ step, set `sourceStepId` or `stepId` to the source step and set each variant's
81
+ `stepId` to its rendered route.
27
82
 
28
83
  ## How the Runtime Behaves
29
84
 
@@ -48,7 +103,8 @@ Check: opening the source URL promotes to the assigned route, each variant rende
48
103
 
49
104
  - One deliberate change per experiment. Don't bundle unrelated edits into a variant.
50
105
  - `control` stays stable; never edit the control step as part of launching a variant.
51
- - Add stable `control.label` and `variant.label` values in the `<experiment-id>-control` / `<experiment-id>-variant-b` format so builder labels and analytics views stay readable.
106
+ - Add stable variant `label` values in the `<experiment-id>-control` / `<experiment-id>-variant-b` format so builder labels and analytics views stay readable.
107
+ - Add matching `steps[].tags` to every control and variant step.
52
108
  - Variant keys and experiment ids are durable — they flow into analytics. Never recycle an id for a different hypothesis.
53
109
  - Keep every variant step's outgoing edge in `edgesByStepId`, or assigned visitors strand.
54
110
  - If a paywall variant changes plans or pricing, follow [payment-plans-and-discounts.md](payment-plans-and-discounts.md) for the plan/discount sync rules and QA both variants' checkout ([qa-checklist.md](qa-checklist.md)).
@@ -15,10 +15,10 @@ The manifest defines:
15
15
  variants. Funnel shells may use this data to warm likely next-step images,
16
16
  but first-viewport images should still use the framework's normal
17
17
  priority/preload mechanism.
18
- - `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, optional `tags`, and optional `assetIds`. New `path` values must be meaningful public route slugs, not `/step-1` style URLs. Sequential ids are acceptable when the funnel uses them internally.
18
+ - `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, optional `tags`, and optional `assetIds`. This includes experiment variant screens and intentionally inactive screens that should appear as `unused` in builder. New `path` values must be meaningful public route slugs, not `/step-1` style URLs. Sequential ids are acceptable when the funnel uses them internally.
19
19
  - `edgesByStepId`: graph edges between steps.
20
20
  - `branches`: builder metadata for conditional paths that own one or more steps before reconverging.
21
- - `experiments`: optional variant routing.
21
+ - `experiments`: optional variant routing. This may be imported from `src/config/experiments.ts` when that file keeps source-readable experiment definitions.
22
22
 
23
23
  Keep `steps[].id`, `path`, and answer keys stable unless the request is a migration.
24
24
  When adding a step, prefer a semantic path like `/motivation`, `/fitness-goal`,
@@ -101,6 +101,13 @@ Experiments attach to a step and route to variant steps:
101
101
  }
102
102
  ```
103
103
 
104
+ Keep every control and variant route target as a real step in
105
+ `funnel.manifest.ts`. The runnable experiment entry may live in
106
+ `src/config/experiments.ts` and be imported into the manifest as
107
+ `experiments: manifestExperiments`; if so, keep the experiment `stepId`, labels,
108
+ and traffic as explicit string/number literals so analytics can parse them.
109
+ Do not hide step ids or labels behind computed variables.
110
+
104
111
  The runtime resolves assignments through the shared analytics/runtime integration outside preview and uses editor overrides inside preview/editor mode. Opening the source step waits for the assignment, then opens the assigned route and keeps the URL in sync. Continue then advances from the assigned route's normal graph edge, with the already-applied experiment ignored for that continuation.
105
112
 
106
113
  Keep experiment redirects out of step components. Add normal `edgesByStepId` entries for the source/control step and every variant step, then let `goNext()` or the shell Continue button use the shared runtime.
@@ -112,6 +119,10 @@ Keep the control variant stable and do not remove a running variant until analyt
112
119
  - Add or update the manifest step.
113
120
  - Register the component in `src/runtime/step-registry.ts`.
114
121
  - Update `edgesByStepId`, `entryPoints`, `branches`, step `tags`, and `assetIds` if needed.
122
+ - Add every experiment control and variant route target to `steps`.
123
+ - Add normal outgoing `edgesByStepId` entries for every experiment route target.
124
+ - Add each running experiment to `src/config/experiments.ts` or the manifest's inline `experiments` array, matching the local funnel pattern.
125
+ - Keep experiment config synchronized with manifest steps, tags, and edges.
115
126
  - Label every branch-owned path with `branches[].name`, answer-derived `label`, useful `tags`, and owned `stepIds`.
116
127
  - Keep builder metadata inline/literal so groups, labels, and `unused` badges are visible in builder.
117
128
  - Label every running experiment variant with stable tags/labels like `<experiment-id>-control` and `<experiment-id>-variant-b`.