@appright/funnelshift 0.0.0-stage → 0.15.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.
Files changed (88) hide show
  1. package/bin/funnel-kit.js +2 -0
  2. package/dist/anchors.d.ts +4 -0
  3. package/dist/anchors.js +94 -0
  4. package/dist/answers.d.ts +11 -0
  5. package/dist/answers.js +100 -0
  6. package/dist/attribution.d.ts +34 -0
  7. package/dist/attribution.js +237 -0
  8. package/dist/autotrack.d.ts +4 -0
  9. package/dist/autotrack.js +13 -0
  10. package/dist/backnav.d.ts +15 -0
  11. package/dist/backnav.js +73 -0
  12. package/dist/blocks/Choice.d.ts +19 -0
  13. package/dist/blocks/Choice.js +38 -0
  14. package/dist/blocks/Cta.d.ts +7 -0
  15. package/dist/blocks/Cta.js +8 -0
  16. package/dist/blocks/Hero.d.ts +10 -0
  17. package/dist/blocks/Hero.js +48 -0
  18. package/dist/blocks/MultiChoice.d.ts +13 -0
  19. package/dist/blocks/MultiChoice.js +47 -0
  20. package/dist/blocks/Section.d.ts +6 -0
  21. package/dist/blocks/Section.js +4 -0
  22. package/dist/bootstrap.d.ts +33 -0
  23. package/dist/bootstrap.js +19 -0
  24. package/dist/bucket.d.ts +13 -0
  25. package/dist/bucket.js +41 -0
  26. package/dist/cli/args.d.ts +5 -0
  27. package/dist/cli/args.js +18 -0
  28. package/dist/cli/build.d.ts +6 -0
  29. package/dist/cli/build.js +200 -0
  30. package/dist/cli/extract.d.ts +32 -0
  31. package/dist/cli/extract.js +253 -0
  32. package/dist/cli/index.d.ts +2 -0
  33. package/dist/cli/index.js +38 -0
  34. package/dist/cli/init.d.ts +5 -0
  35. package/dist/cli/init.js +67 -0
  36. package/dist/cli/lint.d.ts +25 -0
  37. package/dist/cli/lint.js +535 -0
  38. package/dist/cli/upload.d.ts +29 -0
  39. package/dist/cli/upload.js +90 -0
  40. package/dist/clicks.d.ts +7 -0
  41. package/dist/clicks.js +102 -0
  42. package/dist/content.d.ts +7 -0
  43. package/dist/content.js +60 -0
  44. package/dist/context.d.ts +12 -0
  45. package/dist/context.js +52 -0
  46. package/dist/errors.d.ts +7 -0
  47. package/dist/errors.js +90 -0
  48. package/dist/experiments.d.ts +4 -0
  49. package/dist/experiments.js +24 -0
  50. package/dist/exposure.d.ts +4 -0
  51. package/dist/exposure.js +35 -0
  52. package/dist/functions.d.ts +37 -0
  53. package/dist/functions.js +24 -0
  54. package/dist/funnel.d.ts +64 -0
  55. package/dist/funnel.js +433 -0
  56. package/dist/handoff.d.ts +8 -0
  57. package/dist/handoff.js +66 -0
  58. package/dist/index.d.ts +17 -0
  59. package/dist/index.js +17 -0
  60. package/dist/nav.d.ts +19 -0
  61. package/dist/nav.js +14 -0
  62. package/dist/offer.d.ts +3 -0
  63. package/dist/offer.js +7 -0
  64. package/dist/pixel.d.ts +36 -0
  65. package/dist/pixel.js +173 -0
  66. package/dist/prerender.d.ts +2 -0
  67. package/dist/prerender.js +16 -0
  68. package/dist/server.d.ts +12 -0
  69. package/dist/server.js +88 -0
  70. package/dist/sha256.d.ts +2 -0
  71. package/dist/sha256.js +65 -0
  72. package/dist/signals.d.ts +18 -0
  73. package/dist/signals.js +300 -0
  74. package/dist/tracker.d.ts +80 -0
  75. package/dist/tracker.js +446 -0
  76. package/dist/uuid.d.ts +1 -0
  77. package/dist/uuid.js +12 -0
  78. package/dist/vitals.d.ts +4 -0
  79. package/dist/vitals.js +104 -0
  80. package/package.json +29 -4
  81. package/src/blocks/blocks.css +364 -0
  82. package/templates/init/CLAUDE.md +10 -0
  83. package/templates/init/funnels/welcome/index.tsx +129 -0
  84. package/templates/init/gitignore +5 -0
  85. package/templates/init/package.json.tmpl +20 -0
  86. package/templates/init/theme.css +77 -0
  87. package/templates/init/tsconfig.json +7 -0
  88. package/README.md +0 -3
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The main button of a screen. On phones the wrapper sticks to the bottom of the screen (see blocks.css).
3
+ // pending: the action is running — the button is blocked and marked aria-busy, the text stays as it is.
4
+ export function Cta(p) {
5
+ const blocked = !!p.pending || !!p.disabled;
6
+ return (_jsx("div", { className: "fk-cta", children: _jsx("button", { type: "button", className: "fk-cta__button", disabled: blocked, "aria-busy": p.pending ? true : undefined, onClick: () => { if (!blocked)
7
+ p.onClick(); }, children: p.children }) }));
8
+ }
@@ -0,0 +1,10 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function Hero(p: {
3
+ visual?: ReactNode;
4
+ title: string;
5
+ highlight?: string;
6
+ subtitle?: string;
7
+ cta: string;
8
+ onCta: () => void;
9
+ pending?: boolean;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
+ import { readBootstrap } from '../bootstrap.js';
4
+ import { Cta } from './Cta.js';
5
+ function prefersReducedMotion() {
6
+ try {
7
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ // Opening screen: a big visual, a very large headline with one fragment painted by a marker, a muted subtitle,
14
+ // the main button at the bottom. The marker animation is pure CSS (blocks.css); `fk-brush--static` is the
15
+ // reduced-motion hook: the final state at once.
16
+ // A highlight that wraps onto several lines cannot be painted by one pair of pseudo-element strokes, so it gets
17
+ // `fk-brush--wrap` (per-line background strokes). Checked after layout and whenever the headline resizes.
18
+ function useWrapped(dep) {
19
+ const ref = useRef(null);
20
+ const [wrapped, setWrapped] = useState(false);
21
+ useLayoutEffect(() => {
22
+ const el = ref.current;
23
+ if (!el) {
24
+ setWrapped(false);
25
+ return;
26
+ }
27
+ const check = () => setWrapped(el.getClientRects().length > 1);
28
+ check();
29
+ if (typeof ResizeObserver === 'undefined' || !el.parentElement)
30
+ return;
31
+ const ro = new ResizeObserver(check);
32
+ ro.observe(el.parentElement);
33
+ return () => ro.disconnect();
34
+ }, [dep]);
35
+ return [ref, wrapped];
36
+ }
37
+ export function Hero(p) {
38
+ const [still] = useState(prefersReducedMotion);
39
+ const [markRef, wrapped] = useWrapped(p.title + '\0' + p.highlight);
40
+ const at = p.highlight ? p.title.indexOf(p.highlight) : -1;
41
+ const missing = !!p.highlight && at < 0;
42
+ useEffect(() => {
43
+ if (missing && readBootstrap().env !== 'prod')
44
+ console.warn(`funnel-kit: Hero highlight "${p.highlight}" is not found in the title, shown without highlight`);
45
+ }, [missing, p.highlight, p.title]);
46
+ const title = at < 0 || !p.highlight ? p.title : (_jsxs(_Fragment, { children: [p.title.slice(0, at), _jsx("mark", { ref: markRef, className: ['fk-brush', still && 'fk-brush--static', wrapped && 'fk-brush--wrap'].filter(Boolean).join(' '), children: p.highlight }), p.title.slice(at + p.highlight.length)] }));
47
+ return (_jsxs("section", { className: "fk-hero", children: [_jsxs("div", { className: "fk-hero__main", children: [p.visual != null && _jsx("div", { className: "fk-hero__visual", children: p.visual }), _jsx("h1", { className: "fk-hero__title", children: title }), p.subtitle && _jsx("p", { className: "fk-hero__subtitle", children: p.subtitle })] }), _jsx(Cta, { onClick: p.onCta, pending: p.pending, children: p.cta })] }));
48
+ }
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { PickHandlers } from './Choice.js';
3
+ export declare function MultiChoice<T extends string>(p: {
4
+ options: {
5
+ value: T;
6
+ label: string;
7
+ icon?: ReactNode;
8
+ }[];
9
+ value?: T[];
10
+ name: string;
11
+ labelledBy?: string;
12
+ label?: string;
13
+ } & PickHandlers<T[], string[]>): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext, useRef, useState } from 'react';
3
+ import { LabelCtx } from '../offer.js';
4
+ // Several answers to one question: large tappable rows, each a checkbox. A group with a roving tab stop: Tab enters on the
5
+ // first chosen row (or the first one), arrows move the focus only, Enter/Space turn the focused row on or off.
6
+ // onChange gets the chosen values in the order of options, not in the order they were tapped.
7
+ //
8
+ // The group's accessible name: labelledBy (the id of the heading that asks the question, preferred) or label (aria-label).
9
+ // Inside a funnel, onChange={v => f.set('grades', v)} records the option texts too, as with Choice; onPick(values, labels)
10
+ // gets them directly (onPick alone is enough: onPick={(v, texts) => f.set('grades', v, texts)}).
11
+ export function MultiChoice(p) {
12
+ const refs = useRef([]);
13
+ const withLabel = useContext(LabelCtx);
14
+ const chosen = new Set(p.value ?? []);
15
+ const first = p.options.findIndex(o => chosen.has(o.value));
16
+ const [focus, setFocus] = useState(null);
17
+ const stop = focus !== null && focus < p.options.length ? focus : first >= 0 ? first : 0;
18
+ function toggle(i) {
19
+ const v = p.options[i].value;
20
+ const picked = p.options.filter(o => (o.value === v ? !chosen.has(v) : chosen.has(o.value)));
21
+ const values = picked.map(o => o.value);
22
+ const labels = picked.map(o => o.label);
23
+ p.onPick?.(values, labels);
24
+ withLabel(values, labels, () => p.onChange?.(values));
25
+ }
26
+ function onKeyDown(e, i) {
27
+ const n = p.options.length;
28
+ // Alt+Arrow is "back/forward" in browsers, Cmd/Ctrl+Arrow is an OS or browser shortcut: arrows with them are left alone.
29
+ const plain = !e.altKey && !e.metaKey && !e.ctrlKey;
30
+ let to = -1;
31
+ if (plain && (e.key === 'ArrowDown' || e.key === 'ArrowRight'))
32
+ to = (i + 1) % n;
33
+ else if (plain && (e.key === 'ArrowUp' || e.key === 'ArrowLeft'))
34
+ to = (i - 1 + n) % n;
35
+ else if (e.key === 'Enter' || e.key === ' ') {
36
+ e.preventDefault();
37
+ toggle(i);
38
+ return;
39
+ }
40
+ else
41
+ return;
42
+ e.preventDefault();
43
+ setFocus(to);
44
+ refs.current[to]?.focus();
45
+ }
46
+ return (_jsxs("div", { className: "fk-choice fk-choice--multi", role: "group", "aria-labelledby": p.labelledBy, "aria-label": p.label, children: [p.options.filter(o => chosen.has(o.value)).map(o => _jsx("input", { type: "hidden", name: p.name, value: o.value }, o.value)), p.options.map((o, i) => (_jsxs("button", { ref: el => { refs.current[i] = el; }, type: "button", role: "checkbox", "aria-checked": chosen.has(o.value), tabIndex: i === stop ? 0 : -1, className: "fk-choice__option", onClick: () => { setFocus(i); toggle(i); }, onKeyDown: e => onKeyDown(e, i), children: [o.icon != null && _jsx("span", { className: "fk-choice__icon", "aria-hidden": "true", children: o.icon }), _jsx("span", { className: "fk-choice__label", children: o.label }), _jsx("span", { className: "fk-choice__mark", "aria-hidden": "true" })] }, o.value)))] }));
47
+ }
@@ -0,0 +1,6 @@
1
+ import type { HTMLAttributes } from 'react';
2
+ export type SectionProps = HTMLAttributes<HTMLElement> & {
3
+ name: string;
4
+ as?: 'section' | 'div' | 'header' | 'footer' | 'main' | 'article' | 'aside';
5
+ };
6
+ export declare function Section({ name, as: Tag, ...rest }: SectionProps): import("react").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function Section({ name, as: Tag = 'section', ...rest }) {
3
+ return _jsx(Tag, { ...rest, "data-section": name });
4
+ }
@@ -0,0 +1,33 @@
1
+ export type Env = 'prod' | 'preview' | 'dev';
2
+ export type Creator = {
3
+ code: string;
4
+ name: string;
5
+ avatar_url: string | null;
6
+ };
7
+ export type Bootstrap = {
8
+ project: string;
9
+ funnel: string;
10
+ release_id: string;
11
+ build_id: string;
12
+ env: Env;
13
+ ingest_url: string | null;
14
+ ingest_key: string;
15
+ visitor_id: string;
16
+ content?: Record<string, string>;
17
+ ingest_fallback_url?: string | null;
18
+ fn_preview?: string;
19
+ creator?: Creator;
20
+ variants?: Record<string, string>;
21
+ content_overrides?: Record<string, string | number | boolean>;
22
+ fn_arm?: string;
23
+ fn_base?: string;
24
+ geo?: string;
25
+ request_id?: string;
26
+ meta_pixel?: {
27
+ id: string;
28
+ lead_step?: string;
29
+ checkout_step?: string;
30
+ };
31
+ };
32
+ export declare function readBootstrap(win?: Window): Bootstrap;
33
+ export declare function readCreator(win?: Window): Creator | null;
@@ -0,0 +1,19 @@
1
+ // The edge router injects window.__FUNNEL__ into index.html; without it (vite dev) events stay local.
2
+ export function readBootstrap(win = window) {
3
+ const b = win.__FUNNEL__;
4
+ if (b)
5
+ return b;
6
+ return { project: 'dev', funnel: 'dev', release_id: '', build_id: 'dev', env: 'dev', ingest_url: null,
7
+ ingest_key: '', visitor_id: crypto.randomUUID() };
8
+ }
9
+ const CODE = /^[a-z0-9._-]{1,64}$/;
10
+ // The creator of the bootstrap, or null when there is none or it is malformed.
11
+ export function readCreator(win = window) {
12
+ const c = win.__FUNNEL__?.creator;
13
+ if (!c || typeof c !== 'object' || Array.isArray(c))
14
+ return null;
15
+ const { code, name, avatar_url } = c;
16
+ if (typeof code !== 'string' || !CODE.test(code) || typeof name !== 'string' || name === '')
17
+ return null;
18
+ return { code, name, avatar_url: typeof avatar_url === 'string' && avatar_url.startsWith('https://') ? avatar_url : null };
19
+ }
@@ -0,0 +1,13 @@
1
+ export declare const BUCKETS = 10000;
2
+ export declare function fnv1a64(s: string): bigint;
3
+ export declare function bucketOf(salt: string, visitor: string): number;
4
+ export declare function pickArm<A extends {
5
+ weight: number;
6
+ }>(arms: readonly A[], bucket: number): A | null;
7
+ export declare function assignArm<A extends {
8
+ weight: number;
9
+ }>(arms: readonly A[], salt: string, visitor: string): A | null;
10
+ export declare function pickRange<R extends {
11
+ from: number;
12
+ to: number;
13
+ }>(ranges: readonly R[], bucket: number): R | null;
package/dist/bucket.js ADDED
@@ -0,0 +1,41 @@
1
+ // A/B assignment (spec 2026-10-20 «A/B» §2): bucket = FNV-1a 64 of the UTF-8 string "<salt>:<visitor_id>" mod 10000, the arm is
2
+ // the first whose running sum of weights (basis points, in the order of arms) is above the bucket. The router assigns with this
3
+ // file (edge/router/src/experiment.ts imports it); the server's Go copy must give the same buckets
4
+ // (docs/superpowers/specs/2026-10-20-ab-bucket-vectors.json). No imports: the router bundles this file as it is.
5
+ const OFFSET = 0xcbf29ce484222325n;
6
+ const PRIME = 0x100000001b3n;
7
+ const MASK = 0xffffffffffffffffn;
8
+ const utf8 = new TextEncoder();
9
+ export const BUCKETS = 10000;
10
+ export function fnv1a64(s) {
11
+ let h = OFFSET;
12
+ for (const b of utf8.encode(s))
13
+ h = ((h ^ BigInt(b)) * PRIME) & MASK;
14
+ return h;
15
+ }
16
+ export function bucketOf(salt, visitor) {
17
+ return Number(fnv1a64(`${salt}:${visitor}`) % BigInt(BUCKETS));
18
+ }
19
+ // The arm the bucket falls in, or null when the weights add up to no more than the bucket (the server keeps the sum at 10000).
20
+ export function pickArm(arms, bucket) {
21
+ let sum = 0;
22
+ for (const a of arms) {
23
+ sum += Number.isFinite(a.weight) && a.weight > 0 ? a.weight : 0;
24
+ if (bucket < sum)
25
+ return a;
26
+ }
27
+ return null;
28
+ }
29
+ export function assignArm(arms, salt, visitor) {
30
+ return pickArm(arms, bucketOf(salt, visitor));
31
+ }
32
+ // Layers (v2 track D): a layer gives the visitor one bucket, bucketOf(layer_salt, visitor), and each experiment of the layer owns a
33
+ // range [from, to) of the 10000 buckets (the server allocates them when it starts an experiment). The key of the first range the
34
+ // bucket falls in, or null when it falls in none (a gap: the visitor is in no experiment of the layer).
35
+ // Shared vectors: docs/superpowers/specs/2026-10-24-ab-layer-vectors.json.
36
+ export function pickRange(ranges, bucket) {
37
+ for (const r of ranges)
38
+ if (bucket >= r.from && bucket < r.to)
39
+ return r;
40
+ return null;
41
+ }
@@ -0,0 +1,5 @@
1
+ declare function value(argv: string[], name: string): string | undefined;
2
+ export declare function arg(argv: string[], name: string): string;
3
+ export declare const opt: typeof value;
4
+ export declare const flag: (argv: string[], name: string) => boolean;
5
+ export {};
@@ -0,0 +1,18 @@
1
+ // A flag with a value: `--name value`. A missing value or another flag in its place (`--dir --force`) is an error, never swallowed.
2
+ function value(argv, name) {
3
+ const i = argv.indexOf('--' + name);
4
+ if (i < 0)
5
+ return undefined;
6
+ const v = argv[i + 1];
7
+ if (v === undefined || v.startsWith('--'))
8
+ throw new Error(`--${name} needs a value`);
9
+ return v;
10
+ }
11
+ export function arg(argv, name) {
12
+ const v = value(argv, name);
13
+ if (v === undefined)
14
+ throw new Error(`--${name} is required`);
15
+ return v;
16
+ }
17
+ export const opt = value;
18
+ export const flag = (argv, name) => argv.includes('--' + name);
@@ -0,0 +1,6 @@
1
+ export declare function buildFunnel(root: string, slug: string, info: {
2
+ commit: string;
3
+ branch: string;
4
+ }, opts?: {
5
+ warn?: (text: string) => void;
6
+ }): Promise<string>;
@@ -0,0 +1,200 @@
1
+ import { build } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
4
+ import { isBuiltin } from 'node:module';
5
+ import { join, relative } from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { extractContent, extractPrerender, extractSteps } from './extract.js';
8
+ import { check, formatLint, formatWarnings } from './lint.js';
9
+ // The source files under dir, without node_modules; noTests also leaves out *.test.* and *.spec.* (functions/ may keep its tests
10
+ // next to the code, and they are not part of the bundle).
11
+ function sources(dir, noTests = false) {
12
+ if (!existsSync(dir))
13
+ return [];
14
+ return readdirSync(dir).flatMap(n => {
15
+ const p = join(dir, n);
16
+ if (statSync(p).isDirectory())
17
+ return n === 'node_modules' ? [] : sources(p, noTests);
18
+ return /\.(tsx?|jsx?)$/.test(n) && !(noTests && /\.(test|spec)\.[^.]+$/.test(n)) ? [p] : [];
19
+ });
20
+ }
21
+ const kitPackage = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
22
+ const kitVersion = kitPackage.version;
23
+ // The kit is @appright/funnelshift since 0.15.0 (Руслан 01.11: the product is FunnelShift); before it was @appright/funnel-kit. A
24
+ // funnel's code may import either name: the old one is read as the new one everywhere below, so a project only renames its
25
+ // dependency. KIT is the name of this very package, which the generated entries import.
26
+ const KIT = kitPackage.name;
27
+ const OLD_KIT = '@appright/funnel-kit';
28
+ // An import of the kit (either name) with the given subpath, e.g. kitImport('/functions').
29
+ const kitImport = (sub = '') => new RegExp(`^@appright\\/(funnelshift|funnel-kit)${sub.replace('/', '\\/')}$`);
30
+ const MAX_FUNCTIONS_BUNDLE = 1 << 20; // the server's limit for _functions.js (spec §6)
31
+ const MAX_FUNCTIONS = 64; // the server's limit for manifest.functions
32
+ const FUNCTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
33
+ // The kit's own functions module, next to this cli folder: dist/functions.js when installed, src/functions.ts when the tests
34
+ // run the sources. The bundle always inlines this copy, so the marker read below and the helpers in _functions.js come from
35
+ // the same kit version, whatever the project's node_modules hold.
36
+ function kitFunctionsModule() {
37
+ for (const n of ['../functions.js', '../functions.ts']) {
38
+ const p = fileURLToPath(new URL(n, import.meta.url));
39
+ if (existsSync(p))
40
+ return p;
41
+ }
42
+ throw new Error(`funnelshift is missing its functions module (reinstall ${KIT})`);
43
+ }
44
+ // functions/index.ts (or .js) -> one self-contained ESM file for a Worker isolate (spec §3, §5): every import inlined, no node
45
+ // builtins (an isolate has none), es2022. Vite's SSR build with ssr.target 'webworker' resolves packages with their worker/
46
+ // browser conditions; it would quietly leave a builtin as an external import, so any import left in the chunk fails the build.
47
+ async function bundleFunctions(root, entry) {
48
+ const rel = relative(root, entry);
49
+ const res = await build({
50
+ configFile: false, root, logLevel: 'error', publicDir: false,
51
+ resolve: { alias: [{ find: kitImport('/functions'), replacement: kitFunctionsModule() }] },
52
+ // Libraries branch on process.env.NODE_ENV, and `process` does not exist in the isolate.
53
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') },
54
+ ssr: { noExternal: true, target: 'webworker' },
55
+ build: {
56
+ ssr: entry, write: false, minify: false, target: 'es2022', copyPublicDir: false, ssrManifest: false,
57
+ // No `//#region <path>` markers: they carry paths of the building machine, which would make the same code a different
58
+ // artifact on CI and on a laptop.
59
+ rolldownOptions: { experimental: { attachDebugInfo: 'none' }, output: { format: 'es', codeSplitting: false } },
60
+ },
61
+ });
62
+ const outputs = (Array.isArray(res) ? res : [res]).flatMap(r => 'output' in r ? r.output : []);
63
+ const chunks = outputs.filter(o => o.type === 'chunk');
64
+ if (chunks.length !== 1 || outputs.length !== 1) {
65
+ throw new Error(`${rel}: the functions bundle must be one JS file, got ${outputs.map(o => o.fileName).join(', ')}`);
66
+ }
67
+ const chunk = chunks[0];
68
+ const left = [...chunk.imports, ...chunk.dynamicImports];
69
+ if (left.length) {
70
+ const builtins = left.filter(m => m.startsWith('node:') || isBuiltin(m));
71
+ throw new Error(builtins.length
72
+ ? `${rel} needs node built-in modules (${builtins.join(', ')}): functions run in a Worker isolate without them; use fetch and Web APIs`
73
+ : `${rel}: the functions bundle still imports ${left.join(', ')}; every dependency must be bundled`);
74
+ }
75
+ const size = Buffer.byteLength(chunk.code);
76
+ if (size > MAX_FUNCTIONS_BUNDLE)
77
+ throw new Error(`${rel}: the functions bundle is ${size} bytes, the limit is ${MAX_FUNCTIONS_BUNDLE}`);
78
+ return chunk.code;
79
+ }
80
+ // A module of the kit itself (dist/<name>.js when installed, src/<name>.ts(x) when the tests run the sources): the prerender bundles
81
+ // the kit's own copy and aliases the funnel's imports of the kit (either name) to it, so the funnel and the renderer share one
82
+ // FunnelCtx whatever the project's node_modules hold.
83
+ function kitModule(name) {
84
+ for (const n of [`../${name}.js`, `../${name}.tsx`, `../${name}.ts`]) {
85
+ const p = fileURLToPath(new URL(n, import.meta.url));
86
+ if (existsSync(p))
87
+ return p;
88
+ }
89
+ throw new Error(`funnelshift is missing its ${name} module (reinstall ${KIT})`);
90
+ }
91
+ // `prerender: true`: the landing step drawn at build time (react-dom/server) and put inside <div id="root"> of index.html, which the
92
+ // kit hydrates (mount()). The funnel's code is bundled for Node and run once; a landing that touches window or document while it
93
+ // renders fails the build with the error, so the author guards it (or turns prerender off).
94
+ async function prerenderLanding(tmp, entry, slug, out) {
95
+ const ssrEntry = join(tmp, 'ssr.tsx');
96
+ writeFileSync(ssrEntry, `import funnel from '${relative(tmp, entry).replace(/\.tsx$/, '')}'\nimport { renderLanding } from '${KIT}/prerender'\nexport const render = () => renderLanding(funnel)\n`);
97
+ const ssrOut = join(tmp, 'ssr');
98
+ await build({
99
+ configFile: false, root: tmp, logLevel: 'error', publicDir: false, plugins: [react()],
100
+ resolve: { alias: [
101
+ { find: kitImport('/prerender'), replacement: kitModule('prerender') },
102
+ { find: kitImport(), replacement: kitModule('index') },
103
+ ] },
104
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') },
105
+ ssr: { noExternal: [KIT, OLD_KIT] },
106
+ build: { ssr: ssrEntry, outDir: ssrOut, emptyOutDir: true, minify: false, target: 'node20', copyPublicDir: false,
107
+ rolldownOptions: { output: { format: 'es', codeSplitting: false, entryFileNames: 'ssr.mjs' } } },
108
+ });
109
+ let html;
110
+ try {
111
+ const mod = await import(pathToFileURL(join(ssrOut, 'ssr.mjs')).href + '?t=' + Date.now());
112
+ html = mod.render();
113
+ }
114
+ catch (e) {
115
+ throw new Error(`prerender of funnels/${slug} failed: ${e.message}\n` +
116
+ `The landing step is drawn once at build time in Node, without a window or a document: read the browser's objects in useEffect, ` +
117
+ `or remove "prerender: true" from defineFunnel().`);
118
+ }
119
+ const page = join(out, 'index.html');
120
+ const shell = readFileSync(page, 'utf8');
121
+ const marker = '<div id="root"></div>';
122
+ if (shell.split(marker).length !== 2)
123
+ throw new Error(`prerender: index.html of funnels/${slug} does not hold exactly one ${marker}`);
124
+ writeFileSync(page, shell.replace(marker, () => `<div id="root" data-prerendered="landing">${html}</div>`));
125
+ }
126
+ // The names come from the built module itself, the same object the router's host will call, so the manifest cannot drift
127
+ // from the code. Importing runs the module's top level in Node, which also surfaces defineFunctions' name checks here.
128
+ async function functionNames(file, rel) {
129
+ let mod;
130
+ try {
131
+ mod = await import(pathToFileURL(file).href + '?t=' + Date.now());
132
+ }
133
+ catch (e) {
134
+ throw new Error(`${rel} failed to load: ${e.message}`);
135
+ }
136
+ const def = mod?.default;
137
+ if (!def || def.__funnelFunctions !== 1 || !def.functions || typeof def.functions !== 'object') {
138
+ throw new Error(`${rel} must \`export default defineFunctions({...})\` from '${KIT}/functions'`);
139
+ }
140
+ const names = Object.keys(def.functions).sort();
141
+ const bad = names.filter(n => !FUNCTION_NAME.test(n) || typeof def.functions[n] !== 'function');
142
+ if (bad.length)
143
+ throw new Error(`${rel}: bad functions ${bad.join(', ')} (names match ${FUNCTION_NAME.source}, values are functions)`);
144
+ if (!names.length)
145
+ throw new Error(`${rel}: defineFunctions({}) has no functions; remove the functions folder instead`);
146
+ if (names.length > MAX_FUNCTIONS)
147
+ throw new Error(`${rel}: ${names.length} functions, the limit is ${MAX_FUNCTIONS}`);
148
+ return names;
149
+ }
150
+ // warn gets the lint warnings as one text (console.warn by default, so stdout keeps only the output path); they never stop the build.
151
+ export async function buildFunnel(root, slug, info, opts = {}) {
152
+ const entry = join(root, 'funnels', slug, 'index.tsx');
153
+ if (!existsSync(entry))
154
+ throw new Error(`no funnels/${slug}/index.tsx`);
155
+ const read = (p) => ({ path: relative(root, p), code: readFileSync(p, 'utf8') });
156
+ const srcs = [...sources(join(root, 'funnels', slug)), ...sources(join(root, 'components'))].map(read);
157
+ // functions/ is checked for its event names only (lint's own scope); its texts are not the funnel's content.
158
+ const { errors, warnings } = check([...srcs, ...sources(join(root, 'functions'), true).map(read)]);
159
+ if (warnings.length)
160
+ (opts.warn ?? console.warn)(formatWarnings(warnings));
161
+ if (errors.length)
162
+ throw new Error(formatLint(errors));
163
+ const content = extractContent(srcs);
164
+ const steps = extractSteps(srcs);
165
+ const prerender = extractPrerender(srcs);
166
+ if (prerender && steps[0]?.role !== 'landing') {
167
+ throw new Error(`funnels/${slug}: prerender: true draws the landing step into index.html, so the first step needs role: 'landing'`);
168
+ }
169
+ const tmp = join(root, '.funnel-kit', slug);
170
+ mkdirSync(tmp, { recursive: true });
171
+ // The blocks stylesheet goes first: theme.css comes later and overrides its --fk-* variables.
172
+ const theme = existsSync(join(root, 'theme.css')) ? `import '${relative(tmp, join(root, 'theme.css'))}'\n` : '';
173
+ writeFileSync(join(tmp, 'main.tsx'), `import '${KIT}/blocks.css'\n${theme}import { mount } from '${KIT}'\nimport funnel from '${relative(tmp, entry).replace(/\.tsx$/, '')}'\nmount(funnel)\n`);
174
+ writeFileSync(join(tmp, 'index.html'), `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"></head>` +
175
+ `<body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>`);
176
+ // Functions are bundled and checked before the page build, so a broken functions/ leaves no half-built dist behind; the file
177
+ // is copied in afterwards because the page build empties the out dir.
178
+ const fnEntry = ['index.ts', 'index.js'].map(n => join(root, 'functions', n)).find(p => existsSync(p));
179
+ let fn;
180
+ if (fnEntry) {
181
+ const code = await bundleFunctions(root, fnEntry);
182
+ const staged = join(tmp, '_functions.js');
183
+ writeFileSync(staged, code);
184
+ fn = { code, names: await functionNames(staged, relative(root, fnEntry)) };
185
+ }
186
+ const out = join(root, 'dist', slug);
187
+ // the funnel's imports of the old name resolve to the installed kit
188
+ await build({ root: tmp, base: './', logLevel: 'warn', plugins: [react()], resolve: { alias: [{ find: /^@appright\/funnel-kit(?=\/|$)/, replacement: KIT }] },
189
+ build: { outDir: out, emptyOutDir: true } });
190
+ if (prerender)
191
+ await prerenderLanding(tmp, entry, slug, out);
192
+ if (fn)
193
+ writeFileSync(join(out, '_functions.js'), fn.code);
194
+ writeFileSync(join(out, 'manifest.json'), JSON.stringify({
195
+ funnel: slug, kit_version: kitVersion, built_at: new Date().toISOString(), commit: info.commit, branch: info.branch, steps, content,
196
+ ...(prerender ? { prerender: true } : {}),
197
+ ...(fn ? { functions: fn.names } : {}),
198
+ }, null, 2));
199
+ return out;
200
+ }
@@ -0,0 +1,32 @@
1
+ import { type StepRole } from '../nav.js';
2
+ type Item = {
3
+ key: string;
4
+ default: string;
5
+ };
6
+ export declare function literal(n: any): string | null;
7
+ export declare function walk(node: any, visit: (n: any) => void): void;
8
+ export declare function parseSource(f: {
9
+ path: string;
10
+ code: string;
11
+ }): {
12
+ program: any;
13
+ };
14
+ export declare function extractContent(files: {
15
+ path: string;
16
+ code: string;
17
+ }[]): Item[];
18
+ type Src = {
19
+ path: string;
20
+ code: string;
21
+ };
22
+ export type ManifestStep = {
23
+ id: string;
24
+ role?: StepRole;
25
+ sensitive?: true;
26
+ conditional?: true;
27
+ when_keys?: string[];
28
+ };
29
+ export declare function whenKeys(fn: any): string[] | null;
30
+ export declare function extractSteps(files: Src[]): ManifestStep[];
31
+ export declare function extractPrerender(files: Src[]): boolean;
32
+ export {};