@sumly/wizard 0.0.0-stage → 1.0.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/LICENSE +9 -0
- package/README.md +40 -2
- package/bin.mjs +4 -0
- package/package.json +16 -4
- package/src/cli.mjs +343 -0
- package/src/detect.mjs +183 -0
- package/src/diff.mjs +70 -0
- package/src/edits.mjs +418 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Primož Cigoj s.p.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sumly/wizard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Adds [Sumly](https://sumly.dev) to your project in one command:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @sumly/wizard --key sl_…
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It finds how the project is built, shows the one change Sumly needs as a diff, writes it when you say yes, and waits until the first visit arrives. Your project's key (`sl_…`) is on Sumly's Visitors page; leave `--key` out and the wizard asks for it and opens that page.
|
|
10
|
+
|
|
11
|
+
| Project | What changes |
|
|
12
|
+
|---|---|
|
|
13
|
+
| Next.js, App Router | `<Script>` in `app/layout.tsx` |
|
|
14
|
+
| Next.js, Pages Router | `<Script>` in `pages/_app.tsx` (made if missing) |
|
|
15
|
+
| React or Vue with Vite, Create React App, Angular | the line in `index.html` |
|
|
16
|
+
| Nuxt | `app.head.script` in `nuxt.config.ts` |
|
|
17
|
+
| SvelteKit | the line in `src/app.html` |
|
|
18
|
+
| Astro | the line, `is:inline`, in each layout with its own `<head>` |
|
|
19
|
+
| React Router (framework mode), Remix | the line after `<Links />` in `app/root.tsx` |
|
|
20
|
+
| Laravel | the line in each Blade layout with its own `<head>` (not e-mails) |
|
|
21
|
+
| Plain HTML | the line in every page |
|
|
22
|
+
| React Native, Expo | `@sumly/sdk`: `Sumly.init` at the top of `App.tsx` or `app/_layout.tsx`, screens, and taps with `--autocapture` |
|
|
23
|
+
|
|
24
|
+
Anything else gets the line and the guide for its platform. A file that already has Sumly is left alone, apart from its key and `data-autocapture`, so running it twice changes nothing. Nothing outside the files in the diff is touched, and nothing is written before you say yes.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
--key sl_… your project's public key; asked for when left out
|
|
28
|
+
--dir <folder> the project (default: this folder)
|
|
29
|
+
--autocapture count every click, or every tap in an app, without marking anything
|
|
30
|
+
--no-autocapture leave clicks to the ones you mark
|
|
31
|
+
--file <path> only this file (repeat for more)
|
|
32
|
+
-y, --yes no questions: write the change and install what it needs
|
|
33
|
+
--dry-run show the change and stop
|
|
34
|
+
--no-install do not install packages (apps)
|
|
35
|
+
--no-wait do not wait for the first visit
|
|
36
|
+
--wait <minutes> how long to wait for it (default 10)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The key it uses is the public one that is in your site's HTML anyway: it can only send visits. The wizard reads your project's name and its line from `https://sumly.dev/api/v1/install` and sends nothing else.
|
|
40
|
+
|
|
41
|
+
No dependencies, Node 18 or later. MIT.
|
package/bin.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sumly/wizard",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Add Sumly to your project in one command: finds the framework, shows the one change, writes it when you say yes, and waits for the first visit.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://sumly.dev/install",
|
|
7
|
+
"bugs": "https://sumly.dev/support",
|
|
8
|
+
"author": "Primož Cigoj s.p. (https://sumly.dev)",
|
|
9
|
+
"keywords": ["sumly", "analytics", "wizard", "setup", "nextjs", "nuxt", "astro", "sveltekit", "laravel", "react-native", "expo"],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": { "sumly-wizard": "bin.mjs" },
|
|
12
|
+
"files": ["bin.mjs", "src", "README.md", "LICENSE"],
|
|
13
|
+
"engines": { "node": ">=18.17" },
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test test/*.test.mjs",
|
|
16
|
+
"prepublishOnly": "npm test"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// npx @sumly/wizard: finds the framework, shows the one change Sumly needs, writes it when the person says
|
|
2
|
+
// yes, and waits for the first visit. No AI, no account on the command line: the project's public key only.
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { detect } from './detect.mjs';
|
|
8
|
+
import { unified } from './diff.mjs';
|
|
9
|
+
import { plan } from './edits.mjs';
|
|
10
|
+
|
|
11
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
12
|
+
const KEY = /^sl_[A-Za-z0-9]{6,64}$/;
|
|
13
|
+
|
|
14
|
+
const HELP = `Sumly wizard ${VERSION}: adds Sumly to your project in one go.
|
|
15
|
+
|
|
16
|
+
npx @sumly/wizard --key sl_…
|
|
17
|
+
|
|
18
|
+
--key sl_… your project's public key (Sumly › Visitors); asked for when left out
|
|
19
|
+
--dir <folder> the project (default: this folder)
|
|
20
|
+
--autocapture count every click, or every tap in an app, without marking anything
|
|
21
|
+
--no-autocapture leave clicks to the ones you mark
|
|
22
|
+
--file <path> only this file (repeat for more), when the project has several pages
|
|
23
|
+
-y, --yes no questions: write the change and install what it needs
|
|
24
|
+
--dry-run show the change and stop
|
|
25
|
+
--no-install do not install packages (apps)
|
|
26
|
+
--no-wait do not wait for the first visit
|
|
27
|
+
--wait <minutes> how long to wait for it (default 10)
|
|
28
|
+
--host <url> another Sumly address (default https://sumly.dev)
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
/** @returns {Record<string, any>} */
|
|
32
|
+
export function parse(argv) {
|
|
33
|
+
const o = { files: [], wait: 10 };
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
const [flag, inline] = a.startsWith('--') && a.includes('=') ? [a.slice(0, a.indexOf('=')), a.slice(a.indexOf('=') + 1)] : [a, undefined];
|
|
37
|
+
const value = () => inline ?? argv[++i];
|
|
38
|
+
switch (flag) {
|
|
39
|
+
case '--key': o.key = value(); break;
|
|
40
|
+
case '--dir': o.dir = value(); break;
|
|
41
|
+
case '--host': o.host = value(); break;
|
|
42
|
+
case '--file': o.files.push(value()); break;
|
|
43
|
+
case '--wait': o.wait = Number(value()); break;
|
|
44
|
+
case '--autocapture': o.autocapture = true; break;
|
|
45
|
+
case '--no-autocapture': o.autocapture = false; break;
|
|
46
|
+
case '-y':
|
|
47
|
+
case '--yes': o.yes = true; break;
|
|
48
|
+
case '--dry-run': o.dryRun = true; break;
|
|
49
|
+
case '--no-install': o.install = false; break;
|
|
50
|
+
case '--no-wait': o.wait = 0; break;
|
|
51
|
+
case '-h':
|
|
52
|
+
case '--help': o.help = true; break;
|
|
53
|
+
case '-v':
|
|
54
|
+
case '--version': o.version = true; break;
|
|
55
|
+
default:
|
|
56
|
+
if (KEY.test(a)) o.key = a;
|
|
57
|
+
else o.unknown = a;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return o;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Questions on any input: a pasted answer, a pipe, or nothing (then the default). */
|
|
64
|
+
function prompter(input, output) {
|
|
65
|
+
const rl = createInterface({ input, terminal: false });
|
|
66
|
+
const lines = [];
|
|
67
|
+
const waiting = [];
|
|
68
|
+
let closed = false;
|
|
69
|
+
// A piped answer is not echoed by a terminal, so the transcript shows it here.
|
|
70
|
+
const echo = (line) => {
|
|
71
|
+
if (!input.isTTY) output.write(`${line}\n`);
|
|
72
|
+
return line;
|
|
73
|
+
};
|
|
74
|
+
rl.on('line', (line) => (waiting.length ? waiting.shift()(echo(line)) : lines.push(line)));
|
|
75
|
+
rl.on('close', () => {
|
|
76
|
+
closed = true;
|
|
77
|
+
while (waiting.length) waiting.shift()(null);
|
|
78
|
+
});
|
|
79
|
+
const ask = (question) => {
|
|
80
|
+
output.write(question);
|
|
81
|
+
if (lines.length) return Promise.resolve(echo(lines.shift()));
|
|
82
|
+
if (closed) {
|
|
83
|
+
output.write('\n');
|
|
84
|
+
return Promise.resolve(null);
|
|
85
|
+
}
|
|
86
|
+
return new Promise((r) => waiting.push(r));
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
ask,
|
|
90
|
+
async yes(question, fallback) {
|
|
91
|
+
const answer = (await ask(`${question} ${fallback ? '(Y/n)' : '(y/N)'} `))?.trim().toLowerCase();
|
|
92
|
+
return !answer ? fallback : answer.startsWith('y');
|
|
93
|
+
},
|
|
94
|
+
close: () => rl.close(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The project Sumly knows by this key: its name, its line and the last visit it had. */
|
|
99
|
+
export async function project(host, key, fetcher = fetch) {
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetcher(`${host}/api/v1/install`, {
|
|
102
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json', 'User-Agent': `sumly-wizard/${VERSION}` },
|
|
103
|
+
signal: AbortSignal.timeout(15000),
|
|
104
|
+
});
|
|
105
|
+
const body = await response.json().catch(() => ({}));
|
|
106
|
+
return { status: response.status, body };
|
|
107
|
+
} catch {
|
|
108
|
+
return { status: 0, body: {} };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function openBrowser(url) {
|
|
113
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
114
|
+
try {
|
|
115
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
|
|
116
|
+
} catch {
|
|
117
|
+
// The address is printed too.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The command that adds packages with the project's own package manager. */
|
|
122
|
+
export function installCommand(project, packages) {
|
|
123
|
+
const expo = Object.prototype.hasOwnProperty.call(project.deps, 'expo');
|
|
124
|
+
if (expo) return ['npx', ['expo', 'install', ...packages]];
|
|
125
|
+
return {
|
|
126
|
+
pnpm: ['pnpm', ['add', ...packages]],
|
|
127
|
+
yarn: ['yarn', ['add', ...packages]],
|
|
128
|
+
bun: ['bun', ['add', ...packages]],
|
|
129
|
+
npm: ['npm', ['install', ...packages]],
|
|
130
|
+
}[project.pm];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @param {string[]} argv
|
|
137
|
+
* @param {{ cwd?: string, stdin?: NodeJS.ReadableStream, stdout?: NodeJS.WritableStream, fetch?: typeof fetch, env?: Record<string, string|undefined>, run?: (cmd: string, args: string[], cwd: string) => number, poll?: number, open?: (url: string) => void }} io
|
|
138
|
+
* @returns {Promise<number>} the exit code
|
|
139
|
+
*/
|
|
140
|
+
export async function run(argv, io = {}) {
|
|
141
|
+
const out = io.stdout ?? process.stdout;
|
|
142
|
+
const env = io.env ?? process.env;
|
|
143
|
+
const o = parse(argv);
|
|
144
|
+
const color = Boolean(out.isTTY) && !env.NO_COLOR;
|
|
145
|
+
const paint = (code) => (text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
146
|
+
const bold = paint('1');
|
|
147
|
+
const dim = paint('2');
|
|
148
|
+
const green = paint('32');
|
|
149
|
+
const yellow = paint('33');
|
|
150
|
+
const say = (line = '') => out.write(line + '\n');
|
|
151
|
+
|
|
152
|
+
if (o.help) {
|
|
153
|
+
out.write(HELP);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (o.version) {
|
|
157
|
+
say(VERSION);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (o.unknown) {
|
|
161
|
+
say(`Unknown option: ${o.unknown}\n`);
|
|
162
|
+
out.write(HELP);
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const host = (o.host ?? env.SUMLY_HOST ?? 'https://sumly.dev').replace(/\/+$/, '');
|
|
167
|
+
const dir = resolve(io.cwd ?? process.cwd(), o.dir ?? '.');
|
|
168
|
+
const prompt = prompter(io.stdin ?? process.stdin, out);
|
|
169
|
+
const interactive = !o.yes;
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
say(`${bold('Sumly')} ${dim(`wizard ${VERSION}`)}`);
|
|
173
|
+
say();
|
|
174
|
+
|
|
175
|
+
const found = detect(dir);
|
|
176
|
+
say(`${green('◆')} Found ${bold(found.name)}${found.files.length ? dim(` · ${found.files.slice(0, 3).join(', ')}${found.files.length > 3 ? ` and ${found.files.length - 3} more` : ''}`) : found.create ? dim(` · ${found.create} (new)`) : ''}`);
|
|
177
|
+
const guide = `${host}/install${found.guide ? `/${found.guide}` : ''}`;
|
|
178
|
+
|
|
179
|
+
let key = o.key ?? env.SUMLY_KEY;
|
|
180
|
+
while (!key) {
|
|
181
|
+
if (!interactive) {
|
|
182
|
+
say('No key. Pass --key sl_… (Sumly › Visitors shows it).');
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
const answer = await prompt.ask(`${green('◆')} Your project's key (sl_…). Press Enter to open Sumly and copy it: `);
|
|
186
|
+
if (answer === null) {
|
|
187
|
+
say('No key. Pass --key sl_… (Sumly › Visitors shows it).');
|
|
188
|
+
return 2;
|
|
189
|
+
}
|
|
190
|
+
if (answer.trim() === '') {
|
|
191
|
+
const url = `${host}/visitors?install=1#install`;
|
|
192
|
+
(io.open ?? openBrowser)(url);
|
|
193
|
+
say(dim(` Opened ${url}`));
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
key = answer.trim();
|
|
197
|
+
}
|
|
198
|
+
if (!KEY.test(key)) {
|
|
199
|
+
say(`That is not a project key: it starts with sl_. Sumly › Visitors shows it.`);
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const known = await project(host, key, io.fetch);
|
|
204
|
+
if (known.status === 401) {
|
|
205
|
+
say(`Sumly does not know the key ${key}. Copy it again from Sumly › Visitors.`);
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
if (known.status !== 200 && known.status !== 0) {
|
|
209
|
+
say(known.body.error ?? `Sumly answered ${known.status}.`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
const info = known.status === 200 ? known.body : null;
|
|
213
|
+
if (info) say(`${green('◆')} Project ${bold(info.project)}${info.domain ? dim(` · ${info.domain}`) : ''}`);
|
|
214
|
+
else say(`${yellow('▲')} Could not reach ${host}: the line uses the usual address, and the wizard will not wait for the first visit.`);
|
|
215
|
+
|
|
216
|
+
if (found.kind === 'none') {
|
|
217
|
+
say();
|
|
218
|
+
say(found.id === 'unknown' ? 'The wizard does not know how this project is built yet.' : `The wizard did not find the file Sumly goes into in this ${found.name} project.`);
|
|
219
|
+
if (found.id === 'expo' || found.id === 'react-native') say(`Add the SDK where the app starts:\n\n import { Sumly } from '@sumly/sdk/react-native';\n Sumly.init({ siteKey: '${key}' });\n`);
|
|
220
|
+
else say(`Add this line to the <head> every page shares:\n\n <script defer src="${info?.script ?? `${host}/s.js`}" data-site="${key}"></script>\n`);
|
|
221
|
+
say(`Step by step: ${guide}`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const app = found.kind === 'sdk';
|
|
226
|
+
let autocapture = o.autocapture;
|
|
227
|
+
if (autocapture === undefined) {
|
|
228
|
+
autocapture = interactive
|
|
229
|
+
? await prompt.yes(`${green('◆')} Count every ${app ? 'tap' : 'click'} by itself, without marking buttons?`, Boolean(info?.autocapture))
|
|
230
|
+
: Boolean(info?.autocapture);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const has = (name) => Object.prototype.hasOwnProperty.call(found.deps, name);
|
|
234
|
+
let storage = false;
|
|
235
|
+
if (app) {
|
|
236
|
+
storage = has('@react-native-async-storage/async-storage') || !interactive || (await prompt.yes(`${green('◆')} Keep unsent screens across restarts with AsyncStorage (adds the package)?`, true));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let files = o.files.map((f) => f.replace(/\\/g, '/').replace(/^\.\//, ''));
|
|
240
|
+
if (!files.length && found.files.length > 1 && interactive) {
|
|
241
|
+
say(` ${found.files.length} files have their own <head>, and each page needs the line:`);
|
|
242
|
+
for (const f of found.files) say(dim(` ${f}`));
|
|
243
|
+
if (!(await prompt.yes(`${green('◆')} Add it to all of them?`, true))) {
|
|
244
|
+
for (const f of found.files) if (await prompt.yes(` ${f}?`, true)) files.push(f);
|
|
245
|
+
if (!files.length) {
|
|
246
|
+
say('Nothing picked, nothing changed.');
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const options = { key, src: info?.script ?? `${host}/s.js`, domains: info?.domains ?? null, autocapture, storage, files };
|
|
253
|
+
const result = plan(dir, found, options);
|
|
254
|
+
|
|
255
|
+
say();
|
|
256
|
+
for (const f of result.already) say(`${green('✓')} ${f} already has Sumly`);
|
|
257
|
+
for (const p of result.problems) say(`${yellow('▲')} ${p.file}: ${p.reason}. Step by step: ${guide}`);
|
|
258
|
+
for (const c of result.changes) {
|
|
259
|
+
say(unified(c.file, c.before, c.after, { color }));
|
|
260
|
+
say();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.changes.length && o.dryRun) {
|
|
264
|
+
say(dim('Dry run: nothing written.'));
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
if (result.changes.length) {
|
|
268
|
+
const write = !interactive || (await prompt.yes(`${green('◆')} Write ${result.changes.length === 1 ? 'this change' : `these ${result.changes.length} changes`}?`, true));
|
|
269
|
+
if (!write) {
|
|
270
|
+
say('Nothing written.');
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
for (const c of result.changes) {
|
|
274
|
+
mkdirSync(dirname(join(dir, c.file)), { recursive: true });
|
|
275
|
+
writeFileSync(join(dir, c.file), c.after);
|
|
276
|
+
say(`${green('✓')} Wrote ${c.file}`);
|
|
277
|
+
}
|
|
278
|
+
} else if (!result.problems.length) {
|
|
279
|
+
say(`${green('✓')} Nothing to change.`);
|
|
280
|
+
}
|
|
281
|
+
if (result.problems.length && !result.changes.length && !result.already.length) return 1;
|
|
282
|
+
|
|
283
|
+
if (app && o.install !== false && !o.dryRun) {
|
|
284
|
+
const missing = ['@sumly/sdk', ...(storage ? ['@react-native-async-storage/async-storage'] : [])].filter((p) => !has(p));
|
|
285
|
+
if (missing.length) {
|
|
286
|
+
const [cmd, args] = installCommand(found, missing);
|
|
287
|
+
const line = `${cmd} ${args.join(' ')}`;
|
|
288
|
+
if (!interactive || (await prompt.yes(`${green('◆')} Run ${bold(line)}?`, true))) {
|
|
289
|
+
const code = (io.run ?? ((c, a, cwd) => spawnSync(c, a, { cwd, stdio: 'inherit', shell: process.platform === 'win32' }).status ?? 1))(cmd, args, dir);
|
|
290
|
+
if (code !== 0) say(`${yellow('▲')} That did not finish. Run it yourself: ${line}`);
|
|
291
|
+
} else {
|
|
292
|
+
say(` Run it yourself: ${line}`);
|
|
293
|
+
}
|
|
294
|
+
if (missing.includes('@react-native-async-storage/async-storage') && !has('expo')) say(dim(' Then, for iOS: cd ios && pod install'));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const n of result.notes) say(`${yellow('▲')} ${n}`);
|
|
299
|
+
if (autocapture && info && info.autocapture === false) {
|
|
300
|
+
say(`${yellow('▲')} Turn on ${bold('Every click, without marking')} for ${info.project} in Sumly, or the ${app ? 'taps' : 'clicks'} are not kept: ${info.settings_url ?? `${host}/accounts`}`);
|
|
301
|
+
}
|
|
302
|
+
if (!app && interactive) {
|
|
303
|
+
if (await prompt.yes(`${green('◆')} Do you take payments with Stripe?`, false)) {
|
|
304
|
+
say(' Payment Links, pricing tables and buy buttons on the page carry the visitor into checkout by themselves.');
|
|
305
|
+
say(` A Checkout Session your server makes needs metadata: { sumly_visitor_id: <the sumly_vid cookie> }. How: ${guide}#checkout`);
|
|
306
|
+
say(` Then connect Stripe in Sumly with a read-only key: ${host}/accounts`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
prompt.close();
|
|
311
|
+
if (!info || o.wait <= 0 || o.dryRun) {
|
|
312
|
+
say();
|
|
313
|
+
say(`Done. Deploy, open the ${app ? 'app' : 'site'}, and the visit shows in Sumly: ${host}/visitors`);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
say();
|
|
318
|
+
say(app ? 'Now run the app (a dev build is fine) and open a screen.' : 'Now deploy, or start your dev server (localhost counts too), and open the site.');
|
|
319
|
+
const started = Date.now();
|
|
320
|
+
const seen = (body) => body.last_seen_at && Date.parse(body.last_seen_at) >= started - 2000;
|
|
321
|
+
if (info.last_seen_at) say(dim(` ${info.project} last had a visit ${new Date(info.last_seen_at).toLocaleString()}; waiting for a new one.`));
|
|
322
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
323
|
+
const waiting = `Waiting for the first ${app ? 'screen' : 'visit'}… (it can take a minute; Ctrl+C to stop)`;
|
|
324
|
+
if (!out.isTTY) say(waiting);
|
|
325
|
+
const every = io.poll ?? 3000;
|
|
326
|
+
for (let tick = 0; Date.now() - started < o.wait * 60_000; tick++) {
|
|
327
|
+
if (out.isTTY) out.write(`\r${frames[tick % frames.length]} ${waiting}`);
|
|
328
|
+
const now = await project(host, key, io.fetch);
|
|
329
|
+
if (now.status === 200 && seen(now.body)) {
|
|
330
|
+
if (out.isTTY) out.write('\r\x1b[K');
|
|
331
|
+
say(`${green('✓')} Got it. ${now.body.project} is receiving ${app ? 'screens' : 'visits'}: ${now.body.visitors_url ?? `${host}/visitors`}`);
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
await sleep(every);
|
|
335
|
+
}
|
|
336
|
+
if (out.isTTY) out.write('\r\x1b[K');
|
|
337
|
+
say(`${yellow('▲')} No visit yet. Is the change deployed? An ad blocker in your browser stops it too${info.domain ? `, and visits from outside ${info.domain} (other than localhost) are not counted` : ''}.`);
|
|
338
|
+
say(` Sumly › Visitors shows it as soon as it comes: ${info.visitors_url ?? `${host}/visitors`}`);
|
|
339
|
+
return 0;
|
|
340
|
+
} finally {
|
|
341
|
+
prompt.close();
|
|
342
|
+
}
|
|
343
|
+
}
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Which framework a project is built with, from its package.json, composer.json and files,
|
|
2
|
+
// and which files of it Sumly's line (or the app SDK) goes into.
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** Folders that are built, installed or someone else's: never searched, never edited. */
|
|
7
|
+
const SKIP = new Set(['node_modules', '.git', 'vendor', 'dist', 'build', 'out', '.next', '.nuxt', '.output', '.svelte-kit', '.astro', '.vercel', '.netlify', 'coverage', 'storage', 'bootstrap', 'ios', 'android', '.expo', 'tmp', '.cache']);
|
|
8
|
+
|
|
9
|
+
/** Blade and Astro folders that hold e-mails, PDFs and the like, not pages. */
|
|
10
|
+
const NOT_PAGES = /(^|\/)(mail|mails|emails?|vendor|pdfs?|notifications)(\/|$)/i;
|
|
11
|
+
|
|
12
|
+
export const FRAMEWORKS = {
|
|
13
|
+
'next-app': { name: 'Next.js (App Router)', guide: 'nextjs' },
|
|
14
|
+
'next-pages': { name: 'Next.js (Pages Router)', guide: 'nextjs' },
|
|
15
|
+
'react-router': { name: 'React Router (framework mode)', guide: 'react-router' },
|
|
16
|
+
remix: { name: 'Remix', guide: 'react-router' },
|
|
17
|
+
nuxt: { name: 'Nuxt', guide: 'vue' },
|
|
18
|
+
sveltekit: { name: 'SvelteKit', guide: null },
|
|
19
|
+
astro: { name: 'Astro', guide: 'astro' },
|
|
20
|
+
laravel: { name: 'Laravel (Blade)', guide: 'laravel' },
|
|
21
|
+
expo: { name: 'Expo (Expo Router)', guide: 'react-native' },
|
|
22
|
+
'react-native': { name: 'React Native', guide: 'react-native' },
|
|
23
|
+
vite: { name: 'Vite', guide: 'react-router' },
|
|
24
|
+
cra: { name: 'Create React App', guide: 'react-router' },
|
|
25
|
+
angular: { name: 'Angular', guide: null },
|
|
26
|
+
html: { name: 'Plain HTML', guide: null },
|
|
27
|
+
unknown: { name: 'Something Sumly does not know yet', guide: null },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const read = (dir, file) => {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(join(dir, file), 'utf8');
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const json = (dir, file) => {
|
|
39
|
+
const text = read(dir, file);
|
|
40
|
+
if (text === null) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The first of these paths that exists, relative to the project. */
|
|
49
|
+
export function first(dir, paths) {
|
|
50
|
+
return paths.find((p) => existsSync(join(dir, p))) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const variants = (base, exts) => exts.map((e) => base + e);
|
|
54
|
+
const JS = ['.tsx', '.jsx', '.ts', '.js', '.mjs'];
|
|
55
|
+
|
|
56
|
+
/** Files under dir/start ending in one of exts, skipping built and installed folders; at most limit of them. */
|
|
57
|
+
export function walk(dir, start, exts, limit = 400) {
|
|
58
|
+
const found = [];
|
|
59
|
+
const visit = (folder) => {
|
|
60
|
+
if (found.length >= limit) return;
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = readdirSync(join(dir, folder), { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (found.length >= limit) return;
|
|
70
|
+
const path = folder ? `${folder}/${entry.name}` : entry.name;
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
if (!SKIP.has(entry.name) && !entry.name.startsWith('.')) visit(path);
|
|
73
|
+
} else if (exts.some((e) => entry.name.endsWith(e))) {
|
|
74
|
+
found.push(path);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
visit(start);
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Page shells: files with their own </head>, each a document that needs the line. */
|
|
83
|
+
function shells(dir, start, exts, rank) {
|
|
84
|
+
return walk(dir, start, exts)
|
|
85
|
+
.filter((p) => !NOT_PAGES.test(p) && /<\/head\s*>/i.test(read(dir, p) ?? ''))
|
|
86
|
+
.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const bladeRank = (p) => {
|
|
90
|
+
const order = [/^resources\/views\/app\.blade\.php$/, /^resources\/views\/layouts\/app\.blade\.php$/, /^resources\/views\/components\/layouts\/app\.blade\.php$/, /\/layouts\//, /^resources\/views\/welcome\.blade\.php$/];
|
|
91
|
+
const i = order.findIndex((r) => r.test(p));
|
|
92
|
+
return i === -1 ? order.length : i;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const astroRank = (p) => (/\/layouts\//.test(p) ? 0 : /\/pages\//.test(p) ? 1 : 2);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What the project is and where Sumly goes.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{ id: string, name: string, guide: string|null, kind: 'script'|'sdk'|'none', files: string[], create?: string, deps: Record<string,string>, pm: string, typescript: boolean }}
|
|
101
|
+
*/
|
|
102
|
+
export function detect(dir) {
|
|
103
|
+
const pkg = json(dir, 'package.json');
|
|
104
|
+
const composer = json(dir, 'composer.json');
|
|
105
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
106
|
+
const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
|
|
107
|
+
const typescript = has('typescript') || existsSync(join(dir, 'tsconfig.json'));
|
|
108
|
+
const pm = existsSync(join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(dir, 'yarn.lock')) ? 'yarn' : existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock')) ? 'bun' : 'npm';
|
|
109
|
+
const result = (id, kind, files, extra = {}) => ({ id, ...FRAMEWORKS[id], kind, files: files.filter(Boolean), deps, pm, typescript, ...extra });
|
|
110
|
+
|
|
111
|
+
if (has('react-native') || has('expo')) {
|
|
112
|
+
if (has('expo-router')) {
|
|
113
|
+
const layout = first(dir, [...variants('app/_layout', JS), ...variants('src/app/_layout', JS)]);
|
|
114
|
+
if (layout) return result('expo', 'sdk', [layout]);
|
|
115
|
+
}
|
|
116
|
+
const app = first(dir, [...variants('App', JS), ...variants('src/App', JS), ...variants('app/App', JS)]);
|
|
117
|
+
return result(has('expo') && has('expo-router') ? 'expo' : 'react-native', app ? 'sdk' : 'none', [app]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (has('next')) {
|
|
121
|
+
const layout = first(dir, [...variants('app/layout', JS), ...variants('src/app/layout', JS)]);
|
|
122
|
+
if (layout) return result('next-app', 'script', [layout]);
|
|
123
|
+
const app = first(dir, [...variants('pages/_app', JS), ...variants('src/pages/_app', JS)]);
|
|
124
|
+
if (app) return result('next-pages', 'script', [app]);
|
|
125
|
+
const pages = existsSync(join(dir, 'src/pages')) ? 'src/pages' : 'pages';
|
|
126
|
+
return result('next-pages', 'script', [], { create: `${pages}/_app.${typescript ? 'tsx' : 'jsx'}` });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (has('nuxt') || has('nuxt3')) {
|
|
130
|
+
const config = first(dir, ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs']);
|
|
131
|
+
return result('nuxt', config ? 'script' : 'none', [config]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (has('@sveltejs/kit')) {
|
|
135
|
+
return result('sveltekit', existsSync(join(dir, 'src/app.html')) ? 'script' : 'none', [existsSync(join(dir, 'src/app.html')) ? 'src/app.html' : null]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (has('astro')) {
|
|
139
|
+
const files = shells(dir, 'src', ['.astro'], astroRank);
|
|
140
|
+
return result('astro', files.length ? 'script' : 'none', files);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (has('@react-router/dev') || has('@remix-run/react')) {
|
|
144
|
+
const root = first(dir, variants('app/root', JS));
|
|
145
|
+
return result(has('@react-router/dev') ? 'react-router' : 'remix', root ? 'script' : 'none', [root]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const laravel = composer?.require && Object.prototype.hasOwnProperty.call(composer.require, 'laravel/framework');
|
|
149
|
+
if (laravel) {
|
|
150
|
+
const files = shells(dir, 'resources/views', ['.blade.php'], bladeRank);
|
|
151
|
+
return result('laravel', files.length ? 'script' : 'none', files);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (has('@angular/core')) {
|
|
155
|
+
return result('angular', existsSync(join(dir, 'src/index.html')) ? 'script' : 'none', [existsSync(join(dir, 'src/index.html')) ? 'src/index.html' : null]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (has('react-scripts')) {
|
|
159
|
+
return result('cra', existsSync(join(dir, 'public/index.html')) ? 'script' : 'none', [existsSync(join(dir, 'public/index.html')) ? 'public/index.html' : null]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (has('vite') && existsSync(join(dir, 'index.html'))) {
|
|
163
|
+
const flavour = ['react', 'vue', 'svelte', 'solid-js', 'preact', 'lit'].find(has);
|
|
164
|
+
const label = { react: 'React', vue: 'Vue', svelte: 'Svelte', 'solid-js': 'Solid', preact: 'Preact', lit: 'Lit' }[flavour];
|
|
165
|
+
return { ...result('vite', 'script', ['index.html']), name: label ? `${label} with Vite` : 'Vite' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const pages = shells(dir, '', ['.html', '.htm'], (p) => (p === 'index.html' ? 0 : p.split('/').length));
|
|
169
|
+
if (pages.length) return result('html', 'script', pages);
|
|
170
|
+
|
|
171
|
+
return result('unknown', 'none', []);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A path as the person reads it, with forward slashes. */
|
|
175
|
+
export const show = (dir, path) => relative(dir, join(dir, path)).split(sep).join('/');
|
|
176
|
+
|
|
177
|
+
export const exists = (dir, path) => {
|
|
178
|
+
try {
|
|
179
|
+
return statSync(join(dir, path)).isFile();
|
|
180
|
+
} catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
};
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// A small unified diff of one file, so the person sees exactly what changes before anything is written.
|
|
2
|
+
|
|
3
|
+
/** Line operations from a longest common subsequence: [' ' | '-' | '+', line]. */
|
|
4
|
+
export function lines(before, after) {
|
|
5
|
+
const a = before === null ? [] : before.split('\n');
|
|
6
|
+
const b = after.split('\n');
|
|
7
|
+
// Same start and end are kept out of the table, so a large file with a small edit stays cheap.
|
|
8
|
+
let start = 0;
|
|
9
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start++;
|
|
10
|
+
let endA = a.length;
|
|
11
|
+
let endB = b.length;
|
|
12
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
13
|
+
endA--;
|
|
14
|
+
endB--;
|
|
15
|
+
}
|
|
16
|
+
const midA = a.slice(start, endA);
|
|
17
|
+
const midB = b.slice(start, endB);
|
|
18
|
+
const ops = a.slice(0, start).map((l) => [' ', l]);
|
|
19
|
+
if (midA.length * midB.length > 4_000_000) {
|
|
20
|
+
ops.push(...midA.map((l) => ['-', l]), ...midB.map((l) => ['+', l]));
|
|
21
|
+
} else {
|
|
22
|
+
const n = midA.length;
|
|
23
|
+
const m = midB.length;
|
|
24
|
+
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
25
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
26
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
27
|
+
table[i][j] = midA[i] === midB[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let i = 0;
|
|
31
|
+
let j = 0;
|
|
32
|
+
while (i < n && j < m) {
|
|
33
|
+
if (midA[i] === midB[j]) {
|
|
34
|
+
ops.push([' ', midA[i]]);
|
|
35
|
+
i++;
|
|
36
|
+
j++;
|
|
37
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
38
|
+
ops.push(['-', midA[i++]]);
|
|
39
|
+
} else {
|
|
40
|
+
ops.push(['+', midB[j++]]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
while (i < n) ops.push(['-', midA[i++]]);
|
|
44
|
+
while (j < m) ops.push(['+', midB[j++]]);
|
|
45
|
+
}
|
|
46
|
+
ops.push(...a.slice(endA).map((l) => [' ', l]));
|
|
47
|
+
return ops;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The diff as text: changed lines with up to `context` lines around them, and "…" between parts. */
|
|
51
|
+
export function unified(file, before, after, { context = 2, color = false } = {}) {
|
|
52
|
+
const ops = lines(before, after);
|
|
53
|
+
const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
54
|
+
const keep = new Set();
|
|
55
|
+
ops.forEach(([op], i) => {
|
|
56
|
+
if (op !== ' ') for (let k = i - context; k <= i + context; k++) keep.add(k);
|
|
57
|
+
});
|
|
58
|
+
const out = [paint('1', before === null ? `+++ ${file} (new file)` : `--- ${file}`)];
|
|
59
|
+
let gap = false;
|
|
60
|
+
ops.forEach(([op, line], i) => {
|
|
61
|
+
if (!keep.has(i)) {
|
|
62
|
+
gap = true;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (gap && out.length > 1) out.push(paint('2', ' …'));
|
|
66
|
+
gap = false;
|
|
67
|
+
out.push(op === '+' ? paint('32', `+ ${line}`) : op === '-' ? paint('31', `- ${line}`) : paint('2', ` ${line}`));
|
|
68
|
+
});
|
|
69
|
+
return out.join('\n');
|
|
70
|
+
}
|
package/src/edits.mjs
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// The one change per framework: Sumly's line in the layout every page shares, or the SDK's init in an app.
|
|
2
|
+
// Every edit is idempotent: a file that already has Sumly gets at most its key or data-autocapture brought
|
|
3
|
+
// up to date, and a second run changes nothing.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export class EditError extends Error {}
|
|
8
|
+
|
|
9
|
+
const SDK = '@sumly/sdk/react-native';
|
|
10
|
+
const STORAGE = '@react-native-async-storage/async-storage';
|
|
11
|
+
|
|
12
|
+
/** How a file is written: quotes, semicolons, one level of indent, line endings. */
|
|
13
|
+
export function style(content) {
|
|
14
|
+
const imports = content.split('\n').filter((l) => /^import\b/.test(l));
|
|
15
|
+
const singles = imports.filter((l) => /from\s+'/.test(l) || /^import\s+'/.test(l)).length;
|
|
16
|
+
const doubles = imports.filter((l) => /from\s+"/.test(l) || /^import\s+"/.test(l)).length;
|
|
17
|
+
const withSemi = imports.filter((l) => /;\s*$/.test(l)).length;
|
|
18
|
+
const indents = content.split('\n').map((l) => /^[\t ]*/.exec(l)[0]).filter((i) => i.length > 0);
|
|
19
|
+
const tab = indents.length > 0 && indents.filter((i) => i.startsWith('\t')).length > indents.length / 2;
|
|
20
|
+
const spaces = indents.filter((i) => !i.startsWith('\t')).map((i) => i.length);
|
|
21
|
+
const smallest = spaces.length ? Math.min(...spaces) : 2;
|
|
22
|
+
return {
|
|
23
|
+
q: doubles > singles ? '"' : "'",
|
|
24
|
+
semi: imports.length === 0 ? /;[ \t]*$/m.test(content) : withSemi >= imports.length / 2,
|
|
25
|
+
unit: tab ? '\t' : ' '.repeat(smallest >= 4 ? 4 : 2),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const indentOf = (line) => /^[\t ]*/.exec(line)[0];
|
|
30
|
+
|
|
31
|
+
/** The attributes of the line, in the order the Visitors page writes them. */
|
|
32
|
+
function attrs(o) {
|
|
33
|
+
return [`src="${o.src}"`, `data-site="${o.key}"`, o.domains ? `data-domains="${o.domains}"` : null, o.autocapture ? 'data-autocapture' : null].filter(Boolean).join(' ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const htmlLine = (o) => `<script defer ${attrs(o)}></script>`;
|
|
37
|
+
export const astroLine = (o) => `<script is:inline defer ${attrs(o)}></script>`;
|
|
38
|
+
export const jsxLine = (o) => `<script defer ${attrs(o)} />`;
|
|
39
|
+
export const nextLine = (o, name = 'Script') => `<${name} ${attrs(o)} />`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A file that already has Sumly: bring its key and data-autocapture up to date, touching nothing else.
|
|
43
|
+
* Returns null when the file has no Sumly line.
|
|
44
|
+
*/
|
|
45
|
+
export function refresh(content, o) {
|
|
46
|
+
const html = /data-site\s*=\s*\{?\s*(["'])(sl_[A-Za-z0-9_-]*)\1/;
|
|
47
|
+
const object = /(['"])data-site\1\s*:\s*(['"])(sl_[A-Za-z0-9_-]*)\2/;
|
|
48
|
+
const sdk = /siteKey\s*:\s*(['"])(sl_[A-Za-z0-9_-]*)\1/;
|
|
49
|
+
let c = content;
|
|
50
|
+
let m;
|
|
51
|
+
if ((m = html.exec(c))) {
|
|
52
|
+
if (m[2] !== o.key) c = c.slice(0, m.index) + m[0].replace(m[2], o.key) + c.slice(m.index + m[0].length);
|
|
53
|
+
const end = c.indexOf('>', m.index);
|
|
54
|
+
const tag = c.slice(c.lastIndexOf('<', m.index), end);
|
|
55
|
+
if (o.autocapture && !/data-autocapture/.test(tag)) {
|
|
56
|
+
const at = m.index + m[0].replace(m[2], o.key).length;
|
|
57
|
+
c = c.slice(0, at) + ' data-autocapture' + c.slice(at);
|
|
58
|
+
}
|
|
59
|
+
return c;
|
|
60
|
+
}
|
|
61
|
+
if ((m = object.exec(c))) {
|
|
62
|
+
if (m[3] !== o.key) c = c.slice(0, m.index) + m[0].replace(m[3], o.key) + c.slice(m.index + m[0].length);
|
|
63
|
+
const close = c.indexOf('}', m.index);
|
|
64
|
+
if (o.autocapture && !/data-autocapture/.test(c.slice(m.index, close))) {
|
|
65
|
+
const at = m.index + m[0].replace(m[3], o.key).length;
|
|
66
|
+
c = c.slice(0, at) + `, ${m[1]}data-autocapture${m[1]}: true` + c.slice(at);
|
|
67
|
+
}
|
|
68
|
+
return c;
|
|
69
|
+
}
|
|
70
|
+
if ((m = sdk.exec(c)) && /Sumly\.init\s*\(/.test(c)) {
|
|
71
|
+
if (m[2] !== o.key) c = c.slice(0, m.index) + m[0].replace(m[2], o.key) + c.slice(m.index + m[0].length);
|
|
72
|
+
if (o.autocapture && !/autocapture\s*:/.test(c)) {
|
|
73
|
+
c = c.replace(/Sumly\.init\(\s*\{([\s\S]*?)\s*\}\s*\)/, (all, inner) => `Sumly.init({${inner.replace(/,\s*$/, '')}, autocapture: true })`);
|
|
74
|
+
}
|
|
75
|
+
return c;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Put a line just inside a closing tag (</head>, </body>): on its own line, indented like its siblings.
|
|
82
|
+
* When the closing tag shares its line with other code, "inline" puts it right before the tag and
|
|
83
|
+
* "after" puts it on a new line after that whole line, at the same indent.
|
|
84
|
+
*/
|
|
85
|
+
export function beforeClosing(content, tag, line, { unit = ' ', crowded = 'inline' } = {}) {
|
|
86
|
+
const m = new RegExp(`</${tag}\\s*>`, 'i').exec(content);
|
|
87
|
+
if (!m) return null;
|
|
88
|
+
const start = content.lastIndexOf('\n', m.index - 1) + 1;
|
|
89
|
+
const lead = content.slice(start, m.index);
|
|
90
|
+
if (lead.trim() !== '') {
|
|
91
|
+
if (crowded === 'after') {
|
|
92
|
+
const end = content.indexOf('\n', m.index);
|
|
93
|
+
const at = end === -1 ? content.length : end;
|
|
94
|
+
return content.slice(0, at) + '\n' + indentOf(content.slice(start, at)) + line + content.slice(at);
|
|
95
|
+
}
|
|
96
|
+
return content.slice(0, m.index) + line + content.slice(m.index);
|
|
97
|
+
}
|
|
98
|
+
const before = content.slice(0, start).split('\n');
|
|
99
|
+
before.pop();
|
|
100
|
+
const previous = [...before].reverse().find((l) => l.trim() !== '') ?? '';
|
|
101
|
+
const opens = new RegExp(`<${tag}(\\s[^>]*)?>\\s*$`, 'i').test(previous);
|
|
102
|
+
const indent = opens ? lead + unit : indentOf(previous);
|
|
103
|
+
return content.slice(0, start) + indent + line + '\n' + content.slice(start);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Put a line on a new line after the line that matches, at its indent. */
|
|
107
|
+
export function afterLine(content, pattern, line) {
|
|
108
|
+
const m = pattern.exec(content);
|
|
109
|
+
if (!m) return null;
|
|
110
|
+
const start = content.lastIndexOf('\n', m.index - 1) + 1;
|
|
111
|
+
const end = content.indexOf('\n', m.index + m[0].length);
|
|
112
|
+
const at = end === -1 ? content.length : end;
|
|
113
|
+
return content.slice(0, at) + '\n' + indentOf(content.slice(start, at)) + line + content.slice(at);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Line index just after the file's imports (and its 'use client' style directives), and the imports' text. */
|
|
117
|
+
export function importsEnd(content) {
|
|
118
|
+
const lines = content.split('\n');
|
|
119
|
+
let last = -1;
|
|
120
|
+
let open = false;
|
|
121
|
+
for (let i = 0; i < lines.length; i++) {
|
|
122
|
+
const l = lines[i].trim();
|
|
123
|
+
if (open) {
|
|
124
|
+
if (/from\s+['"][^'"]+['"]\s*;?\s*$/.test(l) || /^['"][^'"]+['"]\s*;?\s*$/.test(l)) {
|
|
125
|
+
open = false;
|
|
126
|
+
last = i;
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (/^import\b/.test(l)) {
|
|
131
|
+
if (/from\s+['"][^'"]+['"]\s*;?\s*$/.test(l) || /^import\s+['"][^'"]+['"]\s*;?\s*$/.test(l) || /^import\s+\w+\s*=/.test(l)) last = i;
|
|
132
|
+
else open = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (l === '' || l.startsWith('//') || l.startsWith('/*') || l.startsWith('*') || /^['"]use [a-z ]+['"];?$/.test(l)) {
|
|
136
|
+
if (/^['"]use [a-z ]+['"];?$/.test(l) && last === -1) last = i;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
return { line: last + 1, text: lines.slice(0, last + 1).join('\n') };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Add lines after the last import, or at the top. */
|
|
145
|
+
export function addAfterImports(content, add) {
|
|
146
|
+
const lines = content.split('\n');
|
|
147
|
+
const { line } = importsEnd(content);
|
|
148
|
+
const blank = line === 0 && add[add.length - 1] !== '' && (lines[0] ?? '').trim() !== '' ? [''] : [];
|
|
149
|
+
lines.splice(line, 0, ...add, ...blank);
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const imported = (content, name) => new RegExp(`\\b${name}\\b`).test(importsEnd(content).text);
|
|
154
|
+
|
|
155
|
+
// ——— the frameworks ———
|
|
156
|
+
|
|
157
|
+
function head(content, o, line) {
|
|
158
|
+
const s = style(content);
|
|
159
|
+
const out = beforeClosing(content, 'head', line, { unit: s.unit });
|
|
160
|
+
if (out === null) throw new EditError('it has no </head>');
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function nextApp(content, o) {
|
|
165
|
+
const s = style(content);
|
|
166
|
+
let c = content;
|
|
167
|
+
let name = /import\s+(\w+)\s+from\s+['"]next\/script['"]/.exec(c)?.[1];
|
|
168
|
+
if (!name) {
|
|
169
|
+
name = imported(c, 'Script') ? 'SumlyScript' : 'Script';
|
|
170
|
+
c = addAfterImports(c, [`import ${name} from ${s.q}next/script${s.q}${s.semi ? ';' : ''}`]);
|
|
171
|
+
}
|
|
172
|
+
const out = beforeClosing(c, 'body', nextLine(o, name), { unit: s.unit, crowded: 'after' });
|
|
173
|
+
if (out === null) throw new EditError('the layout has no </body>');
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nextPages(content, o) {
|
|
178
|
+
const s = style(content);
|
|
179
|
+
let c = content;
|
|
180
|
+
let name = /import\s+(\w+)\s+from\s+['"]next\/script['"]/.exec(c)?.[1];
|
|
181
|
+
if (!name) {
|
|
182
|
+
name = imported(c, 'Script') ? 'SumlyScript' : 'Script';
|
|
183
|
+
c = addAfterImports(c, [`import ${name} from ${s.q}next/script${s.q}${s.semi ? ';' : ''}`]);
|
|
184
|
+
}
|
|
185
|
+
const tag = nextLine(o, name);
|
|
186
|
+
const m = /<Component\s+\{\s*\.\.\.pageProps\s*\}\s*\/>/.exec(c);
|
|
187
|
+
if (!m) throw new EditError('_app has no <Component {...pageProps} />');
|
|
188
|
+
const start = c.lastIndexOf('\n', m.index - 1) + 1;
|
|
189
|
+
const lead = c.slice(start, m.index);
|
|
190
|
+
const ind = indentOf(lead);
|
|
191
|
+
const u = s.unit;
|
|
192
|
+
const direct = /(return|=>)\s*\(?\s*$/.test(c.slice(0, m.index));
|
|
193
|
+
if (!direct) return afterLine(c, /<Component\s+\{\s*\.\.\.pageProps\s*\}\s*\/>/, tag);
|
|
194
|
+
if (lead.trim() === '' || /\(\s*$/.test(lead)) {
|
|
195
|
+
const inner = lead.trim() === '' ? ind : ind + u;
|
|
196
|
+
const rep = `<>\n${inner}${u}${m[0]}\n${inner}${u}${tag}\n${inner}</>`;
|
|
197
|
+
return c.slice(0, m.index) + (lead.trim() === '' ? '' : '\n' + inner) + rep + c.slice(m.index + m[0].length);
|
|
198
|
+
}
|
|
199
|
+
const rep = `(\n${ind}${u}<>\n${ind}${u}${u}${m[0]}\n${ind}${u}${u}${tag}\n${ind}${u}</>\n${ind})`;
|
|
200
|
+
return c.slice(0, m.index) + rep + c.slice(m.index + m[0].length);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function nextPagesNew(o, typescript) {
|
|
204
|
+
return typescript
|
|
205
|
+
? `import type { AppProps } from 'next/app';\nimport Script from 'next/script';\n\nexport default function App({ Component, pageProps }: AppProps) {\n return (\n <>\n <Component {...pageProps} />\n ${nextLine(o)}\n </>\n );\n}\n`
|
|
206
|
+
: `import Script from 'next/script';\n\nexport default function App({ Component, pageProps }) {\n return (\n <>\n <Component {...pageProps} />\n ${nextLine(o)}\n </>\n );\n}\n`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function reactRouter(content, o) {
|
|
210
|
+
const after = afterLine(content, /<Links\s*\/>/, jsxLine(o));
|
|
211
|
+
if (after !== null) return after;
|
|
212
|
+
const s = style(content);
|
|
213
|
+
const out = beforeClosing(content, 'head', jsxLine(o), { unit: s.unit });
|
|
214
|
+
if (out === null) throw new EditError('root has no <Links /> or </head>');
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Index of the brace that closes the one at open, skipping strings and comments. */
|
|
219
|
+
export function closing(text, open) {
|
|
220
|
+
let depth = 0;
|
|
221
|
+
for (let i = open; i < text.length; i++) {
|
|
222
|
+
const ch = text[i];
|
|
223
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
224
|
+
for (i++; i < text.length && text[i] !== ch; i++) if (text[i] === '\\') i++;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
228
|
+
i = text.indexOf('\n', i);
|
|
229
|
+
if (i === -1) return -1;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
233
|
+
i = text.indexOf('*/', i);
|
|
234
|
+
if (i === -1) return -1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (ch === '{' || ch === '[' || ch === '(') depth++;
|
|
238
|
+
if (ch === '}' || ch === ']' || ch === ')') {
|
|
239
|
+
depth--;
|
|
240
|
+
if (depth === 0) return i;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return -1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function nuxt(content, o) {
|
|
247
|
+
const s = style(content);
|
|
248
|
+
const q = s.q;
|
|
249
|
+
const u = s.unit;
|
|
250
|
+
const entry = `{ src: ${q}${o.src}${q}, defer: true, ${q}data-site${q}: ${q}${o.key}${q}${o.domains ? `, ${q}data-domains${q}: ${q}${o.domains}${q}` : ''}${o.autocapture ? `, ${q}data-autocapture${q}: true` : ''} }`;
|
|
251
|
+
const lineIndent = (at) => indentOf(content.slice(content.lastIndexOf('\n', at - 1) + 1, at));
|
|
252
|
+
const insertAfterBrace = (at, build) => {
|
|
253
|
+
const ind = lineIndent(at) + u;
|
|
254
|
+
return content.slice(0, at + 1) + '\n' + build(ind) + content.slice(at + 1);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const config = /defineNuxtConfig\(\s*\{/.exec(content) ?? /export\s+default\s*\{/.exec(content);
|
|
258
|
+
if (!config) throw new EditError('nuxt.config has no defineNuxtConfig({ … })');
|
|
259
|
+
const open = config.index + config[0].length - 1;
|
|
260
|
+
const end = closing(content, open);
|
|
261
|
+
const top = content.slice(open, end);
|
|
262
|
+
|
|
263
|
+
const app = /\bapp\s*:\s*\{/.exec(top);
|
|
264
|
+
if (!app) {
|
|
265
|
+
return insertAfterBrace(open, (ind) => `${ind}app: {\n${ind}${u}head: {\n${ind}${u}${u}script: [\n${ind}${u}${u}${u}${entry},\n${ind}${u}${u}],\n${ind}${u}},\n${ind}},`);
|
|
266
|
+
}
|
|
267
|
+
const appOpen = open + app.index + app[0].length - 1;
|
|
268
|
+
const appBody = content.slice(appOpen, closing(content, appOpen));
|
|
269
|
+
const hd = /\bhead\s*:\s*\{/.exec(appBody);
|
|
270
|
+
if (!hd) {
|
|
271
|
+
return insertAfterBrace(appOpen, (ind) => `${ind}head: {\n${ind}${u}script: [\n${ind}${u}${u}${entry},\n${ind}${u}],\n${ind}},`);
|
|
272
|
+
}
|
|
273
|
+
const headOpen = appOpen + hd.index + hd[0].length - 1;
|
|
274
|
+
const headBody = content.slice(headOpen, closing(content, headOpen));
|
|
275
|
+
const sc = /\bscript\s*:\s*\[/.exec(headBody);
|
|
276
|
+
if (!sc) {
|
|
277
|
+
return insertAfterBrace(headOpen, (ind) => `${ind}script: [\n${ind}${u}${entry},\n${ind}],`);
|
|
278
|
+
}
|
|
279
|
+
const listOpen = headOpen + sc.index + sc[0].length - 1;
|
|
280
|
+
return insertAfterBrace(listOpen, (ind) => `${ind}${entry},`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The app: import the SDK, init it once at the top, and, for taps or Expo Router's screens, a small
|
|
285
|
+
* component around the app that is now the default export.
|
|
286
|
+
*/
|
|
287
|
+
function reactNative(content, o, project) {
|
|
288
|
+
const s = style(content);
|
|
289
|
+
const q = s.q;
|
|
290
|
+
const semi = s.semi ? ';' : '';
|
|
291
|
+
const u = s.unit;
|
|
292
|
+
const expo = project.id === 'expo';
|
|
293
|
+
let c = content;
|
|
294
|
+
const notes = [];
|
|
295
|
+
|
|
296
|
+
// The wrapper: around the default export, which must take no props (an App or a root layout does not).
|
|
297
|
+
const wrap = o.autocapture || expo;
|
|
298
|
+
let inner = null;
|
|
299
|
+
if (wrap && !/\bSumlyRoot\b/.test(c)) {
|
|
300
|
+
let m;
|
|
301
|
+
if ((m = /^export\s+default\s+function\s*(\w*)\s*\(\s*\)/m.exec(c))) {
|
|
302
|
+
inner = m[1] || 'AppRoot';
|
|
303
|
+
c = c.slice(0, m.index) + `function ${inner}()` + c.slice(m.index + m[0].length);
|
|
304
|
+
} else if ((m = /^export\s+default\s+class\s+(\w+)/m.exec(c))) {
|
|
305
|
+
inner = m[1];
|
|
306
|
+
c = c.slice(0, m.index) + `class ${inner}` + c.slice(m.index + m[0].length);
|
|
307
|
+
} else if ((m = /^export\s+default\s+(?!function\b|class\b|async\b)([A-Za-z_$][\w$]*)[ \t]*;?[ \t]*(?:\n|$)/m.exec(c))) {
|
|
308
|
+
inner = m[1];
|
|
309
|
+
c = c.slice(0, m.index) + c.slice(m.index + m[0].length);
|
|
310
|
+
} else {
|
|
311
|
+
notes.push(o.autocapture
|
|
312
|
+
? 'Wrap your app once in <Sumly.Touches>…</Sumly.Touches> so taps count (its default export takes props, so the wizard left it as it is).'
|
|
313
|
+
: 'Count screens: in the root layout, const pathname = usePathname(); useEffect(() => { Sumly.screen(pathname); }, [pathname]);');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const add = [];
|
|
318
|
+
if (!new RegExp(`from\\s+['"]${SDK}['"]`).test(c)) add.push(`import { Sumly } from ${q}${SDK}${q}${semi}`);
|
|
319
|
+
let storage = new RegExp(`import\\s+(\\w+)\\s+from\\s+['"]${STORAGE}['"]`).exec(c)?.[1] ?? null;
|
|
320
|
+
if (o.storage && !storage) {
|
|
321
|
+
storage = 'AsyncStorage';
|
|
322
|
+
add.push(`import AsyncStorage from ${q}${STORAGE}${q}${semi}`);
|
|
323
|
+
}
|
|
324
|
+
if (inner && expo) {
|
|
325
|
+
if (!imported(c, 'usePathname')) add.push(`import { usePathname } from ${q}expo-router${q}${semi}`);
|
|
326
|
+
if (!imported(c, 'useEffect')) add.push(`import { useEffect } from ${q}react${q}${semi}`);
|
|
327
|
+
}
|
|
328
|
+
const options = [`siteKey: ${q}${o.key}${q}`, storage ? `storage: ${storage}` : null, o.autocapture ? 'autocapture: true' : null].filter(Boolean).join(', ');
|
|
329
|
+
c = addAfterImports(c, [...add, '', `Sumly.init({ ${options} })${semi}`]);
|
|
330
|
+
|
|
331
|
+
if (inner) {
|
|
332
|
+
const body = [];
|
|
333
|
+
if (expo) {
|
|
334
|
+
body.push(`${u}const pathname = usePathname()${semi}`);
|
|
335
|
+
body.push(`${u}useEffect(() => {`, `${u}${u}Sumly.screen(pathname)${semi}`, `${u}}, [pathname])${semi}`);
|
|
336
|
+
}
|
|
337
|
+
if (o.autocapture) {
|
|
338
|
+
body.push(`${u}return (`, `${u}${u}<Sumly.Touches>`, `${u}${u}${u}<${inner} />`, `${u}${u}</Sumly.Touches>`, `${u})${semi}`);
|
|
339
|
+
} else {
|
|
340
|
+
body.push(`${u}return <${inner} />${semi}`);
|
|
341
|
+
}
|
|
342
|
+
c = c.replace(/\s*$/, '') + `\n\nexport default function SumlyRoot() {\n${body.join('\n')}\n}\n`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!expo && !/onStateChange/.test(c) && /<NavigationContainer\b/.test(c)) {
|
|
346
|
+
c = c.replace(/<NavigationContainer\b/, '<NavigationContainer onStateChange={Sumly.onNavigationChange}');
|
|
347
|
+
} else if (!expo) {
|
|
348
|
+
notes.push("Count screens: <NavigationContainer onStateChange={Sumly.onNavigationChange}>, or Sumly.screen('Name') where a screen opens.");
|
|
349
|
+
}
|
|
350
|
+
return { content: c, notes };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The changes for a project, without writing anything.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} dir
|
|
357
|
+
* @param {ReturnType<import('./detect.mjs').detect>} project
|
|
358
|
+
* @param {{ key: string, src: string, domains?: string|null, autocapture?: boolean, storage?: boolean, files?: string[] }} o
|
|
359
|
+
* @returns {{ changes: {file: string, before: string|null, after: string}[], already: string[], problems: {file: string, reason: string}[], notes: string[] }}
|
|
360
|
+
*/
|
|
361
|
+
export function plan(dir, project, o) {
|
|
362
|
+
const changes = [];
|
|
363
|
+
const already = [];
|
|
364
|
+
const problems = [];
|
|
365
|
+
const notes = [];
|
|
366
|
+
|
|
367
|
+
if (project.create) {
|
|
368
|
+
changes.push({ file: project.create, before: null, after: nextPagesNew(o, project.typescript) });
|
|
369
|
+
return { changes, already, problems, notes };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const files = o.files?.length ? o.files : project.files;
|
|
373
|
+
for (const file of files) {
|
|
374
|
+
let raw;
|
|
375
|
+
try {
|
|
376
|
+
raw = readFileSync(join(dir, file), 'utf8');
|
|
377
|
+
} catch {
|
|
378
|
+
problems.push({ file, reason: 'there is no such file' });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const crlf = raw.includes('\r\n');
|
|
382
|
+
const before = crlf ? raw.replace(/\r\n/g, '\n') : raw;
|
|
383
|
+
let after;
|
|
384
|
+
try {
|
|
385
|
+
after = refresh(before, o);
|
|
386
|
+
if (after === null) {
|
|
387
|
+
switch (project.id) {
|
|
388
|
+
case 'next-app': after = nextApp(before, o); break;
|
|
389
|
+
case 'next-pages': after = nextPages(before, o); break;
|
|
390
|
+
case 'react-router':
|
|
391
|
+
case 'remix': after = reactRouter(before, o); break;
|
|
392
|
+
case 'nuxt': after = nuxt(before, o); break;
|
|
393
|
+
case 'astro': after = head(before, o, astroLine(o)); break;
|
|
394
|
+
case 'expo':
|
|
395
|
+
case 'react-native': {
|
|
396
|
+
const done = reactNative(before, o, project);
|
|
397
|
+
after = done.content;
|
|
398
|
+
notes.push(...done.notes);
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
default: after = head(before, o, htmlLine(o));
|
|
402
|
+
}
|
|
403
|
+
} else if (/Sumly\.init\s*\(/.test(after) && o.autocapture && !/Sumly\.Touches/.test(after)) {
|
|
404
|
+
notes.push('Wrap your app once in <Sumly.Touches>…</Sumly.Touches> so taps count.');
|
|
405
|
+
}
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (!(error instanceof EditError)) throw error;
|
|
408
|
+
problems.push({ file, reason: error.message });
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (after === before) {
|
|
412
|
+
already.push(file);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
changes.push({ file, before: raw, after: crlf ? after.replace(/\n/g, '\r\n') : after });
|
|
416
|
+
}
|
|
417
|
+
return { changes, already, problems, notes };
|
|
418
|
+
}
|