docugate 0.2.5 → 0.2.6

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
@@ -44,14 +44,15 @@ It asks, one at a time:
44
44
  | --- | --- |
45
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
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. |
47
+ | Where should the documentation live? | **1** creates a new space, with a new GitHub repository on your account to hold its markdown (`<project>-docs`, with a first page). Or pick one of your spaces. The pill then links to the published docs. |
48
48
  | How often should DocuGate check your docs still match the code? | On every push, daily (suggested, fewer AI runs), weekly, or never. |
49
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
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
51
  | Start the tour server now? | **1** starts it, so the pill shows right away. |
52
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
53
+ IBM Bob reads its API key from the `BOB_API_KEY` environment variable. If you
54
+ saved it under another name (say `IBM_BOB_KEY`), setup finds it and asks before
55
+ using it. If there is none, setup opens the page where you create a key and lets you paste it for
55
56
  that one run; DocuGate never saves it. To set it for good (then open a new terminal):
56
57
 
57
58
  ```powershell
package/dist/api.js CHANGED
@@ -7,6 +7,8 @@ const ERROR_MESSAGES = {
7
7
  pro_required: 'Private repositories need DocuGate Pro',
8
8
  no_repo_access: "DocuGate can't see this repository. Install the DocuGate GitHub App on it first",
9
9
  slug_taken: 'You already have a space with this name',
10
+ repo_exists: 'You already have a repository with this name on GitHub',
11
+ app_permission: "DocuGate isn't allowed to create repositories on your account yet. Accept the DocuGate app's updated permissions at https://github.com/settings/installations, then try again",
10
12
  github_required: 'Sign in with GitHub to create a space',
11
13
  };
12
14
  function friendlyError(code) {
@@ -45,7 +47,7 @@ export async function apiFetch(path, init = {}) {
45
47
  // ignore JSON parse failure
46
48
  }
47
49
  if (code)
48
- throw new Error(friendlyError(code));
50
+ throw Object.assign(new Error(friendlyError(code)), { code });
49
51
  throw new Error(`DocuGate request failed with status ${res.status}`);
50
52
  }
51
53
  return res;
@@ -65,6 +67,17 @@ export async function createSpace(repo, name, docsDir) {
65
67
  });
66
68
  return (await res.json()).space;
67
69
  }
70
+ /**
71
+ * Creates a repository on the person's GitHub account, as them. With `docs`,
72
+ * it is a documentation repository that starts with docs/index.md.
73
+ */
74
+ export async function createRepo(name, isPrivate, docs) {
75
+ const res = await apiFetch('/api/repos', {
76
+ method: 'POST',
77
+ body: JSON.stringify({ name, private: isPrivate, docs: Boolean(docs), title: docs?.title }),
78
+ });
79
+ return (await res.json());
80
+ }
68
81
  export async function connectRepo(spaceId, repo, docsDir) {
69
82
  await apiFetch(`/api/spaces/${spaceId}/sources`, {
70
83
  method: 'POST',
package/dist/init.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
4
4
  import { basename, dirname, join } from 'node:path';
5
5
  import { CONFIG_FILE, loadConfig } from './config.js';
6
6
  import { tourInit, BobMissingError, BobKeyMissingError, readBobKey, useBobKeyForThisRun } from './tour-init.js';
7
+ import { bobKeyCandidates, startSpinner } from './spinner.js';
7
8
  import { tourInstall, pillSnippetLine, DEFAULT_PORT } from './tour-install.js';
8
9
  import { login, loadCredentials } from './auth.js';
9
10
  /**
@@ -57,6 +58,67 @@ export function makeDefaultIO() {
57
58
  },
58
59
  };
59
60
  }
61
+ /**
62
+ * A new space and the repository that holds its docs: the repository is
63
+ * created on the person's account with docs/index.md in it, then the space is
64
+ * made from it. Returns the space, or undefined when something stopped it
65
+ * (every reason is printed).
66
+ */
67
+ async function createDocsSpace(io, projectName, baseUrl, options) {
68
+ const visibility = await choose(io, 'Who can read the docs repository on GitHub?', [
69
+ 'Public',
70
+ 'Private (a space from a private repository needs DocuGate Pro)',
71
+ ]);
72
+ const repoName = `${projectName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-|-$/g, '')}-docs`;
73
+ const stop = startSpinner(['Creating the docs repository on GitHub', 'Writing its first page', 'Almost there']);
74
+ let repo;
75
+ try {
76
+ repo = await (options.createDocsRepoFn ?? (async (name, priv, title) => {
77
+ const { createRepo } = await import('./api.js');
78
+ return createRepo(name, priv, { title });
79
+ }))(repoName, visibility === 2, projectName);
80
+ stop();
81
+ io.print(` Created ${repo.url}`);
82
+ }
83
+ catch (err) {
84
+ stop();
85
+ io.print(` warning ${err.message}`);
86
+ return undefined;
87
+ }
88
+ const createFn = options.createSpaceFn ?? (async (r, name, dir) => {
89
+ const { createSpace } = await import('./api.js');
90
+ return createSpace(r, name, dir);
91
+ });
92
+ // A space only ever reads a repository DocuGate can see. One installed on
93
+ // selected repositories may not include the new one yet: offer the install
94
+ // and try again once.
95
+ for (let attempt = 0; attempt < 2; attempt++) {
96
+ const stopSpace = startSpinner(['Setting up your space', 'Reading the docs repository']);
97
+ try {
98
+ const space = await createFn(repo.repo, projectName, 'docs');
99
+ stopSpace();
100
+ io.print(` Created space "${space.name}"`);
101
+ if (repo.seeded === false)
102
+ io.print(` Add markdown files to docs/ in ${repo.repo} and they become pages.`);
103
+ return space;
104
+ }
105
+ catch (err) {
106
+ stopSpace();
107
+ io.print(` warning ${err.message}`);
108
+ if (err.code !== 'no_repo_access' || attempt > 0)
109
+ return undefined;
110
+ const fix = await choose(io, `The DocuGate GitHub App can't see ${repo.repo} yet.`, [
111
+ 'Add it to the app (opens GitHub), then try again',
112
+ 'Skip for now',
113
+ ]);
114
+ if (fix !== 1)
115
+ return undefined;
116
+ (options.openUrl ?? openInBrowser)(`${baseUrl}/api/auth/github?install=1`);
117
+ await io.ask(` On GitHub, add ${repo.repo} (or choose All repositories) and save. Then press Enter here`, '');
118
+ }
119
+ }
120
+ return undefined;
121
+ }
60
122
  /**
61
123
  * The nearest folder above `root`, inside the same Git repository, that has
62
124
  * DocuGate set up already (a docugate.json), if any.
@@ -185,53 +247,6 @@ export async function choose(io, question, options, fallback = 1) {
185
247
  io.print(` Type a number from 1 to ${options.length}.`);
186
248
  }
187
249
  }
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
- }
235
250
  function defaultGetRemote(root) {
236
251
  try {
237
252
  const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
@@ -366,93 +381,76 @@ export async function initFlow(root, options = {}) {
366
381
  }
367
382
  }
368
383
  }
369
- // ── 3b: connect this repository to a space ────────────────────────────────
384
+ // ── 3b: the space that holds this project's documentation ─────────────────
385
+ // A space reads markdown from a GitHub repository. A new space gets its own
386
+ // documentation repository, created on the person's account; an existing
387
+ // space can be picked instead. Either way docugate.json remembers it, so the
388
+ // pill can link to the published docs.
370
389
  if (signedIn) {
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);
390
+ const repoSlug = getRemoteFn(root);
391
+ let spaces = [];
392
+ try {
393
+ spaces = (await getSpacesFn()).spaces;
375
394
  }
376
- if (!repoSlug) {
377
- io.print(` A space reads from a GitHub repository, so this one can't be connected yet.`);
395
+ catch (err) {
396
+ connectError = `Couldn't load spaces: ${err.message}`;
397
+ io.print(` warning ${connectError}`);
378
398
  }
379
- else {
380
- // List spaces.
381
- let spaces = [];
399
+ const { docsDir: repoDocsDir, config } = loadConfig(root);
400
+ const projectName = config.title ?? repoSlug?.split('/')[1] ?? basename(root);
401
+ const hasRepo = (sp) => Boolean(repoSlug) && (sp.sources ?? []).some((src) => src.repo.toLowerCase() === repoSlug.toLowerCase());
402
+ const already = spaces.find(hasRepo);
403
+ const linkSpace = (sp) => {
404
+ saveSetting(root, 'space', `${sp.owner}/${sp.slug}`);
405
+ io.print(` Docs will be published at ${BASE_URL}/${sp.owner}/${sp.slug}`);
406
+ };
407
+ const connectProject = async (sp) => {
408
+ if (!repoSlug || hasRepo(sp))
409
+ return;
382
410
  try {
383
- const res = await getSpacesFn();
384
- spaces = res.spaces;
411
+ await connectFn(sp.id, repoSlug, repoDocsDir);
412
+ io.print(` Connected ${repoSlug} to space "${sp.name}".`);
385
413
  }
386
414
  catch (err) {
387
- connectError = `Couldn't load spaces: ${err.message}`;
415
+ connectError = err.message;
388
416
  io.print(` warning ${connectError}`);
389
417
  }
390
- const { docsDir: repoDocsDir } = loadConfig(root);
391
- const hasRepo = (sp) => (sp.sources ?? []).some((src) => src.repo.toLowerCase() === repoSlug.toLowerCase());
392
- const already = spaces.find(hasRepo);
393
- if (connectError) {
394
- // the warning is printed already; carry on with the rest of setup
418
+ };
419
+ if (connectError) {
420
+ // the warning is printed already; carry on with the rest of setup
421
+ }
422
+ else if (yes) {
423
+ // --yes: use the space only when there is no doubt which one is meant.
424
+ const only = already ?? (spaces.length === 1 ? spaces[0] : undefined);
425
+ if (only) {
426
+ await connectProject(only);
427
+ linkSpace(only);
428
+ }
429
+ else if (spaces.length > 1) {
430
+ io.print(` Several spaces found: run docugate init again without --yes to choose.`);
395
431
  }
396
- else if (yes) {
397
- // --yes: connect only when there is no doubt which space is meant.
398
- if (already) {
399
- io.print(` ${repoSlug} is already in space "${already.name}".`);
400
- }
401
- else if (spaces.length === 1) {
402
- try {
403
- await connectFn(spaces[0].id, repoSlug, repoDocsDir);
404
- io.print(` Connected ${repoSlug} to space "${spaces[0].name}".`);
405
- }
406
- catch (err) {
407
- connectError = err.message;
408
- io.print(` warning ${connectError}`);
409
- }
410
- }
411
- else if (spaces.length > 1) {
412
- io.print(` Several spaces found: run docugate init again without --yes to choose.`);
413
- }
432
+ }
433
+ else {
434
+ const choices = [
435
+ 'Create a new space, with a new repository for its docs',
436
+ ...spaces.map((sp) => `${sp.name} (${sp.owner}/${sp.slug})${hasRepo(sp) ? ' already has this project' : ''}`),
437
+ 'Skip for now',
438
+ ];
439
+ const pick = await choose(io, `Where should the documentation for ${projectName} live?`, choices, already ? spaces.indexOf(already) + 2 : 1);
440
+ if (pick === 1) {
441
+ const created = await createDocsSpace(io, projectName, BASE_URL, options);
442
+ if (created)
443
+ linkSpace(created);
444
+ else
445
+ connectError = 'The space was not created.';
446
+ }
447
+ else if (pick <= spaces.length + 1) {
448
+ const sp = spaces[pick - 2];
449
+ await connectProject(sp);
450
+ linkSpace(sp);
414
451
  }
415
452
  else {
416
- // Interactive: create a space, or pick one, or skip. Numbers only.
417
- const choices = [
418
- `Create a new space for ${repoSlug}`,
419
- ...spaces.map((sp) => `${sp.name} (${sp.owner}/${sp.slug})${hasRepo(sp) ? ' already reads this repository' : ''}`),
420
- 'Skip for now',
421
- ];
422
- const pick = await choose(io, `Which DocuGate space should document ${repoSlug}?`, choices, already ? spaces.indexOf(already) + 2 : 1);
423
- if (pick === 1) {
424
- try {
425
- const createFn = options.createSpaceFn ?? (async (repo, name, dir) => {
426
- const { createSpace } = await import('./api.js');
427
- return createSpace(repo, name, dir);
428
- });
429
- const created = await createFn(repoSlug, loadConfig(root).config.title ?? repoSlug.split('/')[1], repoDocsDir);
430
- io.print(` Created space "${created.name}": ${BASE_URL}/${created.owner}/${created.slug}`);
431
- }
432
- catch (err) {
433
- connectError = err.message;
434
- io.print(` warning ${connectError}`);
435
- }
436
- }
437
- else if (pick <= spaces.length + 1) {
438
- const sp = spaces[pick - 2];
439
- if (hasRepo(sp)) {
440
- io.print(` ${repoSlug} is already in space "${sp.name}". Nothing to change.`);
441
- }
442
- else {
443
- try {
444
- await connectFn(sp.id, repoSlug, repoDocsDir);
445
- io.print(` Connected ${repoSlug} to space "${sp.name}".`);
446
- }
447
- catch (err) {
448
- connectError = err.message;
449
- io.print(` warning ${connectError}`);
450
- }
451
- }
452
- }
453
- else {
454
- io.print(` Skipped. docugate init connects it any time.`);
455
- }
453
+ io.print(` Skipped. docugate init sets it up any time.`);
456
454
  }
457
455
  }
458
456
  }
@@ -481,18 +479,46 @@ export async function initFlow(root, options = {}) {
481
479
  if (pick === 2)
482
480
  io.print(' IBM watsonx is coming soon. Skipped for now: run docugate tour init when you are ready.');
483
481
  runTour = pick === 1;
484
- if (runTour && !readBobKey() && !options.runBob)
485
- runTour = await askForBobKey(io, options.openUrl);
482
+ if (runTour && !readBobKey() && !options.runBob) {
483
+ // The key may be in the environment under another name.
484
+ const [other] = bobKeyCandidates();
485
+ if (other) {
486
+ const use = await choose(io, `BOB_API_KEY is not set, but ${other} is. Is that your IBM Bob key?`, [
487
+ `Yes, use ${other} for this run`,
488
+ 'No',
489
+ ]);
490
+ if (use === 1) {
491
+ useBobKeyForThisRun(process.env[other]);
492
+ io.print(` Tip: rename it to BOB_API_KEY in your environment so Bob finds it next time.`);
493
+ }
494
+ }
495
+ if (!readBobKey())
496
+ runTour = await askForBobKey(io, options.openUrl);
497
+ }
486
498
  }
487
499
  if (runTour) {
500
+ // Bob takes a minute or two. Show it working, the way an assistant does.
501
+ const stop = options.runBob
502
+ ? () => undefined
503
+ : startSpinner([
504
+ 'IBM Bob is reading your screens and routes',
505
+ 'Following each value to the endpoint it comes from',
506
+ 'Tracing it into the backend',
507
+ 'Writing the tour, one step per element',
508
+ 'Checking every file it names exists',
509
+ 'Still working: bigger apps take a little longer',
510
+ ]);
488
511
  try {
489
512
  tourResult = tourInit(root, { runBob: options.runBob });
513
+ stop();
514
+ io.print(` IBM Bob wrote the tour.`);
490
515
  }
491
516
  catch (error) {
517
+ stop();
492
518
  // Bob missing or failed — describe what went wrong, then continue.
493
519
  if (error instanceof BobKeyMissingError) {
494
520
  tourError =
495
- `Bob Shell needs an API key. Set BOB_API_KEY, then run docugate tour init.`;
521
+ `IBM Bob did not accept the key. Check BOB_API_KEY holds an Inference key from bob.ibm.com, then run docugate tour init.`;
496
522
  }
497
523
  else if (error instanceof BobMissingError) {
498
524
  tourError =
package/dist/rules.js CHANGED
@@ -58,4 +58,4 @@ export function firstHeading(content) {
58
58
  const heading = content.match(/^#\s+(.+)$/m);
59
59
  return heading ? heading[1].trim() : null;
60
60
  }
61
- export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'sidebar', 'api']);
61
+ export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'role', 'freshness', 'space', 'sidebar', 'api']);
@@ -0,0 +1,49 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * A live "working on it" line for the waits in setup: a spinner, the elapsed
4
+ * time, and a status that changes every few seconds, so a minute of IBM Bob
5
+ * reading the code never looks like a frozen terminal.
6
+ *
7
+ * It runs in a small child process, so it keeps moving even while the main
8
+ * process is blocked waiting on Bob Shell. Only in a terminal; elsewhere it
9
+ * prints the first status once.
10
+ */
11
+ export function startSpinner(statuses) {
12
+ if (!process.stderr.isTTY || process.env.CI) {
13
+ process.stderr.write(` ${statuses[0]}...\n`);
14
+ return (finalLine) => {
15
+ if (finalLine)
16
+ process.stderr.write(`${finalLine}\n`);
17
+ };
18
+ }
19
+ const script = `
20
+ const statuses = JSON.parse(process.argv[1]);
21
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
22
+ const start = Date.now();
23
+ let i = 0;
24
+ const cols = () => process.stderr.columns || 80;
25
+ setInterval(() => {
26
+ const secs = Math.floor((Date.now() - start) / 1000);
27
+ const status = statuses[Math.min(Math.floor(secs / 6), statuses.length - 1)];
28
+ const line = ' ' + frames[i++ % frames.length] + ' ' + status + '... ' + secs + 's';
29
+ process.stderr.write('\\r' + line.slice(0, cols() - 1).padEnd(cols() - 1));
30
+ }, 90);
31
+ `;
32
+ const child = spawn(process.execPath, ['-e', script, JSON.stringify(statuses)], {
33
+ stdio: ['ignore', 'ignore', 'inherit'],
34
+ });
35
+ return (finalLine) => {
36
+ child.kill();
37
+ const width = process.stderr.columns || 80;
38
+ process.stderr.write(`\r${' '.repeat(width - 1)}\r`);
39
+ if (finalLine)
40
+ process.stderr.write(`${finalLine}\n`);
41
+ };
42
+ }
43
+ /** Environment variables that look like an IBM Bob key under another name. */
44
+ export function bobKeyCandidates(env = process.env) {
45
+ return Object.keys(env)
46
+ .filter((name) => name !== 'BOB_API_KEY' && env[name])
47
+ .filter((name) => /BOB|IBM|WATSONX/i.test(name) && /KEY|TOKEN|SECRET/i.test(name))
48
+ .sort();
49
+ }
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, watch,
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';
7
+ import { loadConfig } from './config.js';
7
8
  // ---------------------------------------------------------------------------
