docugate 0.2.2 → 0.2.4
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/README.md +112 -14
- package/dist/cli.js +45 -22
- package/dist/init.js +66 -13
- package/dist/tour-init.js +9 -20
- package/dist/tour-server.js +50 -1
- package/package.json +1 -1
- package/pill/dist/pill.js +131 -31
package/README.md
CHANGED
|
@@ -8,28 +8,114 @@ npm i -g docugate
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
## Quick start
|
|
11
|
+
## Quick start, step by step
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Follow these in order. Every question during setup is a numbered choice: type
|
|
14
|
+
the number and press Enter, or just press Enter for the suggested one.
|
|
15
|
+
|
|
16
|
+
**1. Install DocuGate, once per computer.**
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npm i -g docugate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This gives you the `docugate` command in every folder. To pin a version for a
|
|
23
|
+
team instead, run `npm i -D docugate` inside the project and put `npx` in front
|
|
24
|
+
of each command below.
|
|
25
|
+
|
|
26
|
+
**2. Open your project's folder.**
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
cd my-app
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Use the root of the repository: the folder with `.git` in it. If your frontend
|
|
33
|
+
and backend are two repositories, do steps 2 to 4 in each one.
|
|
34
|
+
|
|
35
|
+
**3. Set everything up.**
|
|
14
36
|
|
|
15
37
|
```sh
|
|
16
38
|
docugate init
|
|
17
39
|
```
|
|
18
40
|
|
|
19
|
-
It
|
|
41
|
+
It asks, one at a time:
|
|
20
42
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
43
|
+
| Question | What to choose |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| What is in this repository? | **1** Frontend, **2** Backend, or **3** Both (a `frontend/` and a `backend/` folder in one repository). It suggests one from your folders. |
|
|
46
|
+
| Sign in to DocuGate? | **1** opens your browser to sign in with GitHub. You come back to your editor when it is done. **2** skips it; `docugate login` does it later. |
|
|
47
|
+
| Which DocuGate space should document this repository? | **1** creates a new space for it, or pick one of yours. A space already reading this repository is marked. |
|
|
48
|
+
| How often should DocuGate check your docs still match the code? | On every push, daily (suggested, fewer AI runs), weekly, or never. |
|
|
49
|
+
| Set up the tour: which AI should write it? | **1** IBM Bob reads your code and writes the tour. **2** IBM watsonx is coming soon. **3** skips it; you can write the tour in the browser. Frontend and both only. |
|
|
50
|
+
| Add the DocuGate pill to your app? | **1** adds a few lines to your `index.html` or Next.js layout that load the pill in development only. |
|
|
51
|
+
| Start the tour server now? | **1** starts it, so the pill shows right away. |
|
|
52
|
+
|
|
53
|
+
IBM Bob reads its API key from the `BOB_API_KEY` environment variable. If it is
|
|
54
|
+
not set, setup opens the page where you create a key and lets you paste it for
|
|
55
|
+
that one run; DocuGate never saves it. To set it for good (then open a new terminal):
|
|
56
|
+
|
|
57
|
+
```powershell
|
|
58
|
+
[Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User") # Windows
|
|
59
|
+
export BOB_API_KEY=<key> # macOS, Linux
|
|
60
|
+
```
|
|
25
61
|
|
|
26
|
-
|
|
62
|
+
Setup writes `docugate.json` and `.docugate/tour/`. Commit both, so the next
|
|
63
|
+
person who clones the repository needs no setup.
|
|
64
|
+
|
|
65
|
+
**4. Start the tour server while you work.**
|
|
27
66
|
|
|
28
67
|
```sh
|
|
29
68
|
docugate tour serve
|
|
30
69
|
```
|
|
31
70
|
|
|
32
|
-
|
|
71
|
+
Leave it running in its own terminal. The pill only appears while it runs,
|
|
72
|
+
which is why the pill never shows in production. If you started it at the end
|
|
73
|
+
of step 3, it is already running.
|
|
74
|
+
|
|
75
|
+
**5. Run your app as usual, in another terminal, and open it.**
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npm run dev
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The DocuGate pill sits in the bottom-right corner. Click it:
|
|
82
|
+
|
|
83
|
+
- **Walkthrough** lists the page's steps with your progress. Click a step to
|
|
84
|
+
go to it, the pencil to fix it, or **Add a step**.
|
|
85
|
+
- **Inspector** is one switch. With it on, click anything: you see what it is
|
|
86
|
+
and where its data comes from, and you can edit it. Click something with no
|
|
87
|
+
explanation yet, and the same click lets you add one.
|
|
88
|
+
|
|
89
|
+
Every change is saved straight into `.docugate/tour/`, so it shows up in `git diff`.
|
|
90
|
+
|
|
91
|
+
**6. Keep the tour true.**
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
docugate check
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
It checks every step against the code and warns about any that went stale,
|
|
98
|
+
for example after a rename. Put it in CI so a stale tour fails the build (see
|
|
99
|
+
[Keeping tours true](#keeping-tours-true)).
|
|
100
|
+
|
|
101
|
+
**Later, when you need them:**
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
docugate login # sign in again, or on a new computer
|
|
105
|
+
docugate whoami # who am I signed in as?
|
|
106
|
+
docugate tour init # let IBM Bob write tours for screens that have none
|
|
107
|
+
docugate tour install # add the pill again (--remove takes it out)
|
|
108
|
+
docugate logout # disconnect this computer
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Something not working?**
|
|
112
|
+
|
|
113
|
+
| You see | Do this |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| No pill in the app | Check `docugate tour serve` is running, then refresh the page. |
|
|
116
|
+
| Bob can't run | Choose IBM Bob again in `docugate init` to paste a key, or install Bob Shell: https://bob.ibm.com/docs/shell/getting-started/install-and-setup |
|
|
117
|
+
| Port 4178 is busy | `docugate tour serve --port 4179` and `docugate tour install --port 4179` |
|
|
118
|
+
| "Already set up in ..." | You ran it in a subfolder. Run commands from the folder it names. |
|
|
33
119
|
|
|
34
120
|
---
|
|
35
121
|
|
|
@@ -73,14 +159,24 @@ Tour files are yours. `docugate tour init` never overwrites a file that already
|
|
|
73
159
|
|
|
74
160
|
## The pill
|
|
75
161
|
|
|
76
|
-
When `docugate tour serve` is running, the pill
|
|
162
|
+
When `docugate tour serve` is running, the pill sits in a corner of your app,
|
|
163
|
+
like the Next.js dev indicator. Drag it to any corner. Click it for two things:
|
|
77
164
|
|
|
78
|
-
- **Walkthrough.**
|
|
79
|
-
|
|
165
|
+
- **Walkthrough.** The page's steps as a checklist with progress. Start from
|
|
166
|
+
any step: the page dims, a ring marks the element, and a card says what it
|
|
167
|
+
is and where its data comes from. Each step has a pencil to fix it, and
|
|
168
|
+
**Add a step** adds one.
|
|
169
|
+
- **Inspector.** One switch. With it on, clicking an element shows its step,
|
|
170
|
+
with **Edit** to correct what the AI or anyone else got wrong, and **Remove**.
|
|
171
|
+
Clicking an element with no step lets you add it, and tells you which
|
|
172
|
+
`data-tour` attribute to put on it. Code and source files open in your
|
|
173
|
+
editor at the element's line.
|
|
80
174
|
|
|
81
|
-
The pill
|
|
175
|
+
The pill shows on every page while the server runs, including pages with no
|
|
176
|
+
tour yet, so their first step can be added from the browser.
|
|
82
177
|
|
|
83
|
-
`docugate tour install` adds the pill for you. It only loads when the app runs
|
|
178
|
+
`docugate tour install` adds the pill for you. It only loads when the app runs
|
|
179
|
+
on `localhost` and the tour server answers, so it never shows in production.
|
|
84
180
|
|
|
85
181
|
---
|
|
86
182
|
|
|
@@ -164,6 +260,8 @@ docugate tour serve
|
|
|
164
260
|
|
|
165
261
|
Open the app in the browser. The pill appears in the bottom-right corner.
|
|
166
262
|
|
|
263
|
+
Or try it without installing anything: https://docugate-demo.vercel.app/invoices/inv_1004. The hosted demo reads a copy of the tour, so editing is turned off there.
|
|
264
|
+
|
|
167
265
|
---
|
|
168
266
|
|
|
169
267
|
## Several repositories in one space
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
3
4
|
import { check } from './check.js';
|
|
4
5
|
import { loadConfig } from './config.js';
|
|
5
|
-
import { initFlow } from './init.js';
|
|
6
|
+
import { choose, initFlow, makeDefaultIO } from './init.js';
|
|
6
7
|
import { buildOpenApi, isCurrent, writeOpenApi } from './openapi.js';
|
|
7
8
|
import { BobKeyMissingError, BobMissingError, tourInit } from './tour-init.js';
|
|
8
9
|
import { tourInstall, hasPillSnippet, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
|
|
@@ -197,13 +198,27 @@ async function main() {
|
|
|
197
198
|
if (roleFlag && !['frontend', 'backend', 'both'].includes(roleFlag)) {
|
|
198
199
|
fail(`--role must be frontend, backend, or both.`);
|
|
199
200
|
}
|
|
200
|
-
|
|
201
|
+
const port = one('port') !== undefined ? parseInt(one('port'), 10) : DEFAULT_PORT;
|
|
202
|
+
const result = await initFlow(root, {
|
|
201
203
|
dir: one('dir'),
|
|
202
204
|
title: one('title'),
|
|
203
205
|
role: roleFlag,
|
|
204
206
|
yes: args.bools.has('yes'),
|
|
205
|
-
port
|
|
207
|
+
port,
|
|
206
208
|
});
|
|
209
|
+
// The pill only shows while the tour server runs, so setup ends by
|
|
210
|
+
// offering to start it rather than leaving one more command to remember.
|
|
211
|
+
const where = result.alreadySetUp ?? root;
|
|
212
|
+
const hasTour = result.role === 'frontend' || result.role === 'both';
|
|
213
|
+
if (hasTour && process.stdin.isTTY && !args.bools.has('yes')) {
|
|
214
|
+
const io = makeDefaultIO();
|
|
215
|
+
const pick = await choose(io, 'Start the tour server now, so the pill shows in your app?', [
|
|
216
|
+
'Yes, start it (leave this terminal open, then run your app as usual)',
|
|
217
|
+
'Later: docugate tour serve',
|
|
218
|
+
]);
|
|
219
|
+
if (pick === 1)
|
|
220
|
+
await serveTour(where, port);
|
|
221
|
+
}
|
|
207
222
|
return;
|
|
208
223
|
}
|
|
209
224
|
case 'check': {
|
|
@@ -329,24 +344,7 @@ ${dim(`Bob: ${parts.join(', ')}`)}`);
|
|
|
329
344
|
return;
|
|
330
345
|
}
|
|
331
346
|
case 'serve': {
|
|
332
|
-
|
|
333
|
-
if (!existsSync(tourDir)) {
|
|
334
|
-
console.error(`${red('error')} .docugate/tour/ not found. Run ${green('docugate tour init')} first.`);
|
|
335
|
-
process.exit(2);
|
|
336
|
-
}
|
|
337
|
-
const port = parseInt(one('port') ?? '4178', 10);
|
|
338
|
-
const server = await startTourServer(root, port);
|
|
339
|
-
const url = `http://localhost:${server.port}`;
|
|
340
|
-
const screens = loadTours(root);
|
|
341
|
-
console.log(`${green('Tour server')} ${url}`);
|
|
342
|
-
console.log(`${plural(screens.length, 'screen')} loaded from .docugate/tour/`);
|
|
343
|
-
if (!hasPillSnippet(root)) {
|
|
344
|
-
console.log(`${dim('Hint: run')} ${green('docugate tour install')} ${dim('to add the pill to your app automatically.')}`);
|
|
345
|
-
}
|
|
346
|
-
// Keep the process alive; Ctrl-C will stop it.
|
|
347
|
-
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
348
|
-
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
349
|
-
await new Promise(() => { });
|
|
347
|
+
await serveTour(root, parseInt(one('port') ?? '4178', 10));
|
|
350
348
|
return;
|
|
351
349
|
}
|
|
352
350
|
case 'export': {
|
|
@@ -371,3 +369,28 @@ main().catch((error) => {
|
|
|
371
369
|
console.error(`${red('error')} ${error.message}`);
|
|
372
370
|
process.exit(1);
|
|
373
371
|
});
|
|
372
|
+
/**
|
|
373
|
+
* Serves the tour to the pill until Ctrl-C. Starts even when there is no tour
|
|
374
|
+
* yet: the pill then shows on every page, and its inspector writes the first
|
|
375
|
+
* step, so a failed or skipped AI step never leaves the app without it.
|
|
376
|
+
*/
|
|
377
|
+
async function serveTour(root, port) {
|
|
378
|
+
mkdirSync(join(root, '.docugate', 'tour'), { recursive: true });
|
|
379
|
+
const server = await startTourServer(root, port);
|
|
380
|
+
const url = `http://localhost:${server.port}`;
|
|
381
|
+
const screens = loadTours(root);
|
|
382
|
+
console.log(`${green('Tour server')} ${url}`);
|
|
383
|
+
if (screens.length) {
|
|
384
|
+
console.log(`${plural(screens.length, 'screen')} loaded from .docugate/tour/`);
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
console.log(`No tour yet. Open your app: the pill shows on every page, and its Inspector adds the first step.`);
|
|
388
|
+
}
|
|
389
|
+
if (!hasPillSnippet(root)) {
|
|
390
|
+
console.log(`${dim('Hint: run')} ${green('docugate tour install')} ${dim('to add the pill to your app automatically.')}`);
|
|
391
|
+
}
|
|
392
|
+
console.log(dim('Leave this running while you work. Ctrl-C stops it.'));
|
|
393
|
+
process.on('SIGINT', () => { server.close(); process.exit(0); });
|
|
394
|
+
process.on('SIGTERM', () => { server.close(); process.exit(0); });
|
|
395
|
+
await new Promise(() => { });
|
|
396
|
+
}
|
package/dist/init.js
CHANGED
|
@@ -3,7 +3,7 @@ import { execFileSync, spawn } from 'node:child_process';
|
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { CONFIG_FILE, loadConfig } from './config.js';
|
|
6
|
-
import { tourInit, BobMissingError, BobKeyMissingError, readBobKey,
|
|
6
|
+
import { tourInit, BobMissingError, BobKeyMissingError, readBobKey, useBobKeyForThisRun } from './tour-init.js';
|
|
7
7
|
import { tourInstall, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
|
|
8
8
|
import { login, loadCredentials } from './auth.js';
|
|
9
9
|
/**
|
|
@@ -39,7 +39,7 @@ export function init(root, opts = {}) {
|
|
|
39
39
|
}
|
|
40
40
|
return { created, kept, docsDir };
|
|
41
41
|
}
|
|
42
|
-
function makeDefaultIO() {
|
|
42
|
+
export function makeDefaultIO() {
|
|
43
43
|
const isTTY = Boolean(process.stdin.isTTY);
|
|
44
44
|
return {
|
|
45
45
|
isTTY,
|
|
@@ -91,13 +91,13 @@ function saveSetting(root, key, value) {
|
|
|
91
91
|
}
|
|
92
92
|
const BOB_KEYS_URL = 'https://bob.ibm.com/docs/ide/account/api-keys';
|
|
93
93
|
/**
|
|
94
|
-
* Bob Shell runs on its own with an API key, not the IDE sign-in
|
|
95
|
-
*
|
|
96
|
-
*
|
|
94
|
+
* Bob Shell runs on its own with an API key, not the IDE sign-in, and reads it
|
|
95
|
+
* from BOB_API_KEY. When that is not set, setup can take a pasted key for this
|
|
96
|
+
* one run only: DocuGate never stores it, and says how to set it for good.
|
|
97
97
|
*/
|
|
98
98
|
async function askForBobKey(io, openUrl) {
|
|
99
|
-
const pick = await choose(io, 'IBM Bob needs an API key
|
|
100
|
-
'
|
|
99
|
+
const pick = await choose(io, 'IBM Bob needs an API key (BOB_API_KEY is not set in your environment).', [
|
|
100
|
+
'Paste a key for this run only (opens the page that creates one)',
|
|
101
101
|
'Skip the tour for now',
|
|
102
102
|
]);
|
|
103
103
|
if (pick !== 1)
|
|
@@ -105,13 +105,16 @@ async function askForBobKey(io, openUrl) {
|
|
|
105
105
|
io.print(` In your Bob instance: API keys, then create an Inference key.`);
|
|
106
106
|
io.print(` ${BOB_KEYS_URL}`);
|
|
107
107
|
(openUrl ?? openInBrowser)(BOB_KEYS_URL);
|
|
108
|
-
const key = (await io.ask(' Paste the key and press Enter (
|
|
108
|
+
const key = (await io.ask(' Paste the key and press Enter (used for this run only, never saved)', '')).trim();
|
|
109
109
|
if (!key) {
|
|
110
110
|
io.print(' No key pasted. Skipped the tour: run docugate tour init when you have one.');
|
|
111
111
|
return false;
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
io.print('
|
|
113
|
+
useBobKeyForThisRun(key);
|
|
114
|
+
io.print(' Using the key for this run only. To keep it for next time, set it in your environment:');
|
|
115
|
+
io.print(process.platform === 'win32'
|
|
116
|
+
? ' [Environment]::SetEnvironmentVariable("BOB_API_KEY", "<key>", "User") (then open a new terminal)'
|
|
117
|
+
: ' export BOB_API_KEY=<key> (add it to your shell profile)');
|
|
115
118
|
return true;
|
|
116
119
|
}
|
|
117
120
|
function openInBrowser(url) {
|
|
@@ -182,6 +185,53 @@ export async function choose(io, question, options, fallback = 1) {
|
|
|
182
185
|
io.print(` Type a number from 1 to ${options.length}.`);
|
|
183
186
|
}
|
|
184
187
|
}
|
|
188
|
+
function run(cmd, args, cwd) {
|
|
189
|
+
try {
|
|
190
|
+
const out = execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: process.platform === 'win32' });
|
|
191
|
+
return { ok: true, out: out.trim() };
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const e = err;
|
|
195
|
+
return { ok: false, out: (e.stderr ?? e.message).trim() };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A space reads from a GitHub repository. When the project has none yet, offer
|
|
200
|
+
* to create one with the GitHub CLI, which is already signed in as the person,
|
|
201
|
+
* and push the code to it. Returns owner/name, or null when skipped or failed.
|
|
202
|
+
* DocuGate's own GitHub sign-in is not given the right to create repositories.
|
|
203
|
+
*/
|
|
204
|
+
async function createGitHubRepo(root, io) {
|
|
205
|
+
const pick = await choose(io, 'This project is not on GitHub yet, and a space reads from a GitHub repository.', [
|
|
206
|
+
'Create a GitHub repository for it now (uses the GitHub CLI)',
|
|
207
|
+
'Skip for now',
|
|
208
|
+
]);
|
|
209
|
+
if (pick !== 1)
|
|
210
|
+
return null;
|
|
211
|
+
if (!run('gh', ['auth', 'status'], root).ok) {
|
|
212
|
+
io.print(' The GitHub CLI is not installed or not signed in.');
|
|
213
|
+
io.print(' Install it from https://cli.github.com, run gh auth login, then docugate init again.');
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (!run('git', ['rev-parse', 'HEAD'], root).ok) {
|
|
217
|
+
io.print(' Commit your code first (git add -A, then git commit), then run docugate init again.');
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
const visibility = await choose(io, 'Who can see the repository?', [
|
|
221
|
+
'Public',
|
|
222
|
+
'Private (a space from a private repository needs DocuGate Pro)',
|
|
223
|
+
]);
|
|
224
|
+
const name = basename(root).replace(/[^A-Za-z0-9._-]+/g, '-');
|
|
225
|
+
const made = run('gh', ['repo', 'create', name, visibility === 1 ? '--public' : '--private', '--source', '.', '--remote', 'origin', '--push'], root);
|
|
226
|
+
if (!made.ok) {
|
|
227
|
+
io.print(` Could not create the repository: ${made.out.split(/\r?\n/).pop()}`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
const slug = defaultGetRemote(root);
|
|
231
|
+
if (slug)
|
|
232
|
+
io.print(` Created https://github.com/${slug} and pushed your code.`);
|
|
233
|
+
return slug;
|
|
234
|
+
}
|
|
185
235
|
function defaultGetRemote(root) {
|
|
186
236
|
try {
|
|
187
237
|
const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
|
|
@@ -318,10 +368,13 @@ export async function initFlow(root, options = {}) {
|
|
|
318
368
|
}
|
|
319
369
|
// ── 3b: connect this repository to a space ────────────────────────────────
|
|
320
370
|
if (signedIn) {
|
|
321
|
-
// Read git remote.
|
|
322
|
-
|
|
371
|
+
// Read git remote; a project not on GitHub yet can be put there first.
|
|
372
|
+
let repoSlug = getRemoteFn(root);
|
|
373
|
+
if (!repoSlug && !yes) {
|
|
374
|
+
repoSlug = await (options.createRepoFn ?? createGitHubRepo)(root, io);
|
|
375
|
+
}
|
|
323
376
|
if (!repoSlug) {
|
|
324
|
-
io.print(`
|
|
377
|
+
io.print(` A space reads from a GitHub repository, so this one can't be connected yet.`);
|
|
325
378
|
}
|
|
326
379
|
else {
|
|
327
380
|
// List spaces.
|
package/dist/tour-init.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import {
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { join } from 'node:path';
|
|
6
5
|
import { isMap, isSeq, parseDocument } from 'yaml';
|
|
7
6
|
// `docugate tour init` writes the first tour of an app by asking IBM Bob
|
|
8
7
|
// (through Bob Shell) to read the code. Bob does the reading; this file makes
|
|
@@ -76,30 +75,20 @@ export class BobMissingError extends Error {
|
|
|
76
75
|
/** Bob Shell's headless mode signs in with an API key, not the IDE's IBMid session. */
|
|
77
76
|
export class BobKeyMissingError extends Error {
|
|
78
77
|
}
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
/**
|
|
79
|
+
* The Bob API key comes from the environment only: BOB_API_KEY. DocuGate never
|
|
80
|
+
* writes it anywhere. A key pasted during setup is put in this process's
|
|
81
|
+
* environment for that one run, and is gone when the command ends.
|
|
82
|
+
*/
|
|
84
83
|
export function readBobKey() {
|
|
85
|
-
|
|
86
|
-
return process.env.BOB_API_KEY;
|
|
87
|
-
try {
|
|
88
|
-
return JSON.parse(readFileSync(bobKeyPath(), 'utf8')).key || undefined;
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
84
|
+
return process.env.BOB_API_KEY || undefined;
|
|
93
85
|
}
|
|
94
|
-
export function
|
|
95
|
-
|
|
96
|
-
writeFileSync(bobKeyPath(), JSON.stringify({ key }) + '\n', { mode: 0o600 });
|
|
86
|
+
export function useBobKeyForThisRun(key) {
|
|
87
|
+
process.env.BOB_API_KEY = key;
|
|
97
88
|
}
|
|
98
89
|
const defaultRunBob = (args, cwd) => {
|
|
99
|
-
const key = readBobKey();
|
|
100
90
|
const result = spawnSync('bob', args, {
|
|
101
91
|
cwd,
|
|
102
|
-
env: key ? { ...process.env, BOB_API_KEY: key } : process.env,
|
|
103
92
|
encoding: 'utf8',
|
|
104
93
|
// `bob` is a .cmd shim on Windows, which only runs through a shell.
|
|
105
94
|
shell: process.platform === 'win32',
|
package/dist/tour-server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, watch, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join, relative, resolve, isAbsolute } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { loadTours, matchRoute, parseTourFile, routeToFilename, serializeTourFile } from './tour.js';
|
|
@@ -174,6 +174,52 @@ async function handlePutStop(req, res, root) {
|
|
|
174
174
|
writeFileSync(filepath, serializeTourFile(tour));
|
|
175
175
|
sendJson(res, 200, { ok: true });
|
|
176
176
|
}
|
|
177
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.docugate', '.bob', 'coverage', '.vercel']);
|
|
178
|
+
const CODE_FILE = /\.(tsx|jsx|ts|js|vue|svelte|html|astro)$/;
|
|
179
|
+
/**
|
|
180
|
+
* The repository file that renders an element with this data-tour value, so
|
|
181
|
+
* the pill fills in "the file that shows it" instead of asking. A file with
|
|
182
|
+
* data-tour="value" wins over one that only passes the value along as a prop.
|
|
183
|
+
*/
|
|
184
|
+
export function findTourFile(root, value) {
|
|
185
|
+
const exact = new RegExp(`data-tour=["']${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`);
|
|
186
|
+
const quoted = new RegExp(`["']${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`);
|
|
187
|
+
let fallback;
|
|
188
|
+
let seen = 0;
|
|
189
|
+
const walk = (dir) => {
|
|
190
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
191
|
+
if (++seen > 20000)
|
|
192
|
+
return undefined;
|
|
193
|
+
const abs = join(dir, entry.name);
|
|
194
|
+
if (entry.isDirectory()) {
|
|
195
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
|
|
196
|
+
continue;
|
|
197
|
+
const found = walk(abs);
|
|
198
|
+
if (found)
|
|
199
|
+
return found;
|
|
200
|
+
}
|
|
201
|
+
else if (CODE_FILE.test(entry.name)) {
|
|
202
|
+
const text = readFileSync(abs, 'utf8');
|
|
203
|
+
const rel = relative(root, abs).split('\\').join('/');
|
|
204
|
+
if (exact.test(text))
|
|
205
|
+
return rel;
|
|
206
|
+
if (!fallback && quoted.test(text))
|
|
207
|
+
fallback = rel;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
};
|
|
212
|
+
return walk(root) ?? fallback;
|
|
213
|
+
}
|
|
214
|
+
function handleFind(req, res, root) {
|
|
215
|
+
const value = new URL(req.url ?? '/', 'http://localhost').searchParams.get('value') ?? '';
|
|
216
|
+
const path = value ? findTourFile(root, value) : undefined;
|
|
217
|
+
if (!path) {
|
|
218
|
+
sendJson(res, 404, { error: 'not found' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
sendJson(res, 200, { path });
|
|
222
|
+
}
|
|
177
223
|
/** DELETE /stop { route, target }: removes one stop, leaving the rest of the file as it was. */
|
|
178
224
|
async function handleDeleteStop(req, res, root) {
|
|
179
225
|
let body;
|
|
@@ -309,6 +355,9 @@ export function startTourServer(root, port) {
|
|
|
309
355
|
sendJson(res, 500, { error: err.message });
|
|
310
356
|
});
|
|
311
357
|
}
|
|
358
|
+
else if (req.method === 'GET' && path === '/find') {
|
|
359
|
+
handleFind(req, res, root);
|
|
360
|
+
}
|
|
312
361
|
else if (req.method === 'POST' && path === '/open') {
|
|
313
362
|
handleOpen(req, res, root).catch((err) => {
|
|
314
363
|
sendJson(res, 500, { error: err.message });
|
package/package.json
CHANGED
package/pill/dist/pill.js
CHANGED
|
@@ -50,6 +50,30 @@ function suggestSelector(el) {
|
|
|
50
50
|
const tag = el.tagName.toLowerCase();
|
|
51
51
|
return { selector: `[data-tour="${tag}"]`, needsAttribute: true };
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* A CSS selector that finds exactly this element today, for one with no
|
|
55
|
+
* data-tour attribute yet: its id if it has one, else the tag and position
|
|
56
|
+
* from the nearest ancestor with an id, or from the body.
|
|
57
|
+
*/
|
|
58
|
+
function uniqueSelector(el) {
|
|
59
|
+
if (el.id)
|
|
60
|
+
return `#${CSS.escape(el.id)}`;
|
|
61
|
+
const parts = [];
|
|
62
|
+
let node = el;
|
|
63
|
+
while (node && node !== document.body && node !== document.documentElement) {
|
|
64
|
+
if (node.id) {
|
|
65
|
+
parts.unshift(`#${CSS.escape(node.id)}`);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
const tag = node.tagName.toLowerCase();
|
|
69
|
+
const siblings = node.parentElement ? Array.from(node.parentElement.children).filter((c) => c.tagName === node.tagName) : [];
|
|
70
|
+
parts.unshift(siblings.length > 1 ? `${tag}:nth-of-type(${siblings.indexOf(node) + 1})` : tag);
|
|
71
|
+
node = node.parentElement;
|
|
72
|
+
}
|
|
73
|
+
if (!parts[0]?.startsWith('#'))
|
|
74
|
+
parts.unshift('body');
|
|
75
|
+
return parts.join(' > ');
|
|
76
|
+
}
|
|
53
77
|
// ---------------------------------------------------------------------------
|
|
54
78
|
// Server URL: read from this script's own src attribute
|
|
55
79
|
// ---------------------------------------------------------------------------
|
|
@@ -382,10 +406,12 @@ kbd {
|
|
|
382
406
|
.step-edit svg { width: 15px; height: 15px; }
|
|
383
407
|
.step-row:hover .step-edit, .step-edit:focus-visible { opacity: 1; }
|
|
384
408
|
.step-edit:hover { color: #F4C43F; background: rgba(255, 255, 255, 0.07); }
|
|
385
|
-
.step-
|
|
409
|
+
.step-row .step:disabled + .step-edit { opacity: 1; color: #F4C43F; }
|
|
386
410
|
.add-step { color: #a1a1a1; }
|
|
387
411
|
.add-step .num { border-style: dashed; font-size: 14px; font-weight: 400; }
|
|
388
412
|
.add-step:hover { background: rgba(255, 255, 255, 0.06); color: #ededed; }
|
|
413
|
+
.wt-start { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; height: 34px; margin: 4px 0 2px; }
|
|
414
|
+
.wt-start svg { width: 13px; height: 13px; }
|
|
389
415
|
.wt-empty { margin: 4px 8px 6px; font-size: 12.5px; line-height: 1.5; color: #a1a1a1; }
|
|
390
416
|
.item-text { display: flex; flex-direction: column; gap: 1px; }
|
|
391
417
|
.menu-list { border-top: 1px solid rgba(255, 255, 255, 0.07); }
|
|
@@ -527,7 +553,6 @@ function renderProse(el, prose) {
|
|
|
527
553
|
}
|
|
528
554
|
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
|
|
529
555
|
const CORNER_KEY = 'docugate-pill-corner';
|
|
530
|
-
const HIDDEN_KEY = 'docugate-pill-hidden';
|
|
531
556
|
const EDGE = 16;
|
|
532
557
|
/** The corner nearest to a point, for snapping the badge where it is dropped. */
|
|
533
558
|
function nearestCorner(x, y, width, height) {
|
|
@@ -574,6 +599,11 @@ class DocugatePill {
|
|
|
574
599
|
this.inspEl = null;
|
|
575
600
|
this.inspectedPath = location.pathname;
|
|
576
601
|
this.listCollapsed = false;
|
|
602
|
+
/** Hidden for this page view only: a reload, or Alt+Shift+D, brings it back. */
|
|
603
|
+
this.hidden = false;
|
|
604
|
+
this.retarget = null;
|
|
605
|
+
/** When a re-attached step is saved, the target it had before. */
|
|
606
|
+
this.editingFrom = null;
|
|
577
607
|
this.highlightEl = null;
|
|
578
608
|
this.evtSource = null;
|
|
579
609
|
this.isStaticMode = false;
|
|
@@ -592,6 +622,12 @@ class DocugatePill {
|
|
|
592
622
|
document.body.appendChild(this.host);
|
|
593
623
|
this.init();
|
|
594
624
|
this.patchHistory();
|
|
625
|
+
document.addEventListener('keydown', (e) => {
|
|
626
|
+
if (e.altKey && e.shiftKey && e.code === 'KeyD') {
|
|
627
|
+
e.preventDefault();
|
|
628
|
+
this.setHidden(!this.hidden);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
595
631
|
if (!this.isStaticMode) {
|
|
596
632
|
this.listenEvents();
|
|
597
633
|
}
|
|
@@ -610,15 +646,8 @@ class DocugatePill {
|
|
|
610
646
|
}
|
|
611
647
|
render() {
|
|
612
648
|
this.removePill();
|
|
613
|
-
if (!this.alive)
|
|
649
|
+
if (!this.alive || this.hidden)
|
|
614
650
|
return;
|
|
615
|
-
try {
|
|
616
|
-
if (sessionStorage.getItem(HIDDEN_KEY))
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
catch {
|
|
620
|
-
// storage blocked: show the pill
|
|
621
|
-
}
|
|
622
651
|
const stops = this.tour?.stops.length ?? 0;
|
|
623
652
|
const btn = document.createElement('button');
|
|
624
653
|
btn.className = `pill-btn surface${this.inspectorActive ? ' inspecting' : ''}`;
|
|
@@ -688,6 +717,20 @@ class DocugatePill {
|
|
|
688
717
|
this.toggleMenu();
|
|
689
718
|
});
|
|
690
719
|
}
|
|
720
|
+
setHidden(hidden) {
|
|
721
|
+
this.hidden = hidden;
|
|
722
|
+
if (hidden) {
|
|
723
|
+
if (this.inspectorActive)
|
|
724
|
+
this.stopInspector();
|
|
725
|
+
if (this.walkthroughActive)
|
|
726
|
+
this.stopWalkthrough();
|
|
727
|
+
this.removePill();
|
|
728
|
+
this.toast('Pill hidden. Reload the page, or press Alt+Shift+D, to bring it back.');
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
this.render();
|
|
732
|
+
}
|
|
733
|
+
}
|
|
691
734
|
removePill() {
|
|
692
735
|
this.pillBtn?.remove();
|
|
693
736
|
this.pillBtn = null;
|
|
@@ -761,6 +804,26 @@ class DocugatePill {
|
|
|
761
804
|
section.appendChild(head);
|
|
762
805
|
if (this.listCollapsed)
|
|
763
806
|
return section;
|
|
807
|
+
const playable = stops.filter((st) => document.querySelector(st.target) !== null);
|
|
808
|
+
if (playable.length) {
|
|
809
|
+
const allSeen = playable.every((st) => seen.has(st.target));
|
|
810
|
+
const resume = playable.find((st) => !seen.has(st.target));
|
|
811
|
+
const start = document.createElement('button');
|
|
812
|
+
start.className = 'wt-btn primary wt-start';
|
|
813
|
+
start.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5 3.5v9l7-4.5z" fill="currentColor"/></svg><span></span>`;
|
|
814
|
+
start.querySelector('span').textContent = allSeen
|
|
815
|
+
? 'Watch again'
|
|
816
|
+
: done && resume
|
|
817
|
+
? `Continue from step ${stops.indexOf(resume) + 1}`
|
|
818
|
+
: 'Start walkthrough';
|
|
819
|
+
start.addEventListener('click', () => {
|
|
820
|
+
this.closeMenu();
|
|
821
|
+
if (allSeen)
|
|
822
|
+
this.resetVisited();
|
|
823
|
+
this.startWalkthrough(allSeen ? undefined : resume);
|
|
824
|
+
});
|
|
825
|
+
section.appendChild(start);
|
|
826
|
+
}
|
|
764
827
|
const list = document.createElement('div');
|
|
765
828
|
list.className = 'steps';
|
|
766
829
|
stops.forEach((stop, i) => {
|
|
@@ -785,12 +848,20 @@ class DocugatePill {
|
|
|
785
848
|
edit.className = 'step-edit';
|
|
786
849
|
edit.setAttribute('aria-label', `Edit step ${i + 1}, ${stop.heading}`);
|
|
787
850
|
edit.innerHTML = `<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 3.5l2 2L6 12H4v-2z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>`;
|
|
788
|
-
|
|
851
|
+
if (!onPage)
|
|
852
|
+
edit.setAttribute('aria-label', `Point step ${i + 1}, ${stop.heading}, at an element`);
|
|
789
853
|
edit.addEventListener('click', () => {
|
|
790
854
|
const el = document.querySelector(stop.target);
|
|
791
855
|
this.closeMenu();
|
|
792
|
-
if (el)
|
|
856
|
+
if (el) {
|
|
793
857
|
this.showInspPopup(el, stop, true);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// The step points at nothing on this page: the next click picks its element.
|
|
861
|
+
this.retarget = stop;
|
|
862
|
+
if (!this.inspectorActive)
|
|
863
|
+
this.toggleInspector();
|
|
864
|
+
this.toast(`Click the element "${stop.heading}" should point to.`);
|
|
794
865
|
});
|
|
795
866
|
row.appendChild(edit);
|
|
796
867
|
}
|
|
@@ -848,18 +919,8 @@ class DocugatePill {
|
|
|
848
919
|
const hide = document.createElement('button');
|
|
849
920
|
hide.className = 'link-btn';
|
|
850
921
|
hide.textContent = 'Hide';
|
|
851
|
-
hide.title = 'Hide
|
|
852
|
-
hide.addEventListener('click', () =>
|
|
853
|
-
try {
|
|
854
|
-
sessionStorage.setItem(HIDDEN_KEY, '1');
|
|
855
|
-
}
|
|
856
|
-
catch {
|
|
857
|
-
// hidden for now only
|
|
858
|
-
}
|
|
859
|
-
if (this.inspectorActive)
|
|
860
|
-
this.stopInspector();
|
|
861
|
-
this.removePill();
|
|
862
|
-
});
|
|
922
|
+
hide.title = 'Hide the pill. Reload the page or press Alt+Shift+D to bring it back.';
|
|
923
|
+
hide.addEventListener('click', () => this.setHidden(true));
|
|
863
924
|
foot.append(status, hide);
|
|
864
925
|
return foot;
|
|
865
926
|
}
|
|
@@ -872,6 +933,14 @@ class DocugatePill {
|
|
|
872
933
|
return new Set();
|
|
873
934
|
}
|
|
874
935
|
}
|
|
936
|
+
resetVisited() {
|
|
937
|
+
try {
|
|
938
|
+
sessionStorage.removeItem(`docugate-seen:${this.tour?.route ?? location.pathname}`);
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
// nothing remembered, nothing to reset
|
|
942
|
+
}
|
|
943
|
+
}
|
|
875
944
|
markVisited(target) {
|
|
876
945
|
const seen = this.visited();
|
|
877
946
|
seen.add(target);
|
|
@@ -1105,6 +1174,15 @@ class DocugatePill {
|
|
|
1105
1174
|
return;
|
|
1106
1175
|
e.preventDefault();
|
|
1107
1176
|
e.stopPropagation();
|
|
1177
|
+
if (this.retarget) {
|
|
1178
|
+
// Re-attach a step whose element was missing: same step, this element.
|
|
1179
|
+
const old = this.retarget;
|
|
1180
|
+
this.retarget = null;
|
|
1181
|
+
const { selector, needsAttribute } = suggestSelector(el);
|
|
1182
|
+
this.showInspPopup(el, { ...old, target: needsAttribute ? uniqueSelector(el) : selector }, true);
|
|
1183
|
+
this.editingFrom = old.target;
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1108
1186
|
const stop = this.findStopForElement(el);
|
|
1109
1187
|
if (stop) {
|
|
1110
1188
|
this.showInspPopup(el, stop);
|
|
@@ -1135,7 +1213,9 @@ class DocugatePill {
|
|
|
1135
1213
|
* With no stop, it opens as the form for a new one.
|
|
1136
1214
|
*/
|
|
1137
1215
|
showInspPopup(el, stop, editing = false) {
|
|
1216
|
+
const from = this.editingFrom;
|
|
1138
1217
|
this.closeInspPopup();
|
|
1218
|
+
this.editingFrom = from;
|
|
1139
1219
|
const panel = document.createElement('div');
|
|
1140
1220
|
panel.className = 'insp-popup surface';
|
|
1141
1221
|
panel.setAttribute('role', 'dialog');
|
|
@@ -1247,10 +1327,10 @@ class DocugatePill {
|
|
|
1247
1327
|
{ key: 'heading', label: 'Name', value: existing?.heading ?? '', placeholder: 'e.g. Total' },
|
|
1248
1328
|
{ key: 'prose', label: 'What it is', value: existing?.prose ?? '', multi: true, placeholder: 'e.g. The amount due: the subtotal plus tax, computed by `totals()`.' },
|
|
1249
1329
|
{ key: 'data', label: 'Data', value: existing?.data ?? '', placeholder: 'e.g. GET /api/invoices/:id → total' },
|
|
1250
|
-
{ key: 'code', label: '
|
|
1330
|
+
{ key: 'code', label: 'File that shows it', value: existing?.code ?? '', placeholder: 'A path in your repository, e.g. src/pages/Home.tsx' },
|
|
1251
1331
|
{ key: 'source', label: 'Source file', value: existing?.source ?? '', placeholder: 'Optional: where the value is computed' },
|
|
1252
1332
|
{ key: 'docs', label: 'Docs link', value: existing?.docs ?? '', placeholder: 'Optional' },
|
|
1253
|
-
{ key: 'target', label: 'Selector', value: existing?.target ?? selector },
|
|
1333
|
+
{ key: 'target', label: 'Selector', value: existing?.target ?? (needsAttribute ? uniqueSelector(el) : selector) },
|
|
1254
1334
|
];
|
|
1255
1335
|
const inputs = {};
|
|
1256
1336
|
const form = document.createElement('form');
|
|
@@ -1273,12 +1353,25 @@ class DocugatePill {
|
|
|
1273
1353
|
if (needsAttribute && !existing) {
|
|
1274
1354
|
const warn = document.createElement('div');
|
|
1275
1355
|
warn.className = 'add-form-warn';
|
|
1276
|
-
|
|
1356
|
+
const slug = tourValue(selector) ?? '';
|
|
1277
1357
|
const code = document.createElement('code');
|
|
1278
|
-
code.textContent = `data-tour="${
|
|
1279
|
-
|
|
1358
|
+
code.textContent = `data-tour="${slug}"`;
|
|
1359
|
+
const sel = document.createElement('code');
|
|
1360
|
+
sel.textContent = selector;
|
|
1361
|
+
warn.append('This works now. To keep it working after a redesign, add ', code, ' to the element in your code, then set Selector to ', sel, '.');
|
|
1280
1362
|
form.appendChild(warn);
|
|
1281
1363
|
}
|
|
1364
|
+
// Fill in the file for them: the tour server knows which file has this data-tour.
|
|
1365
|
+
const value = tourValue(existing?.target ?? selector);
|
|
1366
|
+
if (!this.isStaticMode && !inputs.code.value && value && !needsAttribute) {
|
|
1367
|
+
fetch(`${this.base}/find?value=${encodeURIComponent(value)}`)
|
|
1368
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
1369
|
+
.then((found) => {
|
|
1370
|
+
if (found?.path && !inputs.code.value)
|
|
1371
|
+
inputs.code.value = found.path;
|
|
1372
|
+
})
|
|
1373
|
+
.catch(() => undefined);
|
|
1374
|
+
}
|
|
1282
1375
|
const error = document.createElement('p');
|
|
1283
1376
|
error.className = 'panel-error';
|
|
1284
1377
|
error.hidden = true;
|
|
@@ -1335,14 +1428,20 @@ class DocugatePill {
|
|
|
1335
1428
|
docs: value('docs') || undefined,
|
|
1336
1429
|
prose: value('prose'),
|
|
1337
1430
|
};
|
|
1338
|
-
const missing = !stop.heading ? 'a name' : !stop.code ? 'the
|
|
1431
|
+
const missing = !stop.heading ? 'a name' : !stop.code ? 'the file that shows it' : !stop.target ? 'a selector' : '';
|
|
1339
1432
|
if (missing) {
|
|
1340
1433
|
error.textContent = `Add ${missing} first.`;
|
|
1341
1434
|
error.hidden = false;
|
|
1342
1435
|
return;
|
|
1343
1436
|
}
|
|
1437
|
+
if (/^[a-z]+:\/\/|^localhost/i.test(stop.code)) {
|
|
1438
|
+
error.textContent = 'The file is a path in your repository, like src/pages/Home.tsx, not the page address.';
|
|
1439
|
+
error.hidden = false;
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1344
1442
|
save.disabled = true;
|
|
1345
|
-
const saved = await this.saveStop(stop, existing?.target);
|
|
1443
|
+
const saved = await this.saveStop(stop, this.editingFrom ?? existing?.target);
|
|
1444
|
+
this.editingFrom = null;
|
|
1346
1445
|
save.disabled = false;
|
|
1347
1446
|
if (!saved) {
|
|
1348
1447
|
error.textContent = 'Could not save. Is docugate tour serve still running?';
|
|
@@ -1372,6 +1471,7 @@ class DocugatePill {
|
|
|
1372
1471
|
this.inspPopup?.remove();
|
|
1373
1472
|
this.inspPopup = null;
|
|
1374
1473
|
this.inspEl = null;
|
|
1474
|
+
this.editingFrom = null;
|
|
1375
1475
|
}
|
|
1376
1476
|
showAddForm(el, existing) {
|
|
1377
1477
|
this.showInspPopup(el, existing ?? null, true);
|