@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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import { STEP_ROLES } from '../nav.js';
|
|
3
|
+
export function literal(n) {
|
|
4
|
+
if (n?.type === 'StringLiteral')
|
|
5
|
+
return n.value;
|
|
6
|
+
if (n?.type === 'TemplateLiteral' && n.expressions.length === 0)
|
|
7
|
+
return n.quasis[0].value.cooked;
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
export function walk(node, visit) {
|
|
11
|
+
if (!node || typeof node.type !== 'string')
|
|
12
|
+
return;
|
|
13
|
+
visit(node);
|
|
14
|
+
for (const k of Object.keys(node)) {
|
|
15
|
+
if (k === 'loc' || k === 'start' || k === 'end')
|
|
16
|
+
continue;
|
|
17
|
+
const v = node[k];
|
|
18
|
+
if (Array.isArray(v))
|
|
19
|
+
v.forEach(c => walk(c, visit));
|
|
20
|
+
else if (v && typeof v === 'object')
|
|
21
|
+
walk(v, visit);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// `.ts` must not get the jsx plugin: `<T>x` casts are valid there and would not parse.
|
|
25
|
+
function plugins(path) {
|
|
26
|
+
if (/\.tsx$/.test(path))
|
|
27
|
+
return ['typescript', 'jsx'];
|
|
28
|
+
if (/\.ts$/.test(path))
|
|
29
|
+
return ['typescript'];
|
|
30
|
+
return ['jsx'];
|
|
31
|
+
}
|
|
32
|
+
export function parseSource(f) {
|
|
33
|
+
try {
|
|
34
|
+
return parse(f.code, { sourceType: 'module', plugins: plugins(f.path) });
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
throw new Error(`${f.path}: cannot parse: ${e.message}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Every t('key', 'default') in the funnel's code, so the admin can show and edit all texts (spec §5).
|
|
41
|
+
export function extractContent(files) {
|
|
42
|
+
const found = new Map();
|
|
43
|
+
for (const f of files) {
|
|
44
|
+
const ast = parseSource(f);
|
|
45
|
+
walk(ast.program, n => {
|
|
46
|
+
if (n.type !== 'CallExpression' || n.callee?.type !== 'Identifier' || n.callee.name !== 't')
|
|
47
|
+
return;
|
|
48
|
+
const key = literal(n.arguments[0]), def = literal(n.arguments[1]);
|
|
49
|
+
if (key === null || def === null)
|
|
50
|
+
throw new Error(`${f.path}:${n.loc?.start.line}: t() key and default must be string literals`);
|
|
51
|
+
const prev = found.get(key);
|
|
52
|
+
if (prev && prev.default !== def)
|
|
53
|
+
throw new Error(`t('${key}') has two defaults: ${prev.path} and ${f.path}`);
|
|
54
|
+
found.set(key, { key, default: def, path: f.path });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return [...found.values()].map(({ key, default: d }) => ({ key, default: d })).sort((a, b) => a.key.localeCompare(b.key));
|
|
58
|
+
}
|
|
59
|
+
const unwrap = (n) => (n && ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(n.type) ? unwrap(n.expression) : n);
|
|
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];
|
|
73
|
+
if (p.type === 'AssignmentPattern')
|
|
74
|
+
p = p.left;
|
|
75
|
+
const keys = [];
|
|
76
|
+
const add = (k) => { if (!keys.includes(k))
|
|
77
|
+
keys.push(k); };
|
|
78
|
+
if (p.type === 'ObjectPattern') {
|
|
79
|
+
for (const prop of p.properties) {
|
|
80
|
+
if (prop.type !== 'ObjectProperty')
|
|
81
|
+
return null; // a rest element takes every key
|
|
82
|
+
const k = prop.computed ? literal(prop.key) : prop.key.type === 'Identifier' ? prop.key.name : literal(prop.key);
|
|
83
|
+
if (k === null)
|
|
84
|
+
return null;
|
|
85
|
+
add(k);
|
|
86
|
+
}
|
|
87
|
+
return keys;
|
|
88
|
+
}
|
|
89
|
+
if (p.type !== 'Identifier')
|
|
90
|
+
return null;
|
|
91
|
+
const name = p.name;
|
|
92
|
+
let ok = true;
|
|
93
|
+
// every other mention of the parameter in the body is a use we cannot follow; a property or key that merely has its name is not
|
|
94
|
+
const visit = (node, parent, field) => {
|
|
95
|
+
if (!ok || !node || typeof node.type !== 'string')
|
|
96
|
+
return;
|
|
97
|
+
if ((node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') && node.object?.type === 'Identifier' && node.object.name === name) {
|
|
98
|
+
const k = node.computed ? literal(node.property) : node.property.type === 'Identifier' ? node.property.name : null;
|
|
99
|
+
if (k === null)
|
|
100
|
+
ok = false;
|
|
101
|
+
else
|
|
102
|
+
add(k);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (node.type === 'Identifier' && node.name === name) {
|
|
106
|
+
const propName = (parent?.type === 'MemberExpression' || parent?.type === 'OptionalMemberExpression') && field === 'property' && !parent.computed;
|
|
107
|
+
const objKey = parent?.type === 'ObjectProperty' && field === 'key' && !parent.computed && !parent.shorthand;
|
|
108
|
+
if (!propName && !objKey)
|
|
109
|
+
ok = false;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const k of Object.keys(node)) {
|
|
113
|
+
if (k === 'loc' || k === 'start' || k === 'end' || k === 'typeAnnotation' || k === 'returnType' || k === 'typeParameters')
|
|
114
|
+
continue;
|
|
115
|
+
const v = node[k];
|
|
116
|
+
if (Array.isArray(v))
|
|
117
|
+
v.forEach(c => visit(c, node, k));
|
|
118
|
+
else if (v && typeof v === 'object')
|
|
119
|
+
visit(v, node, k);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
visit(fn.body, fn, 'body');
|
|
123
|
+
return ok ? keys : null;
|
|
124
|
+
}
|
|
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
|
|
126
|
+
// 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
|
+
// same file; anything else (imports, spreads, computed ids) is an error with file:line, never a guess.
|
|
128
|
+
export function extractSteps(files) {
|
|
129
|
+
const funnels = [];
|
|
130
|
+
for (const f of files) {
|
|
131
|
+
const program = parseSource(f).program;
|
|
132
|
+
walk(program, n => { if (isCall(n, 'defineFunnel'))
|
|
133
|
+
funnels.push({ f, program, call: n }); });
|
|
134
|
+
}
|
|
135
|
+
if (funnels.length === 0)
|
|
136
|
+
throw new Error('no defineFunnel({ ... }) found in the funnel');
|
|
137
|
+
if (funnels.length > 1) {
|
|
138
|
+
const where = funnels.map(x => `${x.f.path}:${x.call.loc?.start.line}`).join(', ');
|
|
139
|
+
throw new Error(`defineFunnel() is called more than once (${where}); a funnel has exactly one`);
|
|
140
|
+
}
|
|
141
|
+
const { f, program, call } = funnels[0];
|
|
142
|
+
const at = (n) => `${f.path}:${n?.loc?.start.line ?? call.loc?.start.line}`;
|
|
143
|
+
const decls = new Map();
|
|
144
|
+
for (const st of program.body) {
|
|
145
|
+
const d = st.type === 'ExportNamedDeclaration' ? st.declaration : st;
|
|
146
|
+
if (d?.type !== 'VariableDeclaration')
|
|
147
|
+
continue;
|
|
148
|
+
for (const v of d.declarations)
|
|
149
|
+
if (v.id?.type === 'Identifier' && v.init)
|
|
150
|
+
decls.set(v.id.name, v);
|
|
151
|
+
}
|
|
152
|
+
// Follow `steps` / a step through identifiers declared in this file.
|
|
153
|
+
const resolve = (n, what) => {
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (let cur = unwrap(n);; cur = unwrap(cur)) {
|
|
156
|
+
if (cur?.type !== 'Identifier')
|
|
157
|
+
return cur;
|
|
158
|
+
const decl = decls.get(cur.name);
|
|
159
|
+
if (!decl || seen.has(cur.name)) {
|
|
160
|
+
throw new Error(`${at(cur)}: ${what} '${cur.name}' is not declared in this file; imported steps are not supported, define every step as ` +
|
|
161
|
+
`const ${cur.name} = defineStep({ ... }) in the funnel's index file`);
|
|
162
|
+
}
|
|
163
|
+
seen.add(cur.name);
|
|
164
|
+
cur = decl.init;
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
const arg = unwrap(call.arguments[0]);
|
|
168
|
+
const prop = arg?.type === 'ObjectExpression' && arg.properties.find((p) => p.type === 'ObjectProperty' && !p.computed && (p.key.name === 'steps' || p.key.value === 'steps'));
|
|
169
|
+
if (!prop)
|
|
170
|
+
throw new Error(`${at(call)}: defineFunnel() needs an object with a literal \`steps: [...]\` property`);
|
|
171
|
+
const list = resolve(prop.value, 'steps array');
|
|
172
|
+
if (list?.type !== 'ArrayExpression')
|
|
173
|
+
throw new Error(`${at(prop)}: \`steps\` must be an array literal of defineStep(...) calls`);
|
|
174
|
+
const seen = new Map();
|
|
175
|
+
const steps = list.elements.map((el) => {
|
|
176
|
+
if (!el || el.type === 'SpreadElement')
|
|
177
|
+
throw new Error(`${at(el ?? list)}: steps cannot contain spreads or holes; list each defineStep(...)`);
|
|
178
|
+
const step = resolve(el, 'step');
|
|
179
|
+
if (!isCall(step, 'defineStep'))
|
|
180
|
+
throw new Error(`${at(step)}: every element of \`steps\` must be defineStep({ id: '...' }) or a const declared with it`);
|
|
181
|
+
const o = unwrap(step.arguments[0]);
|
|
182
|
+
const idProp = o?.type === 'ObjectExpression' && o.properties.find((p) => p.type === 'ObjectProperty' && !p.computed && (p.key.name === 'id' || p.key.value === 'id'));
|
|
183
|
+
const id = idProp ? literal(unwrap(idProp.value)) : null;
|
|
184
|
+
if (id === null)
|
|
185
|
+
throw new Error(`${at(idProp ?? step)}: defineStep() id must be a string literal`);
|
|
186
|
+
const line = (idProp ?? step).loc?.start.line;
|
|
187
|
+
if (seen.has(id))
|
|
188
|
+
throw new Error(`${at(idProp ?? step)}: duplicate step id '${id}' (first used on line ${seen.get(id)}); step ids must be unique`);
|
|
189
|
+
seen.set(id, line);
|
|
190
|
+
const option = (name) => o.properties.find((p) => p.type === 'ObjectProperty' && !p.computed && (p.key.name === name || p.key.value === name));
|
|
191
|
+
const out = { id };
|
|
192
|
+
const roleProp = option('role');
|
|
193
|
+
if (roleProp) {
|
|
194
|
+
const role = literal(unwrap(roleProp.value));
|
|
195
|
+
if (role === null)
|
|
196
|
+
throw new Error(`${at(roleProp)}: defineStep() role must be 'lead', 'paywall' or 'landing' (a string literal)`);
|
|
197
|
+
if (!STEP_ROLES.includes(role)) {
|
|
198
|
+
throw new Error(`${at(roleProp)}: defineStep() role '${role}' is unknown; role must be 'lead', 'paywall' or 'landing'`);
|
|
199
|
+
}
|
|
200
|
+
out.role = role;
|
|
201
|
+
}
|
|
202
|
+
// sensitive only marks the step in the admin; the manifest carries it so the server can say so (steps_order).
|
|
203
|
+
const sensProp = option('sensitive');
|
|
204
|
+
if (sensProp) {
|
|
205
|
+
const v = unwrap(sensProp.value);
|
|
206
|
+
if (v?.type !== 'BooleanLiteral')
|
|
207
|
+
throw new Error(`${at(sensProp)}: defineStep() sensitive must be true or false (a literal)`);
|
|
208
|
+
if (v.value)
|
|
209
|
+
out.sensitive = true;
|
|
210
|
+
}
|
|
211
|
+
// when: the step is shown only to some visitors (the admin draws the ways around it before any traffic); when_keys are the
|
|
212
|
+
// answers it depends on, only when its code tells them reliably (whenKeys).
|
|
213
|
+
const whenProp = option('when');
|
|
214
|
+
const whenVal = whenProp && unwrap(whenProp.value);
|
|
215
|
+
if (whenVal && whenVal.type !== 'NullLiteral' && !(whenVal.type === 'Identifier' && whenVal.name === 'undefined')) {
|
|
216
|
+
out.conditional = true;
|
|
217
|
+
const keys = whenKeys(whenProp.value);
|
|
218
|
+
if (keys?.length)
|
|
219
|
+
out.when_keys = keys;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
});
|
|
223
|
+
// The landing is the funnel's first screen, and there is one of it: landing_views and the prerender read it as such.
|
|
224
|
+
const landings = steps.map((s, i) => ({ s, i })).filter(x => x.s.role === 'landing');
|
|
225
|
+
if (landings.length > 1)
|
|
226
|
+
throw new Error(`${at(call)}: more than one step has role: 'landing' (${landings.map(x => x.s.id).join(', ')}); a funnel has one landing`);
|
|
227
|
+
if (landings.length === 1 && landings[0].i !== 0) {
|
|
228
|
+
throw new Error(`${at(call)}: the step '${landings[0].s.id}' has role: 'landing' but is not the first step; the landing is the first step of the funnel`);
|
|
229
|
+
}
|
|
230
|
+
return steps;
|
|
231
|
+
}
|
|
232
|
+
// `prerender: true` in the object given to defineFunnel (kit 0.11.0): draw the landing step into index.html at build time. A
|
|
233
|
+
// literal true or false only; anything else is an error, never a guess. Absent: false.
|
|
234
|
+
export function extractPrerender(files) {
|
|
235
|
+
for (const f of files) {
|
|
236
|
+
let found = null;
|
|
237
|
+
walk(parseSource(f).program, n => {
|
|
238
|
+
if (!isCall(n, 'defineFunnel'))
|
|
239
|
+
return;
|
|
240
|
+
const arg = unwrap(n.arguments[0]);
|
|
241
|
+
const prop = arg?.type === 'ObjectExpression' && arg.properties.find((p) => p.type === 'ObjectProperty' && !p.computed && (p.key.name === 'prerender' || p.key.value === 'prerender'));
|
|
242
|
+
if (!prop)
|
|
243
|
+
return;
|
|
244
|
+
const v = unwrap(prop.value);
|
|
245
|
+
if (v?.type !== 'BooleanLiteral')
|
|
246
|
+
throw new Error(`${f.path}:${prop.loc?.start.line}: defineFunnel() prerender must be true or false (a literal)`);
|
|
247
|
+
found = v.value;
|
|
248
|
+
});
|
|
249
|
+
if (found !== null)
|
|
250
|
+
return found;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { buildFunnel } from './build.js';
|
|
4
|
+
import { runUpload } from './upload.js';
|
|
5
|
+
import { runInit } from './init.js';
|
|
6
|
+
import { arg as argArg, flag, opt as optArg } from './args.js';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
const argv = process.argv;
|
|
9
|
+
const arg = (n) => argArg(argv, n);
|
|
10
|
+
const opt = (n) => optArg(argv, n);
|
|
11
|
+
const git = (c) => { try {
|
|
12
|
+
return execSync(`git ${c}`, { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return 'unknown';
|
|
16
|
+
} };
|
|
17
|
+
const commit = process.env.GITHUB_SHA || git('rev-parse HEAD');
|
|
18
|
+
const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || git('rev-parse --abbrev-ref HEAD');
|
|
19
|
+
async function main() {
|
|
20
|
+
const cmd = process.argv[2];
|
|
21
|
+
if (cmd === 'init') {
|
|
22
|
+
for (const l of runInit({ project: arg('project'), dir: opt('dir') ?? process.cwd(), force: flag(argv, 'force') }))
|
|
23
|
+
console.log(l);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
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>]');
|
|
28
|
+
const funnel = arg('funnel');
|
|
29
|
+
if (cmd === 'build') {
|
|
30
|
+
console.log(await buildFunnel(process.cwd(), funnel, { commit, branch }));
|
|
31
|
+
}
|
|
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') });
|
|
34
|
+
for (const l of lines)
|
|
35
|
+
console.log(l);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
main().catch(e => { console.error(e.message); process.exit(1); });
|
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
// The template ships inside the package: kit/templates/init, the same distance from src/cli and dist/cli.
|
|
5
|
+
const templateDir = fileURLToPath(new URL('../../templates/init/', import.meta.url));
|
|
6
|
+
const kitVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
7
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
8
|
+
// npm never packs a file named .gitignore, so the template calls it gitignore. *.tmpl files get {{project}} and {{kit_version}} filled in.
|
|
9
|
+
const RENAME = { gitignore: '.gitignore' };
|
|
10
|
+
function files(dir) {
|
|
11
|
+
return readdirSync(dir).sort().flatMap(n => {
|
|
12
|
+
const p = join(dir, n);
|
|
13
|
+
return statSync(p).isDirectory() ? files(p) : [p];
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
export function runInit(o) {
|
|
17
|
+
if (!SLUG.test(o.project))
|
|
18
|
+
throw new Error(`--project "${o.project}" is not a valid slug: lowercase letters, digits and hyphens, up to 64 characters, starting with a letter or digit`);
|
|
19
|
+
const dir = resolve(o.dir);
|
|
20
|
+
if (existsSync(dir)) {
|
|
21
|
+
if (!statSync(dir).isDirectory())
|
|
22
|
+
throw new Error(`${dir} is not a folder`);
|
|
23
|
+
const busy = readdirSync(dir).filter(n => n !== '.git');
|
|
24
|
+
if (busy.length && !o.force)
|
|
25
|
+
throw new Error(`${dir} is not empty (${busy.slice(0, 3).join(', ')}${busy.length > 3 ? ', …' : ''}). Run init in an empty folder (--dir <folder>); --force writes over the template files and needs the project owner's consent`);
|
|
26
|
+
}
|
|
27
|
+
// Everything is read and filled in before the first byte is written.
|
|
28
|
+
const out = files(templateDir).map(src => {
|
|
29
|
+
const rel = relative(templateDir, src);
|
|
30
|
+
const base = basename(rel);
|
|
31
|
+
const name = RENAME[base] ? join(dirname(rel), RENAME[base]) : rel.replace(/\.tmpl$/, '');
|
|
32
|
+
let text = readFileSync(src, 'utf8');
|
|
33
|
+
if (rel.endsWith('.tmpl'))
|
|
34
|
+
text = text.replaceAll('{{project}}', o.project).replaceAll('{{kit_version}}', kitVersion);
|
|
35
|
+
return { path: join(dir, name), text };
|
|
36
|
+
});
|
|
37
|
+
// Writing through a link could change a file outside the folder: refuse any symlink (also a dangling one) on the way to a target.
|
|
38
|
+
for (const f of out) {
|
|
39
|
+
let p = dir;
|
|
40
|
+
for (const seg of relative(dir, f.path).split(/[\\/]/)) {
|
|
41
|
+
p = join(p, seg);
|
|
42
|
+
let link = false;
|
|
43
|
+
try {
|
|
44
|
+
link = lstatSync(p).isSymbolicLink();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
break;
|
|
48
|
+
} // does not exist yet: nothing below it can be a link
|
|
49
|
+
if (link)
|
|
50
|
+
throw new Error(`${p} is a symlink; init does not write through symlinks. Remove it or use another folder`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const f of out) {
|
|
54
|
+
mkdirSync(dirname(f.path), { recursive: true });
|
|
55
|
+
writeFileSync(f.path, f.text);
|
|
56
|
+
}
|
|
57
|
+
const here = resolve(process.cwd()) === dir;
|
|
58
|
+
return [
|
|
59
|
+
`Created the funnel-kit project "${o.project}" in ${dir}`,
|
|
60
|
+
'Next:',
|
|
61
|
+
...(here ? [] : [` cd ${dir}`]),
|
|
62
|
+
' npm install',
|
|
63
|
+
' export FUNNELS_TOKEN=<project token from the owner>',
|
|
64
|
+
' npx funnel-kit build --funnel welcome',
|
|
65
|
+
' npx funnel-kit upload --funnel welcome',
|
|
66
|
+
];
|
|
67
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type LintError = {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
rule: 'text-outside-t' | 'placeholder' | 'event-name' | 'answer-key';
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
export type LintWarning = {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
rule: 'answer-without-label' | 'click-without-name' | 'track-answers' | 'skip-answers-unset' | 'event-name' | 'answer-key' | 'parse-error';
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function check(files: {
|
|
14
|
+
path: string;
|
|
15
|
+
code: string;
|
|
16
|
+
}[]): {
|
|
17
|
+
errors: LintError[];
|
|
18
|
+
warnings: LintWarning[];
|
|
19
|
+
};
|
|
20
|
+
export declare function lint(files: {
|
|
21
|
+
path: string;
|
|
22
|
+
code: string;
|
|
23
|
+
}[]): LintError[];
|
|
24
|
+
export declare function formatLint(errors: LintError[]): string;
|
|
25
|
+
export declare function formatWarnings(warnings: LintWarning[]): string;
|