8
9
  // Security helpers
9
10
  // ---------------------------------------------------------------------------
@@ -91,11 +92,19 @@ function handleGetTour(req, res, root) {
91
92
  const pathname = url.searchParams.get('path') ?? '/';
92
93
  const tours = loadTours(root);
93
94
  const match = tours.find((t) => matchRoute(t.route, pathname));
95
+ const docsUrl = publishedDocsUrl(root);
94
96
  if (!match) {
95
- sendJson(res, 404, { error: 'not found' });
97
+ sendJson(res, 404, { error: 'not found', docsUrl });
96
98
  return;
97
99
  }
98
- sendJson(res, 200, { route: match.route, title: match.title, file: match.file, stops: match.stops });
100
+ sendJson(res, 200, { route: match.route, title: match.title, file: match.file, docsUrl, stops: match.stops });
101
+ }
102
+ /** Where this project's docs are published, from the space docugate init saved. */
103
+ export function publishedDocsUrl(root) {
104
+ const space = loadConfig(root).config.space;
105
+ if (!space || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(space))
106
+ return undefined;
107
+ return `${process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site'}/${space}`;
99
108
  }
100
109
  function handleGetEvents(req, res, root) {
101
110
  const tourDir = join(root, '.docugate', 'tour');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docugate",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
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
@@ -104,11 +104,14 @@ async function fetchTour(base) {
104
104
  }
105
105
  try {
106
106
  const res = await fetch(`${base}/tour?path=${encodeURIComponent(pathname)}`, { signal: AbortSignal.timeout(3000) });
107
- if (res.status === 404)
108
- return { alive: true, tour: null };
107
+ if (res.status === 404) {
108
+ const body = (await res.json().catch(() => ({})));
109
+ return { alive: true, tour: null, docsUrl: body.docsUrl };
110
+ }
109
111
  if (!res.ok)
110
112
  return { alive: false, tour: null };
111
- return { alive: true, tour: (await res.json()) };
113
+ const tour = (await res.json());
114
+ return { alive: true, tour, docsUrl: tour.docsUrl };
112
115
  }
113
116
  catch {
114
117
  return { alive: false, tour: null };
@@ -247,6 +250,8 @@ kbd {
247
250
  font-size: 12px;
248
251
  color: #8f8f8f;
249
252
  }
253
+ .docs-link { margin-left: auto; color: #F4C43F; text-decoration: none; }
254
+ .docs-link:hover { color: #ffd35c; background: rgba(244, 196, 63, 0.1); }
250
255
  .menu-foot .live { display: inline-flex; align-items: center; gap: 6px; }
251
256
  .menu-foot .live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #45d483; box-shadow: 0 0 0 3px rgba(69, 212, 131, 0.15); }
252
257
  .link-btn { border: 0; background: none; color: #a1a1a1; font: inherit; cursor: pointer; padding: 2px 4px; border-radius: 5px; }
@@ -601,6 +606,8 @@ class DocugatePill {
601
606
  this.listCollapsed = false;
602
607
  /** Hidden for this page view only: a reload, or Alt+Shift+D, brings it back. */
603
608
  this.hidden = false;
609
+ /** The project's published docs on DocuGate, when init linked a space. */
610
+ this.docsUrl = null;
604
611
  this.retarget = null;
605
612
  /** When a re-attached step is saved, the target it had before. */
606
613
  this.editingFrom = null;
@@ -640,9 +647,10 @@ class DocugatePill {
640
647
  this.render();
641
648
  }
642
649
  async load() {
643
- const { alive, tour } = await fetchTour(this.base);
650
+ const { alive, tour, docsUrl } = await fetchTour(this.base);
644
651
  this.alive = alive;
645
652
  this.tour = tour;
653
+ this.docsUrl = docsUrl ?? null;
646
654
  }
647
655
  render() {
648
656
  this.removePill();
@@ -921,7 +929,17 @@ class DocugatePill {
921
929
  hide.textContent = 'Hide';
922
930
  hide.title = 'Hide the pill. Reload the page or press Alt+Shift+D to bring it back.';
923
931
  hide.addEventListener('click', () => this.setHidden(true));
924
- foot.append(status, hide);
932
+ foot.append(status);
933
+ if (this.docsUrl && /^https:\/\//.test(this.docsUrl)) {
934
+ const docs = document.createElement('a');
935
+ docs.className = 'link-btn docs-link';
936
+ docs.href = this.docsUrl;
937
+ docs.target = '_blank';
938
+ docs.rel = 'noopener noreferrer';
939
+ docs.textContent = 'Open the docs ↗';
940
+ foot.append(docs);
941
+ }
942
+ foot.append(hide);
925
943
  return foot;
926
944
  }
927
945
  /** Steps seen on this screen in this tab, by target, so the checklist shows progress. */