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 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
- `cd` into your app's repository, then run:
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 walks you through four steps:
41
+ It asks, one at a time:
20
42
 
21
- 1. **Repository role.** Say whether this repository is the frontend, the backend, or both.
22
- 2. **Sign in and connect.** Sign in to DocuGate with GitHub and connect this repository to a space.
23
- 3. **IBM Bob writes the tour.** Bob reads the code and writes one `.docugate/tour/*.md` file for each screen it finds.
24
- 4. **The pill is added to your app.** The dev-only loader snippet is inserted into your HTML or layout file.
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
- Once that is done:
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
- Start your app in another terminal, open it in the browser, and the pill appears in the corner.
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 appears in the corner of your app. It has two modes:
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.** Steps through every stop on the current screen in order, highlighting each element and showing its tour card.
79
- - **Inspector.** Hovering highlights an element; clicking it shows its stop: what it is and where its data comes from. An element with no stop yet offers **Add to tour**, a small form that saves the stop straight into the tour file, and tells you which `data-tour` attribute to add when the element has none.
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 stays on every page while the server runs, including pages with no tour yet, so their first stop can be added from the browser. Edits to the tour files show up on the page as you save them.
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 on `localhost` and the tour server answers, so it never shows in production.
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
- await initFlow(root, {
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: one('port') !== undefined ? parseInt(one('port'), 10) : undefined,
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
- const tourDir = `${root}/.docugate/tour`;
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, saveBobKey } from './tour-init.js';
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. Setup gets
95
- * one without anyone editing environment variables: open the page that makes
96
- * it, paste it, and it is kept in ~/.docugate/bob.json, outside the repository.
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 to write the tour. Get one now?', [
100
- 'Yes, open the page to create an Inference key, then paste it here',
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 (it stays on this computer)', '')).trim();
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
- saveBobKey(key);
114
- io.print(' Key saved in ~/.docugate/bob.json. It is never written into the repository.');
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
- const repoSlug = getRemoteFn(root);
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(` This repository has no GitHub remote yet, so it can't be connected to a space.`);
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 { homedir } from 'node:os';
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
- /** Where `docugate init` keeps the Bob API key: the home folder, never the repository. */
80
- export function bobKeyPath() {
81
- return join(homedir(), '.docugate', 'bob.json');
82
- }
83
- /** The Bob API key: BOB_API_KEY if set, else the one saved during setup. */
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
- if (process.env.BOB_API_KEY)
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 saveBobKey(key) {
95
- mkdirSync(dirname(bobKeyPath()), { recursive: true });
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',
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Command-line tools for DocuGate: set up, check and prepare the documentation DocuGate reads straight from your repository.",
5
5
  "type": "module",
6
6
  "bin": {
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-edit:disabled { display: none; }
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
- edit.disabled = !onPage;
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 until this tab is reloaded';
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: 'Code file', value: existing?.code ?? '', placeholder: 'e.g. src/screens/Invoice.tsx' },
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
- warn.append('Add ');
1356
+ const slug = tourValue(selector) ?? '';
1277
1357
  const code = document.createElement('code');
1278
- code.textContent = `data-tour="${tourValue(selector) ?? ''}"`;
1279
- warn.append(code, ' to this element in your code, so the stop keeps finding it.');
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 code file' : !stop.target ? 'a selector' : '';
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);