@appright/funnelshift 0.15.0 → 0.17.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.
@@ -144,8 +144,9 @@ function entryOf(loc, doc, project, funnel, mountPath) {
144
144
  const fit = [];
145
145
  for (const [k, v] of q) {
146
146
  // __shot, __step, __build, __exp, __sig: the kit's and the router's own (a preview signature among them), not the link's;
147
- // $dropped is the kit's count, never taken from a link
148
- if (!k || k.startsWith('__') || k === DROPPED_KEY || chars(k).length > MAX_PARAM_KEY || seen.has(k))
147
+ // $dropped is the kit's count, never taken from a link; link_token is the router's (a hosted paywall's signed placement link,
148
+ // kit 0.17.0), ?placement next to it stays
149
+ if (!k || k.startsWith('__') || k === DROPPED_KEY || k === 'link_token' || chars(k).length > MAX_PARAM_KEY || seen.has(k))
149
150
  continue;
150
151
  seen.add(k);
151
152
  fit.push([k, cutTo(v, MAX_PARAM_VALUE)]);
@@ -1,3 +1,4 @@
1
+ import type { PlacementContent, URLPlacement } from './placement.js';
1
2
  export type Env = 'prod' | 'preview' | 'dev';
2
3
  export type Creator = {
3
4
  code: string;
@@ -18,6 +19,11 @@ export type Bootstrap = {
18
19
  fn_preview?: string;
19
20
  creator?: Creator;
20
21
  variants?: Record<string, string>;
22
+ experiments?: Record<string, {
23
+ type: 'build' | 'content';
24
+ phase: number;
25
+ keys: string[];
26
+ }>;
21
27
  content_overrides?: Record<string, string | number | boolean>;
22
28
  fn_arm?: string;
23
29
  fn_base?: string;
@@ -28,6 +34,12 @@ export type Bootstrap = {
28
34
  lead_step?: string;
29
35
  checkout_step?: string;
30
36
  };
37
+ placements?: Record<string, PlacementContent>;
38
+ placement_rejected?: {
39
+ placement: string;
40
+ reason: string;
41
+ };
42
+ placement_url?: URLPlacement;
31
43
  };
32
44
  export declare function readBootstrap(win?: Window): Bootstrap;
33
45
  export declare function readCreator(win?: Window): Creator | null;
@@ -22,6 +22,7 @@ type Src = {
22
22
  export type ManifestStep = {
23
23
  id: string;
24
24
  role?: StepRole;
25
+ placement?: string;
25
26
  sensitive?: true;
26
27
  conditional?: true;
27
28
  when_keys?: string[];
@@ -58,18 +58,11 @@ export function extractContent(files) {
58
58
  }
59
59
  const unwrap = (n) => (n && ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(n.type) ? unwrap(n.expression) : n);
60
60
  const isCall = (n, name) => n?.type === 'CallExpression' && n.callee?.type === 'Identifier' && n.callee.name === name;
61
- // The answer keys a step's `when` reads, when its code tells them: an arrow or function whose one parameter is only ever read as
62
- // `a.key`, `a?.key` or `a['key']`, or is destructured with literal keys and no rest; no parameter reads none. Anything else (the
63
- // parameter passed on or spread, a computed key, a function declared elsewhere) gives null: the step is conditional, its keys unknown.
64
- export function whenKeys(fn) {
65
- fn = unwrap(fn);
66
- if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression')
67
- return null;
68
- if (fn.params.length === 0)
69
- return [];
70
- if (fn.params.length > 1)
71
- return null;
72
- let p = fn.params[0];
61
+ const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
62
+ // The keys a function reads of one of its parameters, when its code tells them: the parameter is only ever read as `a.key`, `a?.key`
63
+ // or `a['key']`, or is destructured with literal keys and no rest; an unused parameter reads none. Anything else (the parameter passed
64
+ // on or spread, a computed key) gives null.
65
+ function paramKeys(fn, p) {
73
66
  if (p.type === 'AssignmentPattern')
74
67
  p = p.left;
75
68
  const keys = [];
@@ -122,7 +115,20 @@ export function whenKeys(fn) {
122
115
  visit(fn.body, fn, 'body');
123
116
  return ok ? keys : null;
124
117
  }
125
- // Steps (id, optional role, sensitive: true when the step is marked) in screen order, read statically from `defineFunnel({ steps: [...] })` (the manifest's `steps`, which the server uses to
118
+ // The keys a step's `when` reads, when its code tells them: the answers it reads (the first parameter) as their keys, the experiments
119
+ // it reads (the second, `(a, ab) => ab.quiz_len === 'long'`) as `ab.<experiment>`, in the order of the parameters. Anything else (a
120
+ // parameter passed on or spread, a computed key, a function declared elsewhere) gives null: the step is conditional, its keys unknown.
121
+ export function whenKeys(fn) {
122
+ fn = unwrap(fn);
123
+ if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression')
124
+ return null;
125
+ if (fn.params.length > 2)
126
+ return null;
127
+ const answers = fn.params[0] ? paramKeys(fn, fn.params[0]) : [];
128
+ const arms = fn.params[1] ? paramKeys(fn, fn.params[1]) : [];
129
+ return answers && arms ? [...answers, ...arms.map(k => `ab.${k}`)] : null;
130
+ }
131
+ // Steps (id, optional role and placement, sensitive: true when the step is marked) in screen order, read statically from `defineFunnel({ steps: [...] })` (the manifest's `steps`, which the server uses to
126
132
  // order the report and find the lead and paywall steps). An element is `defineStep({ id: '...' })` or an identifier declared as `const x = defineStep({...})` in the
127
133
  // same file; anything else (imports, spreads, computed ids) is an error with file:line, never a guess.
128
134
  export function extractSteps(files) {
@@ -199,6 +205,16 @@ export function extractSteps(files) {
199
205
  }
200
206
  out.role = role;
201
207
  }
208
+ // placement (kit 0.17.0): the project's placement the step sells in; the server refuses a publish whose placement it does not
209
+ // have (placement_not_found), so a typo fails there and never makes a new placement.
210
+ const placementProp = option('placement');
211
+ if (placementProp) {
212
+ const key = literal(unwrap(placementProp.value));
213
+ if (key === null || !PLACEMENT_KEY.test(key)) {
214
+ throw new Error(`${at(placementProp)}: defineStep() placement must be a placement key as a string literal (${PLACEMENT_KEY.source})`);
215
+ }
216
+ out.placement = key;
217
+ }
202
218
  // sensitive only marks the step in the admin; the manifest carries it so the server can say so (steps_order).
203
219
  const sensProp = option('sensitive');
204
220
  if (sensProp) {
package/dist/cli/index.js CHANGED
@@ -8,11 +8,11 @@ import { join } from 'node:path';
8
8
  const argv = process.argv;
9
9
  const arg = (n) => argArg(argv, n);
10
10
  const opt = (n) => optArg(argv, n);
11
- const git = (c) => { try {
11
+ const git = (c, none = 'unknown') => { try {
12
12
  return execSync(`git ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
13
13
  }
14
14
  catch {
15
- return 'unknown';
15
+ return none;
16
16
  } };
17
17
  const commit = process.env.GITHUB_SHA || git('rev-parse HEAD');
18
18
  const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || git('rev-parse --abbrev-ref HEAD');
@@ -24,13 +24,15 @@ async function main() {
24
24
  return;
25
25
  }
26
26
  if (cmd !== 'build' && cmd !== 'upload')
27
- throw new Error('usage: funnel-kit init --project <slug> [--dir <path>] [--force] | build|upload --funnel <slug> [--api <url>] [--project <slug>]');
27
+ throw new Error('usage: funnel-kit init --project <slug> [--dir <path>] [--force] | build --funnel <slug> | upload --funnel <slug> [--api <url>] [--project <slug>] [--message <text>]');
28
28
  const funnel = arg('funnel');
29
29
  if (cmd === 'build') {
30
30
  console.log(await buildFunnel(process.cwd(), funnel, { commit, branch }));
31
31
  }
32
32
  else {
33
- const lines = await runUpload({ dir: join(process.cwd(), 'dist', funnel), funnel, commit, branch, env: process.env, api: opt('api'), project: opt('project') });
33
+ // the build's name in the service: --message, else the subject of the commit (none outside git)
34
+ const message = opt('message') ?? git('log -1 --format=%s', '');
35
+ const lines = await runUpload({ dir: join(process.cwd(), 'dist', funnel), funnel, commit, branch, message, env: process.env, api: opt('api'), project: opt('project') });
34
36
  for (const l of lines)
35
37
  console.log(l);
36
38
  }
@@ -1,3 +1,4 @@
1
+ export declare function commitSubject(msg: string | undefined): string;
1
2
  export declare function uploadBuild(o: {
2
3
  dir: string;
3
4
  api: string;
@@ -5,6 +6,7 @@ export declare function uploadBuild(o: {
5
6
  funnel: string;
6
7
  commit: string;
7
8
  branch: string;
9
+ message?: string;
8
10
  token: string;
9
11
  fetchImpl?: typeof fetch;
10
12
  }): Promise<{
@@ -22,6 +24,7 @@ export declare function runUpload(o: {
22
24
  funnel: string;
23
25
  commit: string;
24
26
  branch: string;
27
+ message?: string;
25
28
  env: Record<string, string | undefined>;
26
29
  api?: string;
27
30
  project?: string;
@@ -17,6 +17,12 @@ function files(root, rel = '') {
17
17
  throw new Error(`not a regular file in build dir: ${r}`);
18
18
  });
19
19
  }
20
+ // commitSubject is the commit message the build is named by in the service («#14 · Новый текст пейвола»): its first line, or '' for
21
+ // none. The merge commit GitHub makes for a pull request («Merge <sha> into <sha>») says nothing about the build and is left out.
22
+ export function commitSubject(msg) {
23
+ const s = (msg ?? '').split(/\r?\n/)[0].trim();
24
+ return /^Merge [0-9a-f]{40} into [0-9a-f]{40}$/.test(s) ? '' : s;
25
+ }
20
26
  export async function uploadBuild(o) {
21
27
  const list = files(o.dir);
22
28
  for (const need of ['index.html', 'manifest.json']) {
@@ -33,6 +39,10 @@ export async function uploadBuild(o) {
33
39
  const form = new FormData();
34
40
  for (const k of ['project', 'funnel', 'commit', 'branch'])
35
41
  form.set(k, o[k]);
42
+ // the commit subject names the build (kit 0.16.0); a server before it ignores the field
43
+ const message = commitSubject(o.message);
44
+ if (message)
45
+ form.set('message', message);
36
46
  form.set('artifact', new Blob([art], { type: 'application/gzip' }), 'build.tgz');
37
47
  const r = await (o.fetchImpl ?? fetch)(`${o.api.replace(/\/+$/, '')}/api/builds`, { method: 'POST', body: form, headers: { Authorization: `Bearer ${o.token}` } });
38
48
  const text = await r.text();
@@ -85,6 +95,6 @@ export async function runUpload(o) {
85
95
  throw new Error('the service address is not set: use FUNNELS_API_URL or --api <url>');
86
96
  const fetchImpl = o.fetchImpl ?? fetch;
87
97
  const project = o.project || o.env.FUNNELS_PROJECT || await projectOfToken(api, token, fetchImpl);
88
- const res = await uploadBuild({ dir: o.dir, api, project, funnel: o.funnel, commit: o.commit, branch: o.branch, token, fetchImpl });
98
+ const res = await uploadBuild({ dir: o.dir, api, project, funnel: o.funnel, commit: o.commit, branch: o.branch, message: o.message, token, fetchImpl });
89
99
  return uploadLines(res);
90
100
  }
package/dist/content.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readKey } from './exposure.js';
1
2
  let content = {};
2
3
  let overrides = {};
3
4
  // The reverse dictionary for $click names (clicks.ts): every text t() has produced on this page, whitespace collapsed → its key.
@@ -25,8 +26,10 @@ function valueOf(key) {
25
26
  }
26
27
  export const collapse = (s) => s.replace(/\s+/g, ' ').trim();
27
28
  // t(key, default): the published text for key, else the default written in code. The build collects every
28
- // t() call with its default into the manifest, so all texts are editable in the admin.
29
+ // t() call with its default into the manifest, so all texts are editable in the admin. A read of a key that an experiment's arm sets
30
+ // is that experiment's exposure (exposure.ts), whichever arm the visitor is in.
29
31
  export function t(key, def, vars) {
32
+ readKey(key);
30
33
  const v = valueOf(key);
31
34
  const s = v === undefined ? def : String(v);
32
35
  const out = vars ? s.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : s;
@@ -36,6 +39,7 @@ export function t(key, def, vars) {
36
39
  // param(key, default): a number, string or boolean the admin (or an A/B arm) may change, of the default's type. A value that does
37
40
  // not read as that type ("abc" for a number) gives the default.
38
41
  export function param(key, def) {
42
+ readKey(key);
39
43
  const v = valueOf(key);
40
44
  if (v === undefined)
41
45
  return def;
@@ -1,12 +1,17 @@
1
1
  // A/B in the funnel's code (kit 0.9.0, spec «A/B» §3): the visitor's arm, as the router assigned it (bootstrap.variants).
2
2
  import { useMemo } from 'react';
3
3
  import { readBootstrap } from './bootstrap.js';
4
- // The arm key of experiment `key` for this visitor, or null: no such experiment running, vite dev, an old router.
4
+ import { readArm } from './exposure.js';
5
+ // The arm key of experiment `key` for this visitor, or null: no such experiment running, vite dev, an old router. Reading the arm
6
+ // is the first read of the experiment's value: it sends experiment_exposed (exposure.ts), except for the rule, which is no experiment.
5
7
  export function experiment(key) {
6
8
  const v = readBootstrap().variants;
7
9
  if (!v || typeof v !== 'object' || !Object.prototype.hasOwnProperty.call(v, key))
8
10
  return null;
9
- return typeof v[key] === 'string' ? v[key] : null;
11
+ if (typeof v[key] !== 'string')
12
+ return null;
13
+ readArm(key);
14
+ return v[key];
10
15
  }
11
16
  // The same in a component; the bootstrap does not change while the page lives.
12
17
  export function useExperiment(key) {
@@ -14,7 +19,7 @@ export function useExperiment(key) {
14
19
  }
15
20
  // The targeting rule the router matched for this visitor (v2 track D: «Правила» in the admin), or null: no rule matched, vite dev, an
16
21
  // old router. Rules decide the build and the t()/param() values before any experiment does; this is for code that wants to know which
17
- // one it got. It is the `rule` key of the variants, so experiment('rule') answers the same, but a rule is no experiment: no $exposure.
22
+ // one it got. It is the `rule` key of the variants, so experiment('rule') answers the same, but a rule is no experiment: no experiment events.
18
23
  export function rule() {
19
24
  return experiment('rule');
20
25
  }
@@ -1,4 +1,9 @@
1
1
  type Track = (name: string, props?: Record<string, unknown>) => void;
2
2
  export declare const RULE_KEY = "rule";
3
- export declare function exposeVariants(funnel: string, variants: Record<string, string> | undefined, track: Track): void;
3
+ export type AB = Readonly<Record<string, string | undefined>>;
4
+ export declare function readKey(key: string): void;
5
+ export declare function readArm(experiment: string): void;
6
+ export declare function currentAB(): AB;
7
+ export declare function startExposure(funnel: string, track: Track): void;
8
+ export declare function clearExposure(): void;
4
9
  export {};
package/dist/exposure.js CHANGED
@@ -1,16 +1,112 @@
1
- // $exposure (kit 0.9.0, spec «A/B» §3): at the funnel's start, one event per experiment of the bootstrap, props {experiment, arm},
2
- // once per session. The variants key `rule` is the targeting rule the router matched, not an experiment: it has no $exposure: sessionStorage af_exposed:<funnel> keeps the session id (the tracker's, visit.session) and the arms already sent.
3
- // A new session (30 min idle) or another arm sends it again. Blocked storage: once per page load.
1
+ import { readPlacement, readURLPlacement } from './placement.js';
4
2
  import { visit } from './server.js';
5
3
  // The variants key of the matched rule (v2 track D): it rides on every event as a variant, but a rule is no test and has no exposure.
6
4
  export const RULE_KEY = 'rule';
7
- export function exposeVariants(funnel, variants, track) {
8
- if (!variants || typeof variants !== 'object')
5
+ // The bootstrap if the page has one: not in Node (the landing's prerender at build time) and not without the router (vite dev).
6
+ const boot = () => (typeof window === 'undefined' ? null : window.__FUNNEL__ ?? null);
7
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
8
+ // The visitor's funnel experiments: { <experiment>: <arm> } without the rule and without junk.
9
+ function armsOf(b) {
10
+ const out = {};
11
+ if (!isObj(b.variants))
12
+ return out;
13
+ for (const [experiment, arm] of Object.entries(b.variants))
14
+ if (experiment !== RULE_KEY && typeof arm === 'string' && arm)
15
+ out[experiment] = arm;
16
+ return out;
17
+ }
18
+ function metaOf(b, experiment) {
19
+ const m = isObj(b.experiments) && Object.prototype.hasOwnProperty.call(b.experiments, experiment) ? b.experiments[experiment] : null;
20
+ if (!isObj(m) || (m.type !== 'build' && m.type !== 'content') || !Array.isArray(m.keys))
21
+ return null;
22
+ return { type: m.type, phase: Number.isInteger(m.phase) ? m.phase : 1, keys: m.keys.filter((k) => typeof k === 'string') };
23
+ }
24
+ const funnelProps = (b, experiment, arm) => ({
25
+ space: 'funnel', experiment, phase: String(metaOf(b, experiment)?.phase ?? 1), arm, content_version: b.build_id, subject: `visitor:${b.visitor_id}`,
26
+ });
27
+ // ---- exposure: the first read of a value ----
28
+ const exposed = new Set();
29
+ // Experiments read before the funnel started (a t() at module level, the first render): sent when it starts.
30
+ const pending = new Set();
31
+ let sink = null;
32
+ function expose(experiment) {
33
+ if (exposed.has(experiment) || pending.has(experiment))
34
+ return;
35
+ const b = boot();
36
+ const arm = b ? armsOf(b)[experiment] : undefined;
37
+ if (!b || !arm)
9
38
  return;
10
- const entries = Object.entries(variants).filter(([experiment, arm]) => experiment !== RULE_KEY && typeof arm === 'string' && arm);
11
- if (!entries.length)
39
+ if (!sink) {
40
+ pending.add(experiment);
41
+ return;
42
+ }
43
+ exposed.add(experiment);
44
+ sink('experiment_exposed', funnelProps(b, experiment, arm));
45
+ }
46
+ // The experiments of the bootstrap by the t()/param() keys that read them; rebuilt when the page's bootstrap is another object.
47
+ let indexed = null;
48
+ function experimentsOfKey(b, key) {
49
+ if (indexed?.b !== b) {
50
+ const byKey = new Map();
51
+ const arms = armsOf(b);
52
+ for (const experiment of Object.keys(arms)) {
53
+ const m = metaOf(b, experiment);
54
+ if (m?.type === 'content')
55
+ for (const k of m.keys)
56
+ byKey.set(k, [...(byKey.get(k) ?? []), experiment]);
57
+ }
58
+ indexed = { b, byKey };
59
+ }
60
+ return indexed.byKey.get(key);
61
+ }
62
+ // A t() or param() of `key` was read (content.ts): that exposes the experiments that have the key among the values their arms set.
63
+ export function readKey(key) {
64
+ const b = boot();
65
+ if (!b?.experiments)
12
66
  return;
13
- const key = `af_exposed:${funnel}`;
67
+ for (const experiment of experimentsOfKey(b, key) ?? [])
68
+ expose(experiment);
69
+ }
70
+ // The arm of `experiment` was read (experiments.ts): experiment(), useExperiment().
71
+ export function readArm(experiment) {
72
+ expose(experiment);
73
+ }
74
+ let frozen = null;
75
+ // The AB of this page, one object per page load (per bootstrap): a getter per experiment, which counts as the first read.
76
+ export function currentAB() {
77
+ const b = boot();
78
+ if (frozen && frozen.variants === (b?.variants ?? undefined))
79
+ return frozen.ab;
80
+ const ab = Object.create(null);
81
+ for (const [experiment, arm] of Object.entries(b ? armsOf(b) : {})) {
82
+ Object.defineProperty(ab, experiment, { enumerable: true, get: () => { expose(experiment); return arm; } });
83
+ }
84
+ frozen = { variants: b?.variants ?? undefined, ab: Object.freeze(ab) };
85
+ return frozen.ab;
86
+ }
87
+ // experiment_assigned for what the router assigned, once per session and arm.
88
+ function assign(funnel, b, track) {
89
+ const events = [];
90
+ for (const [experiment, arm] of Object.entries(armsOf(b)))
91
+ events.push({ key: `funnel:${experiment}`, arm, props: funnelProps(b, experiment, arm) });
92
+ if (isObj(b.placements)) {
93
+ for (const entry of Object.values(b.placements)) {
94
+ const c = readPlacement(entry);
95
+ if (!c || !c.experiment || !c.arm)
96
+ continue;
97
+ events.push({ key: `placement:${c.experiment}`, arm: c.arm, props: { space: 'placement', experiment: c.experiment, phase: String(c.phase), arm: c.arm,
98
+ content_version: `${c.paywall}@${c.version}`, subject: c.subject || `visitor:${b.visitor_id}`, placement: c.placement } });
99
+ }
100
+ }
101
+ // a url placement's arm: this funnel at the build served
102
+ const u = readURLPlacement();
103
+ if (u?.experiment) {
104
+ events.push({ key: `placement:${u.experiment}`, arm: u.arm, props: { space: 'placement', experiment: u.experiment, phase: String(u.phase), arm: u.arm,
105
+ content_version: b.build_id, subject: u.subject || `visitor:${b.visitor_id}`, placement: u.placement } });
106
+ }
107
+ if (!events.length)
108
+ return;
109
+ const store = `af_assigned:${funnel}`;
14
110
  let sid = '';
15
111
  try {
16
112
  sid = visit.session();
@@ -18,18 +114,42 @@ export function exposeVariants(funnel, variants, track) {
18
114
  catch { /* no session */ }
19
115
  let seen = {};
20
116
  try {
21
- seen = JSON.parse(sessionStorage.getItem(key) ?? '{}') ?? {};
117
+ seen = JSON.parse(sessionStorage.getItem(store) ?? '{}') ?? {};
22
118
  }
23
119
  catch { /* blocked or junk */ }
24
120
  const arms = seen.sid === sid && seen.arms && typeof seen.arms === 'object' ? { ...seen.arms } : {};
25
- for (const [experiment, arm] of entries) {
26
- if (arms[experiment] === arm)
121
+ for (const e of events) {
122
+ if (arms[e.key] === e.arm)
27
123
  continue;
28
- track('$exposure', { experiment, arm });
29
- arms[experiment] = arm;
124
+ track('experiment_assigned', e.props);
125
+ arms[e.key] = e.arm;
30
126
  }
31
127
  try {
32
- sessionStorage.setItem(key, JSON.stringify({ sid, arms }));
128
+ sessionStorage.setItem(store, JSON.stringify({ sid, arms }));
33
129
  }
34
130
  catch { /* storage unavailable */ }
35
131
  }
132
+ // The funnel started: send what the router assigned, expose the build experiments (all of them for an older router, which does not
133
+ // say where an experiment is exposed), then what was read before this moment.
134
+ export function startExposure(funnel, track) {
135
+ const b = boot();
136
+ if (!b)
137
+ return;
138
+ assign(funnel, b, track);
139
+ sink = track;
140
+ for (const experiment of Object.keys(armsOf(b)))
141
+ if (!b.experiments || metaOf(b, experiment)?.type === 'build')
142
+ expose(experiment);
143
+ for (const experiment of [...pending]) {
144
+ pending.delete(experiment);
145
+ expose(experiment);
146
+ }
147
+ }
148
+ // For tests: forget what this page load has exposed and where it sends.
149
+ export function clearExposure() {
150
+ exposed.clear();
151
+ pending.clear();
152
+ sink = null;
153
+ indexed = null;
154
+ frozen = null;
155
+ }
@@ -25,9 +25,42 @@ export type FunctionCtx = {
25
25
  variants: Readonly<Record<string, string>>;
26
26
  params: Readonly<Record<string, string | number | boolean>>;
27
27
  rule: string;
28
+ placement: Readonly<FunctionPlacement> | null;
28
29
  reply(status: number, body?: unknown): Reply;
29
30
  track(name: string, props?: Record<string, unknown>): void;
30
31
  };
32
+ export type FunctionPlacement = {
33
+ placement: string;
34
+ paywall: string;
35
+ version: number;
36
+ experiment: string;
37
+ arm: string;
38
+ layout: string;
39
+ config: Readonly<Record<string, unknown>>;
40
+ products: ReadonlyArray<{
41
+ key: string;
42
+ name: string;
43
+ kind: string;
44
+ period: string;
45
+ access_levels: readonly string[];
46
+ phases: readonly {
47
+ kind: string;
48
+ period?: string;
49
+ }[];
50
+ price: {
51
+ price_id: string;
52
+ currency: string;
53
+ amount: number;
54
+ interval: string;
55
+ } | null;
56
+ refs: readonly {
57
+ provider: string;
58
+ ref_kind: string;
59
+ ref: string;
60
+ }[];
61
+ }>;
62
+ fs_ctx: string;
63
+ };
31
64
  export type FunctionHandler<P = any, R = unknown> = (payload: P, ctx: FunctionCtx) => R | Reply | Promise<R | Reply>;
32
65
  export type FunctionsModule<F extends Record<string, FunctionHandler> = Record<string, FunctionHandler>> = {
33
66
  readonly __funnelFunctions: 1;
package/dist/funnel.js CHANGED
@@ -15,12 +15,13 @@ import { installErrors } from './errors.js';
15
15
  import { batchContext } from './context.js';
16
16
  import { emailHash } from './sha256.js';
17
17
  import { visit } from './server.js';
18
- import { exposeVariants } from './exposure.js';
18
+ import { currentAB, startExposure } from './exposure.js';
19
19
  import { attribution } from './attribution.js';
20
20
  import { encodeAnswer, packAnswers } from './answers.js';
21
21
  import { LabelCtx, sameValue } from './offer.js';
22
22
  import { funnelSlug, setHandoff, visitorId } from './handoff.js';
23
23
  import { installPixel, metaCookies, readPixel, revokePixel, setPixel } from './pixel.js';
24
+ import { layoutOf, PaywallCtx, placementOf, readURLPlacement } from './placement.js';
24
25
  export const defineStep = (s) => s;
25
26
  export const defineFunnel = (f) => f;
26
27
  export const FunnelCtx = createContext(null);
@@ -146,8 +147,14 @@ function ShotView({ funnel, stepId }) {
146
147
  stepId: step.id, index, total: funnel.steps.length, progress: 0, shot: true, stopTracking: async () => { }, creator: null,
147
148
  visitorId: '', funnelId: funnelSlug() || funnel.id, userId: '', knownUser: false };
148
149
  }, [funnel, step, index]);
149
- const C = step.component;
150
- return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }) });
150
+ const content = useMemo(() => placementOf(step), [step]);
151
+ const pw = useMemo(() => (content ? { ...content, view_id: '' } : null), [content]);
152
+ const C = stepComponent(step, content);
153
+ return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }) }) });
154
+ }
155
+ // The component a step draws: the layout its placement serves when this build registered it (defineLayout), else its own.
156
+ function stepComponent(step, content) {
157
+ return (content && layoutOf(content.layout)) || step.component;
151
158
  }
152
159
  // root: the element the funnel is drawn into. With it, every click on a button, link or option inside goes as $click (clicks.ts);
153
160
  // mount() passes its element. A thumbnail never tracks clicks.
@@ -190,6 +197,15 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
190
197
  // server() sends the step on screen. Set while rendering, like onScreen: a step that calls its function in its own effect runs
191
198
  // that effect before any effect of the funnel would.
192
199
  visit.step = step.id;
200
+ // The placement of the step (placement.ts) and this view of it: a new id each time the step comes on screen (moving on to it,
201
+ // «Назад» to it), the same while it stays (a re-render). server() sends the placement's signed ctx, like the step.
202
+ const content = useMemo(() => placementOf(step), [step]);
203
+ const view = useRef({ step: '', id: '' });
204
+ if (view.current.step !== step.id)
205
+ view.current = { step: step.id, id: content ? crypto.randomUUID() : '' };
206
+ const viewId = view.current.id;
207
+ const pw = useMemo(() => (content ? { ...content, view_id: viewId } : null), [content, viewId]);
208
+ visit.placementCtx = content?.ctx ?? '';
193
209
  // A layout effect, not a plain one: the listener is on the root before the first paint, so a click right after the screen
194
210
  // appears is never missed (a plain effect can run after the browser has already delivered that click).
195
211
  useLayoutEffect(() => {
@@ -244,9 +260,32 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
244
260
  if (!started.current) {
245
261
  started.current = true;
246
262
  track('$funnel_start');
247
- exposeVariants(funnel.id, readBootstrap().variants, track);
263
+ startExposure(funnel.id, track);
264
+ // A hosted paywall's link the router did not take (not in the step placement's allowed_link_keys, a bad link token): the
265
+ // step shows its own placement; once per page.
266
+ const rej = readBootstrap().placement_rejected;
267
+ if (rej && typeof rej === 'object' && typeof rej.reason === 'string')
268
+ track('placement_rejected', { placement: String(rej.placement ?? ''), reason: rej.reason });
269
+ // The funnel is served at a url placement's address: the opening of the address is the placement's view (and the exposure of
270
+ // its experiment's arm), once per page load.
271
+ const u = readURLPlacement();
272
+ if (u) {
273
+ track('placement_viewed', { placement: u.placement, ...(u.experiment ? { experiment: u.experiment, arm: u.arm, phase: String(u.phase) } : {}),
274
+ surface: 'web_funnel', view_id: crypto.randomUUID() });
275
+ }
248
276
  }
249
277
  track('$step_view', {}, step.id);
278
+ // A view of a placement (spec §5): when the step is on screen, whatever its layout has loaded; one per view (a re-render is the
279
+ // same view). Without content (paused, nothing published) it still is a view of the placement, without a paywall.
280
+ if (step.placement) {
281
+ track('placement_viewed', content
282
+ ? { placement: content.placement, paywall: content.paywall, paywall_version: String(content.version),
283
+ ...(content.experiment ? { experiment: content.experiment, arm: content.arm, phase: String(content.phase) } : {}), surface: 'web_funnel', view_id: viewId }
284
+ : { placement: step.placement, surface: 'web_funnel' }, step.id);
285
+ if (content?.layout && !layoutOf(content.layout)) {
286
+ track('layout_missing', { placement: content.placement, paywall: content.paywall, paywall_version: String(content.version), layout: content.layout }, step.id);
287
+ }
288
+ }
250
289
  signals.current?.newView();
251
290
  }, [step.id]);
252
291
  const ctx = useMemo(() => {
@@ -285,8 +324,9 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
285
324
  backNav.current?.push(n);
286
325
  }
287
326
  };
288
- const visible = funnel.steps.filter(s => !s.when || s.when(answers)).length;
289
- const pos = funnel.steps.slice(0, index + 1).filter(s => !s.when || s.when(answers)).length;
327
+ const ab = currentAB();
328
+ const visible = funnel.steps.filter(s => !s.when || s.when(answers, ab)).length;
329
+ const pos = funnel.steps.slice(0, index + 1).filter(s => !s.when || s.when(answers, ab)).length;
290
330
  const funnelId = funnelSlug() || funnel.id;
291
331
  return {
292
332
  answers, set, track, stepId: step.id, index, total: visible, progress: visible ? pos / visible : 0, shot: false, stopTracking, creator,
@@ -302,8 +342,8 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
302
342
  } },
303
343
  };
304
344
  }, [answers, index, step, funnel, track, creator]);
305
- const C = step.component;
306
- return _jsx(LabelCtx.Provider, { value: withLabel, children: _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(C, {}) }) });
345
+ const C = stepComponent(step, content);
346
+ return _jsx(LabelCtx.Provider, { value: withLabel, children: _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx(C, {}) }) }) });
307
347
  }
308
348
  let current = null;
309
349
  export function track(name, props) { current?.track(name, props); }
@@ -335,6 +375,7 @@ export function mount(funnel, el = document.getElementById('root')) {
335
375
  setContent(b.content ?? {}, b.content_overrides ?? {});
336
376
  const shot = shotRequest(location.search);
337
377
  visit.step = '';
378
+ visit.placementCtx = '';
338
379
  visit.session = () => '';
339
380
  visit.off = () => false;
340
381
  if (shot) {
@@ -426,7 +467,8 @@ function startsOnFirstStep(funnel) {
426
467
  export function landingContext(funnel) {
427
468
  const none = () => { };
428
469
  const step = funnel.steps[0];
429
- const visible = funnel.steps.filter(s => !s.when || s.when({})).length;
470
+ const ab = currentAB();
471
+ const visible = funnel.steps.filter(s => !s.when || s.when({}, ab)).length;
430
472
  return { answers: {}, set: none, next: none, back: none, skip: none, track: none, stepId: step.id, index: 0, total: visible,
431
473
  progress: visible ? 1 / visible : 0, shot: false, stopTracking: async () => { }, creator: null, visitorId: '', funnelId: funnel.id,
432
474
  userId: '', knownUser: false };
package/dist/index.d.ts CHANGED
@@ -5,10 +5,12 @@ export { t, param, setContent } from './content.js';
5
5
  export { experiment, useExperiment, rule, useRule } from './experiments.js';
6
6
  export { bucketOf, pickArm } from './bucket.js';
7
7
  export { readBootstrap, readCreator, type Bootstrap, type Creator } from './bootstrap.js';
8
+ export { defineLayout, usePaywall, type Paywall, type PaywallProduct, type PlacementContent } from './placement.js';
8
9
  export { server, ServerFnError } from './server.js';
9
10
  export { autoOn, SERVICE_SIGNALS, type AutoTrack, type AutoTrackKey } from './autotrack.js';
10
11
  export { Tracker, safeProps, type FinalEvent, type StopScope } from './tracker.js';
11
12
  export { nextIndex, prevIndex, type StepDef, type StepRole, type Answers } from './nav.js';
13
+ export type { AB } from './exposure.js';
12
14
  export { uuidv7 } from './uuid.js';
13
15
  export { Hero } from './blocks/Hero.js';
14
16
  export { Choice } from './blocks/Choice.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { t, param, setContent } from './content.js';
5
5
  export { experiment, useExperiment, rule, useRule } from './experiments.js';
6
6
  export { bucketOf, pickArm } from './bucket.js';
7
7
  export { readBootstrap, readCreator } from './bootstrap.js';
8
+ export { defineLayout, usePaywall } from './placement.js';
8
9
  export { server, ServerFnError } from './server.js';
9
10
  export { autoOn, SERVICE_SIGNALS } from './autotrack.js';
10
11
  export { Tracker, safeProps } from './tracker.js';
package/dist/nav.d.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import type { ComponentType } from 'react';
2
+ import { type AB } from './exposure.js';
2
3
  export type Answers = Record<string, unknown>;
3
4
  export type StepRole = 'lead' | 'paywall' | 'landing';
4
5
  export declare const STEP_ROLES: readonly StepRole[];
5
6
  export type StepDef = {
6
7
  id: string;
7
8
  component: ComponentType;
8
- when?: (a: Answers) => boolean;
9
+ when?: (a: Answers, ab: AB) => boolean;
9
10
  role?: StepRole;
11
+ placement?: string;
10
12
  sensitive?: boolean;
11
13
  sample?: {
12
14
  answers?: Answers;
package/dist/nav.js CHANGED
@@ -1,5 +1,6 @@
1
+ import { currentAB } from './exposure.js';
1
2
  export const STEP_ROLES = ['lead', 'paywall', 'landing'];
2
- const shown = (s, a) => !s.when || s.when(a);
3
+ const shown = (s, a) => !s.when || s.when(a, currentAB());
3
4
  export function nextIndex(steps, a, from) {
4
5
  for (let i = from + 1; i < steps.length; i++)
5
6
  if (shown(steps[i], a))
@@ -0,0 +1,58 @@
1
+ import { type ComponentType } from 'react';
2
+ import type { StepDef } from './nav.js';
3
+ export declare const PLACEMENT_KEY: RegExp;
4
+ export type PaywallProduct = {
5
+ key: string;
6
+ name: string;
7
+ kind: string;
8
+ period: string;
9
+ access_levels: string[];
10
+ phases: {
11
+ kind: string;
12
+ period?: string;
13
+ }[];
14
+ price: {
15
+ price_id: string;
16
+ currency: string;
17
+ amount: number;
18
+ interval: string;
19
+ } | null;
20
+ refs: {
21
+ provider: string;
22
+ ref_kind: string;
23
+ ref: string;
24
+ }[];
25
+ };
26
+ export type PlacementContent = {
27
+ placement: string;
28
+ paywall: string;
29
+ version: number;
30
+ experiment: string;
31
+ arm: string;
32
+ phase: number;
33
+ layout: string;
34
+ config: Record<string, unknown>;
35
+ products: PaywallProduct[];
36
+ subject: string;
37
+ ctx: string;
38
+ };
39
+ export type Paywall = Readonly<PlacementContent & {
40
+ view_id: string;
41
+ }>;
42
+ export declare function readPlacement(c: unknown): PlacementContent | null;
43
+ export type URLPlacement = {
44
+ placement: string;
45
+ experiment: string;
46
+ arm: string;
47
+ phase: number;
48
+ subject: string;
49
+ };
50
+ export declare function readURLPlacement(): URLPlacement | null;
51
+ export declare function placementOf(step: StepDef): PlacementContent | null;
52
+ export declare function defineLayout<C extends ComponentType<any>>(key: string, component: C): C;
53
+ export declare function layoutOf(key: string): ComponentType | undefined;
54
+ export declare function clearLayouts(): void;
55
+ export declare const PaywallCtx: import("react").Context<Readonly<PlacementContent & {
56
+ view_id: string;
57
+ }> | null>;
58
+ export declare function usePaywall(): Paywall | null;
@@ -0,0 +1,65 @@
1
+ // Placements on a funnel step (kit 0.17.0; spec 2026-11-04-paywalls-placements §2.3–§2.4, §4.1, §5):
2
+ // defineStep({ id: 'paywall', role: 'paywall', placement: 'onboarding_paywall', component: Fallback })
3
+ // defineLayout('scholartobe_main', Paywall)
4
+ // const pw = usePaywall()
5
+ // `funnel-kit build` writes the step's placement into the manifest; publishing a build whose step names a placement the project
6
+ // does not have fails (placement_not_found). The router puts what the placement serves now into the bootstrap
7
+ // (bootstrap.placements, by the step's placement key): a paywall version — its layout, JSON config and products with the
8
+ // mirrored prices and the payment references. The step then draws the layout registered under that id (defineLayout), else its
9
+ // own component: the placement serves nothing (paused, nothing published), or the layout is not in this build (layout_missing).
10
+ // A view of a step with a placement sends placement_viewed (funnel.tsx).
11
+ import { createContext, useContext } from 'react';
12
+ import { readBootstrap } from './bootstrap.js';
13
+ export const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
14
+ const LAYOUT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
15
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
16
+ // A bootstrap entry as content, or null when it is not one.
17
+ export function readPlacement(c) {
18
+ if (!isObj(c) || typeof c.placement !== 'string' || !PLACEMENT_KEY.test(c.placement) || typeof c.paywall !== 'string')
19
+ return null;
20
+ return {
21
+ placement: c.placement, paywall: c.paywall, version: Number.isInteger(c.version) ? c.version : 0,
22
+ experiment: typeof c.experiment === 'string' ? c.experiment : '', arm: typeof c.arm === 'string' ? c.arm : '',
23
+ phase: Number.isInteger(c.phase) ? c.phase : 0, layout: typeof c.layout === 'string' ? c.layout : '', config: isObj(c.config) ? c.config : {},
24
+ products: Array.isArray(c.products) ? c.products.filter(isObj) : [],
25
+ subject: typeof c.subject === 'string' ? c.subject : '', ctx: typeof c.ctx === 'string' ? c.ctx : '',
26
+ };
27
+ }
28
+ export function readURLPlacement() {
29
+ const u = readBootstrap().placement_url;
30
+ if (!isObj(u) || typeof u.placement !== 'string' || !PLACEMENT_KEY.test(u.placement))
31
+ return null;
32
+ const experiment = typeof u.experiment === 'string' ? u.experiment : '';
33
+ const arm = experiment && typeof u.arm === 'string' ? u.arm : '';
34
+ return { placement: u.placement, experiment: arm ? experiment : '', arm, phase: Number.isInteger(u.phase) ? u.phase : 0,
35
+ subject: typeof u.subject === 'string' ? u.subject : '' };
36
+ }
37
+ // The content the bootstrap has for the step's placement, or null (no placement, nothing served, vite dev, an old router).
38
+ export function placementOf(step) {
39
+ if (!step.placement)
40
+ return null;
41
+ const all = readBootstrap().placements;
42
+ return readPlacement(isObj(all) ? all[step.placement] : undefined);
43
+ }
44
+ const layouts = new Map();
45
+ // defineLayout(key, Component): this build draws the paywall layout `key` (a paywall version's `layout`) with Component. Call it
46
+ // at module level, next to the funnel; the same key again replaces it.
47
+ export function defineLayout(key, component) {
48
+ if (!LAYOUT_KEY.test(key))
49
+ throw new Error(`defineLayout: bad layout key ${JSON.stringify(key)}: use ${LAYOUT_KEY.source}`);
50
+ if (typeof component !== 'function' && typeof component !== 'object')
51
+ throw new Error(`defineLayout('${key}'): not a component`);
52
+ layouts.set(key, component);
53
+ return component;
54
+ }
55
+ export function layoutOf(key) {
56
+ return key ? layouts.get(key) : undefined;
57
+ }
58
+ // For tests: forget the layouts.
59
+ export function clearLayouts() { layouts.clear(); }
60
+ export const PaywallCtx = createContext(null);
61
+ // The placement of the step on screen with this view's id, or null: the step names no placement, it serves nothing now, outside
62
+ // a funnel. Read texts and the highlighted plan from config, products and their prices from products.
63
+ export function usePaywall() {
64
+ return useContext(PaywallCtx);
65
+ }
package/dist/server.d.ts CHANGED
@@ -2,6 +2,7 @@ export declare const visit: {
2
2
  step: string;
3
3
  session: () => string;
4
4
  off: () => boolean;
5
+ placementCtx: string;
5
6
  };
6
7
  export declare class ServerFnError extends Error {
7
8
  readonly status: number;
package/dist/server.js CHANGED
@@ -8,7 +8,10 @@ const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
8
8
  // (mount() sets it). server() sends both, so the router's $fn_call and the function's ctx.track events join the person's
9
9
  // journey. Neither is a secret. Outside a mounted funnel, and in a thumbnail, both stay empty and nothing is sent.
10
10
  // off: stopTracking() was called; then neither is sent, so the router records no $fn_call for this visit.
11
- export const visit = { step: '', session: () => '', off: () => false };
11
+ // placementCtx (kit 0.17.0): the router's signed ctx of the placement on screen (placement.ts); sent as X-Placement-Ctx, so a
12
+ // function called from a hosted paywall opened by a link gets the link's placement (the router checks the signature).
13
+ export const visit = { step: '', session: () => '', off: () => false, placementCtx: '' };
14
+ const PLACEMENT_CTX = /^fsc1\.[A-Za-z0-9._-]{1,500}$/;
12
15
  function visitHeaders() {
13
16
  const out = {};
14
17
  let off = false;
@@ -29,6 +32,8 @@ function visitHeaders() {
29
32
  catch { /* storage blocked: the call goes without it */ }
30
33
  if (SESSION.test(session))
31
34
  out['X-Session'] = session;
35
+ if (PLACEMENT_CTX.test(visit.placementCtx))
36
+ out['X-Placement-Ctx'] = visit.placementCtx;
32
37
  return out;
33
38
  }
34
39
  // status 0 means the request never got an HTTP answer: 'bad_name' (not sent) or 'network' (fetch itself failed).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appright/funnelshift",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",