@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.
- package/bin/funnel-kit.js +2 -0
- package/dist/anchors.d.ts +4 -0
- package/dist/anchors.js +94 -0
- package/dist/answers.d.ts +11 -0
- package/dist/answers.js +100 -0
- package/dist/attribution.d.ts +34 -0
- package/dist/attribution.js +237 -0
- package/dist/autotrack.d.ts +4 -0
- package/dist/autotrack.js +13 -0
- package/dist/backnav.d.ts +15 -0
- package/dist/backnav.js +73 -0
- package/dist/blocks/Choice.d.ts +19 -0
- package/dist/blocks/Choice.js +38 -0
- package/dist/blocks/Cta.d.ts +7 -0
- package/dist/blocks/Cta.js +8 -0
- package/dist/blocks/Hero.d.ts +10 -0
- package/dist/blocks/Hero.js +48 -0
- package/dist/blocks/MultiChoice.d.ts +13 -0
- package/dist/blocks/MultiChoice.js +47 -0
- package/dist/blocks/Section.d.ts +6 -0
- package/dist/blocks/Section.js +4 -0
- package/dist/bootstrap.d.ts +33 -0
- package/dist/bootstrap.js +19 -0
- package/dist/bucket.d.ts +13 -0
- package/dist/bucket.js +41 -0
- package/dist/cli/args.d.ts +5 -0
- package/dist/cli/args.js +18 -0
- package/dist/cli/build.d.ts +6 -0
- package/dist/cli/build.js +200 -0
- package/dist/cli/extract.d.ts +32 -0
- package/dist/cli/extract.js +253 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +38 -0
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +67 -0
- package/dist/cli/lint.d.ts +25 -0
- package/dist/cli/lint.js +535 -0
- package/dist/cli/upload.d.ts +29 -0
- package/dist/cli/upload.js +90 -0
- package/dist/clicks.d.ts +7 -0
- package/dist/clicks.js +102 -0
- package/dist/content.d.ts +7 -0
- package/dist/content.js +60 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +52 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +90 -0
- package/dist/experiments.d.ts +4 -0
- package/dist/experiments.js +24 -0
- package/dist/exposure.d.ts +4 -0
- package/dist/exposure.js +35 -0
- package/dist/functions.d.ts +37 -0
- package/dist/functions.js +24 -0
- package/dist/funnel.d.ts +64 -0
- package/dist/funnel.js +433 -0
- package/dist/handoff.d.ts +8 -0
- package/dist/handoff.js +66 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/nav.d.ts +19 -0
- package/dist/nav.js +14 -0
- package/dist/offer.d.ts +3 -0
- package/dist/offer.js +7 -0
- package/dist/pixel.d.ts +36 -0
- package/dist/pixel.js +173 -0
- package/dist/prerender.d.ts +2 -0
- package/dist/prerender.js +16 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +88 -0
- package/dist/sha256.d.ts +2 -0
- package/dist/sha256.js +65 -0
- package/dist/signals.d.ts +18 -0
- package/dist/signals.js +300 -0
- package/dist/tracker.d.ts +80 -0
- package/dist/tracker.js +446 -0
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +12 -0
- package/dist/vitals.d.ts +4 -0
- package/dist/vitals.js +104 -0
- package/package.json +29 -4
- package/src/blocks/blocks.css +364 -0
- package/templates/init/CLAUDE.md +10 -0
- package/templates/init/funnels/welcome/index.tsx +129 -0
- package/templates/init/gitignore +5 -0
- package/templates/init/package.json.tmpl +20 -0
- package/templates/init/theme.css +77 -0
- package/templates/init/tsconfig.json +7 -0
- package/README.md +0 -3
package/dist/cli/lint.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { literal, parseSource, walk } from './extract.js';
|
|
2
|
+
// Only the funnel's own code is checked (the paths build passes are relative to the repo root). The text rules apply to the
|
|
3
|
+
// screens; the event rules also to the project's server functions.
|
|
4
|
+
const norm = (path) => path.replace(/\\/g, '/');
|
|
5
|
+
const inScope = (path) => /^(funnels|components)\//.test(norm(path));
|
|
6
|
+
const inEventScope = (path) => /^(funnels|components|functions)\//.test(norm(path));
|
|
7
|
+
const hasLetter = (s) => /\p{L}/u.test(s);
|
|
8
|
+
// Attributes whose string value is shown or read aloud to the visitor; className, id, href, src, role, data-* and the like are not text.
|
|
9
|
+
const TEXT_ATTRS = new Set(['alt', 'aria-label', 'title', 'placeholder']);
|
|
10
|
+
// Props of the kit's Hero that hold visible text. `title` is also a generic text attribute above, which already reports it.
|
|
11
|
+
const HERO_TEXT_PROPS = ['title', 'subtitle', 'highlight', 'cta'];
|
|
12
|
+
// Content of these elements is code, not copy.
|
|
13
|
+
const CODE_TAGS = new Set(['style', 'script']);
|
|
14
|
+
// Case matters except for lorem ipsum: "Todo lo que necesitas", "Exxon" and "XXL size" are real copy, TODO / TBD / XX masks are not.
|
|
15
|
+
const PLACEHOLDER = [/lorem ipsum/i, /\bTODO\b/, /\bTBD\b/, /\$?\bX{2,}(?:[,.]X{2,})*\b/];
|
|
16
|
+
const unwrap = (n) => (n && ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(n.type) ? unwrap(n.expression) : n);
|
|
17
|
+
// String pieces an expression can render as text: a literal, or a literal in a branch (`a ? 'Yes' : 'No'`, `ok && 'Done'`).
|
|
18
|
+
function literals(e) {
|
|
19
|
+
e = unwrap(e);
|
|
20
|
+
switch (e?.type) {
|
|
21
|
+
case 'StringLiteral': return [e];
|
|
22
|
+
case 'TemplateLiteral': return [e];
|
|
23
|
+
case 'ConditionalExpression': return [...literals(e.consequent), ...literals(e.alternate)];
|
|
24
|
+
case 'LogicalExpression': return [...literals(e.left), ...literals(e.right)];
|
|
25
|
+
default: return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const textOf = (n) => n.type === 'TemplateLiteral' ? n.quasis.map((q) => q.value.cooked ?? '').join(' ') : n.value;
|
|
29
|
+
const clip = (s) => { const one = s.trim().replace(/\s+/g, ' '); return one.length > 40 ? one.slice(0, 40) + '…' : one; };
|
|
30
|
+
const tag = (el) => el.openingElement?.name?.name ?? '';
|
|
31
|
+
// Hand-written copy that bypasses t() cannot be edited in the admin and shows up untranslated, and a placeholder default
|
|
32
|
+
// that reaches a live funnel is visible to visitors; build refuses both with the file and line.
|
|
33
|
+
function textRules(program, add) {
|
|
34
|
+
const text = (n, shown) => add(n, 'text-outside-t', `text "${clip(shown)}" must go through t('key', 'default')`);
|
|
35
|
+
// `const OPTIONS = [...]` next to the funnel: Choice options are often kept there, so look one step behind an identifier.
|
|
36
|
+
const consts = new Map();
|
|
37
|
+
walk(program, n => { if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.init)
|
|
38
|
+
consts.set(n.id.name, n.init); });
|
|
39
|
+
const propName = (a) => (a.name?.type === 'JSXIdentifier' ? a.name.name : '');
|
|
40
|
+
// The kit's blocks take their copy as props: a literal there bypasses t() just like JSX text does.
|
|
41
|
+
const blockProps = (el) => {
|
|
42
|
+
const name = tag(el);
|
|
43
|
+
for (const a of el.openingElement?.attributes ?? []) {
|
|
44
|
+
if (a.type !== 'JSXAttribute' || !a.value)
|
|
45
|
+
continue;
|
|
46
|
+
const v = a.value;
|
|
47
|
+
const exprs = v.type === 'JSXExpressionContainer' ? v.expression : v;
|
|
48
|
+
if (name === 'Hero' && HERO_TEXT_PROPS.includes(propName(a)) && !TEXT_ATTRS.has(propName(a))) {
|
|
49
|
+
for (const l of literals(exprs))
|
|
50
|
+
if (hasLetter(textOf(l)))
|
|
51
|
+
add(l, 'text-outside-t', `Hero ${propName(a)}="${clip(textOf(l))}" must go through t('key', 'default')`);
|
|
52
|
+
}
|
|
53
|
+
else if ((name === 'Choice' || name === 'MultiChoice') && propName(a) === 'options') {
|
|
54
|
+
let list = unwrap(exprs);
|
|
55
|
+
if (list?.type === 'Identifier')
|
|
56
|
+
list = unwrap(consts.get(list.name));
|
|
57
|
+
if (list?.type !== 'ArrayExpression')
|
|
58
|
+
continue;
|
|
59
|
+
for (const item of list.elements) {
|
|
60
|
+
const o = unwrap(item);
|
|
61
|
+
if (o?.type !== 'ObjectExpression')
|
|
62
|
+
continue;
|
|
63
|
+
for (const p of o.properties) {
|
|
64
|
+
if (p.type !== 'ObjectProperty' || p.computed || (p.key.name ?? p.key.value) !== 'label')
|
|
65
|
+
continue;
|
|
66
|
+
for (const l of literals(p.value))
|
|
67
|
+
if (hasLetter(textOf(l)))
|
|
68
|
+
add(l, 'text-outside-t', `${name} option label "${clip(textOf(l))}" must go through t('key', 'default')`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
walk(program, n => {
|
|
75
|
+
if (n.type === 'JSXElement')
|
|
76
|
+
blockProps(n);
|
|
77
|
+
if (n.type === 'JSXElement' || n.type === 'JSXFragment') {
|
|
78
|
+
if (n.type === 'JSXElement' && CODE_TAGS.has(tag(n)))
|
|
79
|
+
return;
|
|
80
|
+
for (const c of n.children) {
|
|
81
|
+
if (c.type === 'JSXText') {
|
|
82
|
+
const raw = c.extra?.raw ?? c.value;
|
|
83
|
+
if (c.value.trim() && hasLetter(c.value)) {
|
|
84
|
+
// Point at the first non-blank line of the text, not at the whitespace before it.
|
|
85
|
+
const lead = raw.slice(0, raw.length - raw.trimStart().length);
|
|
86
|
+
text({ loc: { start: { line: c.loc.start.line + (lead.match(/\n/g)?.length ?? 0), column: 0 } } }, c.value);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else if (c.type === 'JSXExpressionContainer') {
|
|
90
|
+
for (const l of literals(c.expression))
|
|
91
|
+
if (hasLetter(textOf(l)))
|
|
92
|
+
text(l, textOf(l));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (n.type === 'JSXAttribute' && n.name.type === 'JSXIdentifier' && TEXT_ATTRS.has(n.name.name) && n.value) {
|
|
97
|
+
const v = n.value;
|
|
98
|
+
const ls = v.type === 'StringLiteral' ? [v] : v.type === 'JSXExpressionContainer' ? literals(v.expression) : [];
|
|
99
|
+
for (const l of ls)
|
|
100
|
+
if (hasLetter(textOf(l)))
|
|
101
|
+
add(l, 'text-outside-t', `${n.name.name}="${clip(textOf(l))}" must go through t('key', 'default')`);
|
|
102
|
+
}
|
|
103
|
+
else if (n.type === 'CallExpression' && n.callee?.type === 'Identifier' && n.callee.name === 't') {
|
|
104
|
+
const def = literal(n.arguments[1]);
|
|
105
|
+
if (def === null)
|
|
106
|
+
return; // extractContent reports non-literal arguments
|
|
107
|
+
if (PLACEHOLDER.some(re => re.test(def)))
|
|
108
|
+
add(n, 'placeholder', `default "${clip(def)}" is a placeholder; write the real text`);
|
|
109
|
+
const vars = unwrap(n.arguments[2]);
|
|
110
|
+
// Keys we can read; a spread or computed key means we cannot know what is passed, so such calls are not judged.
|
|
111
|
+
let known = new Set();
|
|
112
|
+
if (vars === undefined)
|
|
113
|
+
known = new Set();
|
|
114
|
+
else if (vars.type !== 'ObjectExpression')
|
|
115
|
+
known = null;
|
|
116
|
+
else
|
|
117
|
+
for (const p of vars.properties) {
|
|
118
|
+
if (p.type !== 'ObjectProperty' && p.type !== 'ObjectMethod') {
|
|
119
|
+
known = null;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (p.computed) {
|
|
123
|
+
known = null;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
known.add(p.key.name ?? String(p.key.value));
|
|
127
|
+
}
|
|
128
|
+
if (known) {
|
|
129
|
+
const missing = [...new Set([...def.matchAll(/\{\{(\w+)\}\}/g)].map(m => m[1]))].filter(k => !known.has(k));
|
|
130
|
+
for (const k of missing)
|
|
131
|
+
add(n, 'placeholder', `default has {{${k}}} but this t() call passes no vars.${k}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// The findings of every file in the order given, each file's sorted by position. Errors fail the build; warnings are printed
|
|
137
|
+
// and the build goes on (spec "all events" §7: a false alarm is fine as a warning, never as an error).
|
|
138
|
+
export function check(files) {
|
|
139
|
+
const errors = [];
|
|
140
|
+
const warnings = [];
|
|
141
|
+
const parsed = [];
|
|
142
|
+
for (const f of files) {
|
|
143
|
+
if (!inScope(f.path) && !inEventScope(f.path))
|
|
144
|
+
continue;
|
|
145
|
+
try {
|
|
146
|
+
parsed.push({ f, program: parseSource(f).program });
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
// The screens must parse (content and steps are read from them); a server file is checked only for event names.
|
|
150
|
+
if (inScope(f.path))
|
|
151
|
+
throw e;
|
|
152
|
+
warnings.push({ file: f.path, line: 1, rule: 'parse-error', message: `cannot read the file, its event names were not checked: ${clip(e.message)}` });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Keys some step never sends (skipAnswers anywhere in the funnel): they need no text.
|
|
156
|
+
const skipped = new Set(parsed.flatMap(p => skippedKeys(p.program)));
|
|
157
|
+
for (const { f, program } of parsed) {
|
|
158
|
+
const errs = [];
|
|
159
|
+
const warns = [];
|
|
160
|
+
const at = (n) => ({ file: f.path, line: n.loc.start.line, col: n.loc.start.column });
|
|
161
|
+
if (inScope(f.path))
|
|
162
|
+
textRules(program, (n, rule, message) => errs.push({ ...at(n), rule, message }));
|
|
163
|
+
if (inEventScope(f.path))
|
|
164
|
+
eventRules(program, skipped, (n, rule, message) => errs.push({ ...at(n), rule, message }), (n, rule, message) => warns.push({ ...at(n), rule, message }));
|
|
165
|
+
const byPos = (a, b) => a.line - b.line || a.col - b.col;
|
|
166
|
+
errors.push(...errs.sort(byPos).map(({ col: _c, ...e }) => e));
|
|
167
|
+
warnings.push(...warns.sort(byPos).map(({ col: _c, ...e }) => e));
|
|
168
|
+
}
|
|
169
|
+
return { errors, warnings };
|
|
170
|
+
}
|
|
171
|
+
export function lint(files) {
|
|
172
|
+
return check(files).errors;
|
|
173
|
+
}
|
|
174
|
+
// The kit under its name (@appright/funnelshift) and its name before 0.15.0.
|
|
175
|
+
const KITS = new Set(['@appright/funnelshift', '@appright/funnel-kit']);
|
|
176
|
+
// The kit's rule for an event name, a prop name and so for answer_<key> (answers.ts PROP_KEY, functions.ts FUNCTION_NAME).
|
|
177
|
+
const NAME = /^[a-z][a-z0-9_]{0,63}$/;
|
|
178
|
+
const KIT_BLOCKS = new Set(['Hero', 'Choice', 'MultiChoice', 'Cta']);
|
|
179
|
+
// Blocks that hand the option text to set() by themselves (offer.ts), and the props where that happens.
|
|
180
|
+
const LABELLED = new Set(['Choice', 'MultiChoice']);
|
|
181
|
+
const LABELLED_PROPS = new Set(['onChange', 'onPick']);
|
|
182
|
+
// Fields whose value is what the person typed: no other text to send.
|
|
183
|
+
const TEXT_FIELDS = new Set(['input', 'textarea']);
|
|
184
|
+
const NUMERIC_FNS = new Set(['Number', 'parseInt', 'parseFloat', 'Boolean']);
|
|
185
|
+
const STRING_METHODS = new Set(['trim', 'trimStart', 'trimEnd', 'toLowerCase', 'toUpperCase', 'slice', 'normalize']);
|
|
186
|
+
const PLAIN_MEMBERS = new Set(['length', 'checked', 'valueAsNumber']);
|
|
187
|
+
const answerKeyOk = (k) => k !== '' && NAME.test('answer_' + k) && !k.endsWith('_truncated');
|
|
188
|
+
const isFn = (n) => ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'ObjectMethod'].includes(n?.type);
|
|
189
|
+
const propKey = (p) => (p?.type === 'ObjectProperty' || p?.type === 'ObjectMethod') && !p.computed ? (p.key.name ?? p.key.value ?? null) : null;
|
|
190
|
+
const isMember = (c, prop) => c?.type === 'MemberExpression' && !c.computed && c.property?.name === prop;
|
|
191
|
+
const jsxName = (el) => el?.name?.type === 'JSXIdentifier' ? el.name.name : '';
|
|
192
|
+
// The literal keys of every skipAnswers in a file: `skipAnswers: [...]` or `x.skipAnswers = [...]`, also through a const
|
|
193
|
+
// array of the same file.
|
|
194
|
+
function skippedKeys(program) {
|
|
195
|
+
const consts = new Map();
|
|
196
|
+
walk(program, n => { if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && n.init)
|
|
197
|
+
consts.set(n.id.name, n.init); });
|
|
198
|
+
const out = [];
|
|
199
|
+
const add = (v) => {
|
|
200
|
+
v = unwrap(v);
|
|
201
|
+
if (v?.type === 'Identifier')
|
|
202
|
+
v = unwrap(consts.get(v.name));
|
|
203
|
+
if (v?.type === 'ArrayExpression')
|
|
204
|
+
for (const e of v.elements) {
|
|
205
|
+
const k = literal(e);
|
|
206
|
+
if (k !== null)
|
|
207
|
+
out.push(k);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
walk(program, n => {
|
|
211
|
+
if (n.type === 'ObjectProperty' && propKey(n) === 'skipAnswers')
|
|
212
|
+
add(n.value);
|
|
213
|
+
if (n.type === 'AssignmentExpression' && isMember(n.left, 'skipAnswers'))
|
|
214
|
+
add(n.right);
|
|
215
|
+
});
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
// walk with the chain of ancestors (outermost first), for rules that depend on where a call sits.
|
|
219
|
+
function walkUp(node, visit, up = []) {
|
|
220
|
+
if (!node || typeof node.type !== 'string')
|
|
221
|
+
return;
|
|
222
|
+
visit(node, up);
|
|
223
|
+
up.push(node);
|
|
224
|
+
for (const k of Object.keys(node)) {
|
|
225
|
+
if (k === 'loc' || k === 'start' || k === 'end')
|
|
226
|
+
continue;
|
|
227
|
+
const v = node[k];
|
|
228
|
+
if (Array.isArray(v))
|
|
229
|
+
v.forEach(c => walkUp(c, visit, up));
|
|
230
|
+
else if (v && typeof v === 'object')
|
|
231
|
+
walkUp(v, visit, up);
|
|
232
|
+
}
|
|
233
|
+
up.pop();
|
|
234
|
+
}
|
|
235
|
+
// A value that needs no text: a literal, a number or a yes/no, or what the person typed into a field.
|
|
236
|
+
function plainValue(v) {
|
|
237
|
+
v = unwrap(v);
|
|
238
|
+
if (!v)
|
|
239
|
+
return true;
|
|
240
|
+
switch (v.type) {
|
|
241
|
+
case 'StringLiteral':
|
|
242
|
+
case 'NumericLiteral':
|
|
243
|
+
case 'BooleanLiteral':
|
|
244
|
+
case 'NullLiteral':
|
|
245
|
+
case 'BigIntLiteral':
|
|
246
|
+
case 'UnaryExpression': return true;
|
|
247
|
+
case 'TemplateLiteral': return v.expressions.length === 0;
|
|
248
|
+
case 'Identifier': return v.name === 'undefined';
|
|
249
|
+
case 'ArrayExpression': return v.elements.every((e) => e && plainValue(e));
|
|
250
|
+
case 'BinaryExpression': return v.operator !== '+' || (plainValue(v.left) && plainValue(v.right));
|
|
251
|
+
case 'ConditionalExpression': return plainValue(v.consequent) && plainValue(v.alternate);
|
|
252
|
+
case 'MemberExpression': return (!v.computed && PLAIN_MEMBERS.has(v.property?.name)) || typedText(v);
|
|
253
|
+
case 'CallExpression':
|
|
254
|
+
if (v.callee?.type === 'Identifier' && NUMERIC_FNS.has(v.callee.name))
|
|
255
|
+
return true;
|
|
256
|
+
if (v.callee?.type === 'MemberExpression' && v.callee.object?.type === 'Identifier' && v.callee.object.name === 'Math')
|
|
257
|
+
return true;
|
|
258
|
+
return typedText(v);
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
// e.target.value / e.currentTarget.value, also trimmed or lower-cased.
|
|
263
|
+
function typedText(v) {
|
|
264
|
+
v = unwrap(v);
|
|
265
|
+
if (v?.type === 'CallExpression' && v.callee?.type === 'MemberExpression' && !v.callee.computed && STRING_METHODS.has(v.callee.property?.name))
|
|
266
|
+
return typedText(v.callee.object);
|
|
267
|
+
return isMember(v, 'value') && (isMember(v.object, 'target') || isMember(v.object, 'currentTarget'));
|
|
268
|
+
}
|
|
269
|
+
// Text that keeps a click's name stable: a literal, or t() (its key names the click), also in branches.
|
|
270
|
+
function stableText(e) {
|
|
271
|
+
e = unwrap(e);
|
|
272
|
+
switch (e?.type) {
|
|
273
|
+
case 'StringLiteral': return true;
|
|
274
|
+
case 'TemplateLiteral': return e.expressions.length === 0;
|
|
275
|
+
case 'CallExpression': return e.callee?.type === 'Identifier' && e.callee.name === 't';
|
|
276
|
+
case 'ConditionalExpression': return stableText(e.consequent) && stableText(e.alternate);
|
|
277
|
+
case 'LogicalExpression': return e.operator === '&&' ? stableText(e.right) : stableText(e.left) && stableText(e.right);
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
// The identifiers a declaration pattern binds.
|
|
282
|
+
function boundIds(p) {
|
|
283
|
+
switch (p?.type) {
|
|
284
|
+
case 'Identifier': return [p];
|
|
285
|
+
case 'ObjectPattern': return p.properties.flatMap((q) => boundIds(q.type === 'RestElement' ? q.argument : q.value));
|
|
286
|
+
case 'ArrayPattern': return p.elements.flatMap((e) => boundIds(e));
|
|
287
|
+
case 'AssignmentPattern': return boundIds(p.left);
|
|
288
|
+
case 'RestElement': return boundIds(p.argument);
|
|
289
|
+
case 'TSParameterProperty': return boundIds(p.parameter);
|
|
290
|
+
}
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
function eventRules(program, skipped, err, warn) {
|
|
294
|
+
const useNames = new Set(['useFunnel']);
|
|
295
|
+
const kitUse = new Set(); // the import specifiers' local nodes of the kit's useFunnel
|
|
296
|
+
const role = new Map(); // binding identifier node -> what it is
|
|
297
|
+
const weak = new Set(); // bindings whose role comes from something only possibly the kit's: never sure
|
|
298
|
+
for (const st of program.body) {
|
|
299
|
+
if (st.type !== 'ImportDeclaration' || !KITS.has(String(st.source.value)))
|
|
300
|
+
continue;
|
|
301
|
+
for (const s of st.specifiers) {
|
|
302
|
+
if (s.type !== 'ImportSpecifier')
|
|
303
|
+
continue;
|
|
304
|
+
const imported = s.imported.name ?? s.imported.value;
|
|
305
|
+
if (imported === 'track')
|
|
306
|
+
role.set(s.local, 'track');
|
|
307
|
+
if (imported === 'useFunnel') {
|
|
308
|
+
useNames.add(s.local.name);
|
|
309
|
+
kitUse.add(s.local);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// { set, track: tr } = useFunnel() or a handler's ({ track }) parameter; weak when the source is only maybe the kit's.
|
|
314
|
+
const pick = (pattern, want, isWeak = false) => {
|
|
315
|
+
if (pattern?.type !== 'ObjectPattern')
|
|
316
|
+
return;
|
|
317
|
+
for (const p of pattern.properties) {
|
|
318
|
+
const k = propKey(p);
|
|
319
|
+
const v = p.value?.type === 'AssignmentPattern' ? p.value.left : p.value;
|
|
320
|
+
if (k && want[k] && v?.type === 'Identifier') {
|
|
321
|
+
role.set(v, want[k]);
|
|
322
|
+
if (isWeak)
|
|
323
|
+
weak.add(v);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const fns = new Map();
|
|
328
|
+
const decls = new Map(); // name -> every binding identifier of that name in the file
|
|
329
|
+
const declare = (ids) => { for (const id of ids)
|
|
330
|
+
decls.set(id.name, [...(decls.get(id.name) ?? []), id]); };
|
|
331
|
+
walk(program, n => {
|
|
332
|
+
if (n.type === 'FunctionDeclaration' && n.id)
|
|
333
|
+
fns.set(n.id.name, n);
|
|
334
|
+
if (n.type === 'VariableDeclarator' && n.id?.type === 'Identifier' && isFn(unwrap(n.init)))
|
|
335
|
+
fns.set(n.id.name, unwrap(n.init));
|
|
336
|
+
if (n.type === 'VariableDeclarator')
|
|
337
|
+
declare(boundIds(n.id));
|
|
338
|
+
if (isFn(n) || n.type === 'ClassMethod' || n.type === 'ClassPrivateMethod')
|
|
339
|
+
declare(n.params.flatMap(boundIds));
|
|
340
|
+
if ((n.type === 'FunctionDeclaration' || n.type === 'FunctionExpression' || n.type === 'ClassDeclaration' || n.type === 'ClassExpression') && n.id)
|
|
341
|
+
declare([n.id]);
|
|
342
|
+
if (n.type === 'ImportSpecifier' || n.type === 'ImportDefaultSpecifier' || n.type === 'ImportNamespaceSpecifier')
|
|
343
|
+
declare([n.local]);
|
|
344
|
+
if (n.type === 'CatchClause' && n.param)
|
|
345
|
+
declare(boundIds(n.param));
|
|
346
|
+
});
|
|
347
|
+
// useFunnel() is surely the kit's only when its name is bound by nothing but the kit import; a local useFunnel, one from
|
|
348
|
+
// another module or a shadowed alias is only maybe.
|
|
349
|
+
const isUse = (n) => {
|
|
350
|
+
n = unwrap(n);
|
|
351
|
+
if (n?.type !== 'CallExpression' || n.callee?.type !== 'Identifier' || !useNames.has(n.callee.name))
|
|
352
|
+
return null;
|
|
353
|
+
const ds = decls.get(n.callee.name) ?? [];
|
|
354
|
+
return ds.length && ds.every(d => kitUse.has(d)) ? 'sure' : 'maybe';
|
|
355
|
+
};
|
|
356
|
+
walk(program, n => {
|
|
357
|
+
const use = n.type === 'VariableDeclarator' ? isUse(n.init) : null;
|
|
358
|
+
if (use) {
|
|
359
|
+
if (n.id.type === 'Identifier') {
|
|
360
|
+
role.set(n.id, 'funnel');
|
|
361
|
+
if (use === 'maybe')
|
|
362
|
+
weak.add(n.id);
|
|
363
|
+
}
|
|
364
|
+
else
|
|
365
|
+
pick(n.id, { set: 'set', track: 'track' }, use === 'maybe');
|
|
366
|
+
}
|
|
367
|
+
if (n.type === 'CallExpression' && n.callee?.type === 'Identifier' && n.callee.name === 'defineFunctions' && unwrap(n.arguments[0])?.type === 'ObjectExpression') {
|
|
368
|
+
for (const p of unwrap(n.arguments[0]).properties) {
|
|
369
|
+
const v = p.type === 'ObjectMethod' ? p : unwrap(p.value);
|
|
370
|
+
const fn = isFn(v) ? v : v?.type === 'Identifier' ? fns.get(v.name) : null;
|
|
371
|
+
const ctx = fn?.params?.[1];
|
|
372
|
+
if (ctx?.type === 'Identifier')
|
|
373
|
+
role.set(ctx, 'ctx');
|
|
374
|
+
else
|
|
375
|
+
pick(ctx, { track: 'ctxTrack' });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
// How sure it is that `name` is a binding of this role: every declaration has it, or only some (or `ctx` by its name).
|
|
380
|
+
const certainty = (name, r) => {
|
|
381
|
+
const ds = decls.get(name) ?? [];
|
|
382
|
+
const n = ds.filter(d => role.get(d) === r).length;
|
|
383
|
+
if (n && n === ds.length)
|
|
384
|
+
return ds.some(d => weak.has(d)) ? 'maybe' : 'sure';
|
|
385
|
+
return n || (r === 'ctx' && name === 'ctx') ? 'maybe' : null;
|
|
386
|
+
};
|
|
387
|
+
walk(program, n => {
|
|
388
|
+
// const { track } = ctx: as sure as that ctx is.
|
|
389
|
+
const c = n.type === 'VariableDeclarator' && unwrap(n.init)?.type === 'Identifier' ? certainty(unwrap(n.init).name, 'ctx') : null;
|
|
390
|
+
if (c)
|
|
391
|
+
pick(n.id, { track: 'ctxTrack' }, c !== 'sure');
|
|
392
|
+
});
|
|
393
|
+
const viaFunnel = (o) => isUse(o) ?? (o?.type === 'Identifier' ? certainty(o.name, 'funnel') : null);
|
|
394
|
+
const setCall = (c) => {
|
|
395
|
+
if (c.type !== 'CallExpression')
|
|
396
|
+
return null;
|
|
397
|
+
if (c.callee?.type === 'Identifier')
|
|
398
|
+
return certainty(c.callee.name, 'set');
|
|
399
|
+
return isMember(c.callee, 'set') ? viaFunnel(c.callee.object) : null;
|
|
400
|
+
};
|
|
401
|
+
// Which track: the browser's (a $ name is taken) or a function's ctx.track (a $ name is dropped), and how sure.
|
|
402
|
+
const trackCall = (c) => {
|
|
403
|
+
if (c.type !== 'CallExpression')
|
|
404
|
+
return null;
|
|
405
|
+
if (c.callee?.type === 'Identifier') {
|
|
406
|
+
const b = certainty(c.callee.name, 'track'), sv = certainty(c.callee.name, 'ctxTrack');
|
|
407
|
+
if (sv)
|
|
408
|
+
return { server: true, c: b ? 'maybe' : sv };
|
|
409
|
+
return b ? { server: false, c: b } : null;
|
|
410
|
+
}
|
|
411
|
+
if (!isMember(c.callee, 'track'))
|
|
412
|
+
return null;
|
|
413
|
+
const o = c.callee.object;
|
|
414
|
+
const b = viaFunnel(o), sv = o?.type === 'Identifier' ? certainty(o.name, 'ctx') : null;
|
|
415
|
+
if (sv)
|
|
416
|
+
return { server: true, c: b ? 'maybe' : sv };
|
|
417
|
+
return b ? { server: false, c: b } : null;
|
|
418
|
+
};
|
|
419
|
+
const isSet = (c) => setCall(c) !== null;
|
|
420
|
+
const badKey = (n, k, sure, why = '') => (sure ? err : warn)(n, 'answer-key', `answer key "${clip(k)}": answer_ + key must match ${NAME.source} and not end in _truncated, or the answer is not sent` +
|
|
421
|
+
(sure ? '; or list the key in skipAnswers if it is a service flag' : why));
|
|
422
|
+
// The answer keys a step component sets, following the components it draws from this file; null when that cannot be known
|
|
423
|
+
// (a key that is not a literal, a component from another file).
|
|
424
|
+
const keysSet = (fn, seen) => {
|
|
425
|
+
const keys = new Set();
|
|
426
|
+
let unknown = false;
|
|
427
|
+
walk(fn.body, n => {
|
|
428
|
+
if (isSet(n)) {
|
|
429
|
+
const k = literal(n.arguments[0]);
|
|
430
|
+
if (k === null)
|
|
431
|
+
unknown = true;
|
|
432
|
+
else
|
|
433
|
+
keys.add(k);
|
|
434
|
+
}
|
|
435
|
+
if (n.type === 'JSXOpeningElement' && /^[A-Z]/.test(jsxName(n)) && !KIT_BLOCKS.has(jsxName(n))) {
|
|
436
|
+
const sub = fns.get(jsxName(n));
|
|
437
|
+
if (!sub) {
|
|
438
|
+
unknown = true;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (seen.has(sub))
|
|
442
|
+
return;
|
|
443
|
+
seen.add(sub);
|
|
444
|
+
const more = keysSet(sub, seen);
|
|
445
|
+
if (!more)
|
|
446
|
+
unknown = true;
|
|
447
|
+
else
|
|
448
|
+
more.forEach(k => keys.add(k));
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
return unknown ? null : keys;
|
|
452
|
+
};
|
|
453
|
+
const maybe = ' (if this call is not the funnel\'s, ignore)';
|
|
454
|
+
walkUp(program, (n, up) => {
|
|
455
|
+
const tr = trackCall(n);
|
|
456
|
+
if (tr) {
|
|
457
|
+
const name = literal(n.arguments[0]);
|
|
458
|
+
if (name === null || NAME.test(name))
|
|
459
|
+
return;
|
|
460
|
+
// The browser's tracker takes $name (the kit's own events); ctx.track drops it.
|
|
461
|
+
const kits = /^\$[a-z][a-z0-9_]{0,63}$/.test(name);
|
|
462
|
+
const sure = tr.c === 'sure' && (tr.server || !kits);
|
|
463
|
+
(sure ? err : warn)(n, 'event-name', kits
|
|
464
|
+
? `event name "${clip(name)}": names with $ are reserved for the kit's own events; use ${NAME.source}${tr.server ? ', or the event is dropped' : ''}`
|
|
465
|
+
: `event name "${clip(name)}" must match ${NAME.source}, or the event is dropped${sure ? '' : maybe}`);
|
|
466
|
+
}
|
|
467
|
+
else if (isSet(n)) {
|
|
468
|
+
const key = literal(n.arguments[0]);
|
|
469
|
+
if (key !== null && !answerKeyOk(key)) {
|
|
470
|
+
const skip = skipped.has(key);
|
|
471
|
+
badKey(n, key, setCall(n) === 'sure' && !skip, skip ? ' (it is in skipAnswers: if every step that sets it leaves it out, ignore)' : setCall(n) === 'sure' ? '' : maybe);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (n.arguments.length >= 3 || plainValue(n.arguments[1]) || (key !== null && skipped.has(key)))
|
|
475
|
+
return;
|
|
476
|
+
// Inside Choice/MultiChoice onChange the block passes the option text itself; inside a text field the value is the text.
|
|
477
|
+
for (let i = up.length - 1; i > 0; i--) {
|
|
478
|
+
if (up[i].type !== 'JSXAttribute' || up[i - 1].type !== 'JSXOpeningElement')
|
|
479
|
+
continue;
|
|
480
|
+
const el = jsxName(up[i - 1]);
|
|
481
|
+
if ((LABELLED.has(el) && LABELLED_PROPS.has(up[i].name?.name)) || TEXT_FIELDS.has(el))
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const shown = key === null ? 'set(…)' : `set('${clip(key)}', …)`;
|
|
485
|
+
warn(n, 'answer-without-label', `${shown} has no text the person saw: pass it third, set(key, value, label); free text, numbers and yes/no need none`);
|
|
486
|
+
}
|
|
487
|
+
else if (n.type === 'JSXElement' && ['button', 'a'].includes(jsxName(n.openingElement))) {
|
|
488
|
+
const attrs = n.openingElement.attributes;
|
|
489
|
+
if (attrs.some((a) => a.type === 'JSXSpreadAttribute' || a.name?.name === 'data-track'))
|
|
490
|
+
return;
|
|
491
|
+
const kids = n.children.filter((c) => !(c.type === 'JSXText' && !c.value.trim()) && !(c.type === 'JSXExpressionContainer' && c.expression.type === 'JSXEmptyExpression'));
|
|
492
|
+
if (!kids.length || kids.some((c) => c.type !== 'JSXExpressionContainer') || kids.every((c) => stableText(c.expression)))
|
|
493
|
+
return;
|
|
494
|
+
const el = jsxName(n.openingElement);
|
|
495
|
+
warn(n, 'click-without-name', `<${el}> shows a text built at runtime, so its clicks are named <${el}>#<number>: add data-track="name" (not needed if the text is a t() text)`);
|
|
496
|
+
}
|
|
497
|
+
else if (n.type === 'ObjectExpression') {
|
|
498
|
+
const prop = (k) => n.properties.find((p) => propKey(p) === k);
|
|
499
|
+
const track = prop('trackAnswers');
|
|
500
|
+
if (track) {
|
|
501
|
+
warn(track, 'track-answers', 'trackAnswers is obsolete since kit 0.4: every answer is sent, the list filters nothing; remove it (skipAnswers leaves a key out)');
|
|
502
|
+
const list = unwrap(track.value);
|
|
503
|
+
if (list?.type === 'ArrayExpression')
|
|
504
|
+
for (const e of list.elements) {
|
|
505
|
+
const k = literal(e);
|
|
506
|
+
if (k !== null && !answerKeyOk(k))
|
|
507
|
+
badKey(e, k, false, ' (trackAnswers filters nothing; remove it)');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const skip = prop('skipAnswers'), comp = unwrap(prop('component')?.value);
|
|
511
|
+
const list = unwrap(skip?.value);
|
|
512
|
+
const fn = comp?.type === 'Identifier' ? fns.get(comp.name) : null;
|
|
513
|
+
if (list?.type !== 'ArrayExpression' || !fn)
|
|
514
|
+
return;
|
|
515
|
+
const keys = keysSet(fn, new Set([fn]));
|
|
516
|
+
if (!keys)
|
|
517
|
+
return;
|
|
518
|
+
for (const e of list.elements) {
|
|
519
|
+
const k = literal(e);
|
|
520
|
+
if (k !== null && !keys.has(k))
|
|
521
|
+
warn(e, 'skip-answers-unset', `skipAnswers lists "${clip(k)}", but this step never sets it: a typo leaves the real key in the answers`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
// One line per finding, `path:line rule message`, then the count.
|
|
527
|
+
export function formatLint(errors) {
|
|
528
|
+
const lines = errors.map(e => `${e.file}:${e.line} ${e.rule} ${e.message}`);
|
|
529
|
+
return [...lines, `${errors.length} problem${errors.length === 1 ? '' : 's'} found, the build was not made`].join('\n');
|
|
530
|
+
}
|
|
531
|
+
// One line per warning, `path:line warning rule message`, then the count.
|
|
532
|
+
export function formatWarnings(warnings) {
|
|
533
|
+
const lines = warnings.map(w => `${w.file}:${w.line} warning ${w.rule} ${w.message}`);
|
|
534
|
+
return [...lines, `${warnings.length} warning${warnings.length === 1 ? '' : 's'}, the build goes on`].join('\n');
|
|
535
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare function uploadBuild(o: {
|
|
2
|
+
dir: string;
|
|
3
|
+
api: string;
|
|
4
|
+
project: string;
|
|
5
|
+
funnel: string;
|
|
6
|
+
commit: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
token: string;
|
|
9
|
+
fetchImpl?: typeof fetch;
|
|
10
|
+
}): Promise<{
|
|
11
|
+
build_id: string;
|
|
12
|
+
preview_url?: string;
|
|
13
|
+
functions_deployed?: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function uploadLines(r: {
|
|
16
|
+
build_id: string;
|
|
17
|
+
preview_url?: string;
|
|
18
|
+
functions_deployed?: boolean;
|
|
19
|
+
}): string[];
|
|
20
|
+
export declare function runUpload(o: {
|
|
21
|
+
dir: string;
|
|
22
|
+
funnel: string;
|
|
23
|
+
commit: string;
|
|
24
|
+
branch: string;
|
|
25
|
+
env: Record<string, string | undefined>;
|
|
26
|
+
api?: string;
|
|
27
|
+
project?: string;
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
}): Promise<string[]>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { create } from 'tar';
|
|
2
|
+
import { lstatSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const MAX_ARTIFACT = 50 << 20; // the server's limit
|
|
5
|
+
// Regular files only, as relative paths with "/" (what the server accepts). A symlink in the build is an error: the server
|
|
6
|
+
// would reject the whole artifact, so fail here with the file name instead.
|
|
7
|
+
function files(root, rel = '') {
|
|
8
|
+
return readdirSync(join(root, rel)).sort().flatMap(n => {
|
|
9
|
+
const r = rel ? `${rel}/${n}` : n;
|
|
10
|
+
const st = lstatSync(join(root, r));
|
|
11
|
+
if (st.isSymbolicLink())
|
|
12
|
+
throw new Error(`symlink in build dir: ${r}`);
|
|
13
|
+
if (st.isDirectory())
|
|
14
|
+
return files(root, r);
|
|
15
|
+
if (st.isFile())
|
|
16
|
+
return [r];
|
|
17
|
+
throw new Error(`not a regular file in build dir: ${r}`);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function uploadBuild(o) {
|
|
21
|
+
const list = files(o.dir);
|
|
22
|
+
for (const need of ['index.html', 'manifest.json']) {
|
|
23
|
+
if (!list.includes(need))
|
|
24
|
+
throw new Error(`${need} is missing in ${o.dir} (run funnel-kit build first)`);
|
|
25
|
+
}
|
|
26
|
+
const chunks = [];
|
|
27
|
+
const stream = create({ gzip: true, cwd: o.dir, portable: true, noDirRecurse: true }, list);
|
|
28
|
+
for await (const c of stream)
|
|
29
|
+
chunks.push(Buffer.from(c));
|
|
30
|
+
const art = Buffer.concat(chunks);
|
|
31
|
+
if (art.length > MAX_ARTIFACT)
|
|
32
|
+
throw new Error(`artifact is ${art.length} bytes, the limit is ${MAX_ARTIFACT}`);
|
|
33
|
+
const form = new FormData();
|
|
34
|
+
for (const k of ['project', 'funnel', 'commit', 'branch'])
|
|
35
|
+
form.set(k, o[k]);
|
|
36
|
+
form.set('artifact', new Blob([art], { type: 'application/gzip' }), 'build.tgz');
|
|
37
|
+
const r = await (o.fetchImpl ?? fetch)(`${o.api.replace(/\/+$/, '')}/api/builds`, { method: 'POST', body: form, headers: { Authorization: `Bearer ${o.token}` } });
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
// 201: new build, 200: the identical build already exists; both carry the id.
|
|
40
|
+
if (r.status !== 201 && r.status !== 200)
|
|
41
|
+
throw new Error(`upload failed: ${r.status} ${text}`);
|
|
42
|
+
let body;
|
|
43
|
+
try {
|
|
44
|
+
body = JSON.parse(text);
|
|
45
|
+
}
|
|
46
|
+
catch { /* handled below */ }
|
|
47
|
+
const id = body?.build_id;
|
|
48
|
+
if (typeof id !== 'string' || !id)
|
|
49
|
+
throw new Error(`upload answered ${r.status} without build_id`);
|
|
50
|
+
return { build_id: id, preview_url: typeof body.preview_url === 'string' ? body.preview_url : undefined,
|
|
51
|
+
// Only for a build with functions: whether the server got them onto the edge right away (it retries by itself if not).
|
|
52
|
+
functions_deployed: typeof body.functions_deployed === 'boolean' ? body.functions_deployed : undefined };
|
|
53
|
+
}
|
|
54
|
+
const HEX32 = /^[0-9a-f]{32}$/;
|
|
55
|
+
// https only (plain http just for a local dev server), one token without whitespace or control characters: the line is read by
|
|
56
|
+
// people and by agents, and a server that returns anything else must not get to put it in their output.
|
|
57
|
+
const SAFE_URL = /^(https:\/\/[^\s\x00-\x1f\x7f]+|http:\/\/localhost(:\d+)?([/?#][^\s\x00-\x1f\x7f]*)?)$/;
|
|
58
|
+
// The lines `upload` prints (functions_deployed only for a build with functions). The id is checked and an invalid one is an error; a preview url that fails the check is left
|
|
59
|
+
// out (get_preview gives a fresh link), and the guide says so.
|
|
60
|
+
export function uploadLines(r) {
|
|
61
|
+
if (!HEX32.test(r.build_id))
|
|
62
|
+
throw new Error('upload answered with a build_id that is not 32 hex characters; nothing was printed');
|
|
63
|
+
const lines = [`build_id=${r.build_id}`];
|
|
64
|
+
if (r.preview_url && SAFE_URL.test(r.preview_url))
|
|
65
|
+
lines.push(`preview_url=${r.preview_url}`);
|
|
66
|
+
if (r.functions_deployed !== undefined)
|
|
67
|
+
lines.push(`functions_deployed=${r.functions_deployed}`);
|
|
68
|
+
return lines;
|
|
69
|
+
}
|
|
70
|
+
async function projectOfToken(api, token, fetchImpl) {
|
|
71
|
+
const r = await fetchImpl(`${api.replace(/\/+$/, '')}/api/v1/project`, { headers: { Authorization: `Bearer ${token}` } });
|
|
72
|
+
const slug = r.status === 200 ? (await r.json().catch(() => null))?.slug : undefined;
|
|
73
|
+
if (typeof slug !== 'string' || !slug)
|
|
74
|
+
throw new Error(`cannot find the project of the token (${r.status}); pass --project <slug>`);
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
// Everything `funnel-kit upload` does, with the environment and the options passed in: token from FUNNELS_TOKEN (else the CI's
|
|
78
|
+
// FUNNELS_DEPLOY_TOKEN), address from --api or FUNNELS_API_URL, project from --project, FUNNELS_PROJECT or the token itself.
|
|
79
|
+
export async function runUpload(o) {
|
|
80
|
+
const token = o.env.FUNNELS_TOKEN || o.env.FUNNELS_DEPLOY_TOKEN;
|
|
81
|
+
if (!token)
|
|
82
|
+
throw new Error('FUNNELS_TOKEN is not set');
|
|
83
|
+
const api = o.api || o.env.FUNNELS_API_URL;
|
|
84
|
+
if (!api)
|
|
85
|
+
throw new Error('the service address is not set: use FUNNELS_API_URL or --api <url>');
|
|
86
|
+
const fetchImpl = o.fetchImpl ?? fetch;
|
|
87
|
+
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 });
|
|
89
|
+
return uploadLines(res);
|
|
90
|
+
}
|
package/dist/clicks.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type ClickOptions = {
|
|
2
|
+
stepId: () => string;
|
|
3
|
+
track: (name: string, props: Record<string, unknown>, stepId: string) => void;
|
|
4
|
+
keyOf: (text: string) => string | null;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
};
|
|
7
|
+
export declare function installClicks(root: HTMLElement, o: ClickOptions): () => void;
|