docugate 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Gusenga Thierry
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gusenga Thierry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,72 +1,108 @@
1
1
  # docugate
2
2
 
3
- Command-line tools for [DocuGate](https://www.trydocugate.site), which publishes
4
- documentation straight from the markdown in your GitHub repositories and decides
5
- who may read it.
3
+ DocuGate publishes documentation straight from the markdown in your GitHub repositories and shows a walkthrough pill on your running app so any developer can see what each element on screen is and where its data comes from.
6
4
 
7
5
  ```sh
8
6
  npm i -g docugate
9
7
  ```
10
8
 
9
+ ---
10
+
11
+ ## Quick start
12
+
13
+ `cd` into your app's repository, then run:
14
+
11
15
  ```sh
12
- docugate init # set this repository up for DocuGate
13
- docugate check # check the docs the way DocuGate will read them
14
- docugate openapi # prepare an OpenAPI spec for the API reference
16
+ docugate init
15
17
  ```
16
18
 
17
- Installing the command once is usually simpler than reaching for a package
18
- manager in each repository: it needs no `package.json`, so it works just as
19
- well in a Go, Python or Flutter repository as in a JavaScript one. That matters
20
- because one DocuGate space can read from several repositories, and they are
21
- rarely all the same language.
19
+ It walks you through four steps:
22
20
 
23
- If you would rather not install it globally, `npm i -D docugate` adds it to a
24
- single Node project, which also pins the version for CI; `npx docugate` runs it
25
- without installing anything. Both work with pnpm, yarn and bun too.
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.
26
25
 
27
- Requires Node 18.17 or later. Run every command from the root of your
28
- repository.
26
+ Once that is done:
29
27
 
30
- ## Several repositories in one space
28
+ ```sh
29
+ docugate tour serve
30
+ ```
31
31
 
32
- A space can merge the docs from a frontend, a backend and a mobile app into one
33
- set of pages, so a feature that spans all three is documented once instead of
34
- three times. Repositories are added to a space from its settings on
35
- [trydocugate.site](https://www.trydocugate.site/dashboard), not from here.
32
+ Start your app in another terminal, open it in the browser, and the pill appears in the corner.
36
33
 
37
- What that changes for this package: run `init` and `check` in **each**
38
- repository that contributes docs. Every one gets its own `docugate.json`, naming
39
- its own docs folder. `check` reads the repository it is run in, which is why its
40
- 300-page limit is per repository rather than per space.
34
+ ---
41
35
 
42
- See [Merging repositories](https://www.trydocugate.site/docs/merging-repositories).
36
+ ## Tours
43
37
 
44
- ## `init`
38
+ A tour helps a developer who is new to a codebase see what each element on screen is and where its data comes from. For each screen there is one markdown file in `.docugate/tour/`. Each file has front matter with a `route` and a `title`, then one `##` section per stop.
45
39
 
46
- Creates a `docugate.json` and, if your docs folder has no markdown yet, a first
47
- `index.md`. It never overwrites a file, so it is safe to run in a repository
48
- that already has docs.
40
+ **Example stop:**
49
41
 
50
- ```sh
51
- docugate init --dir docs --title "Acme API"
42
+ ```markdown
43
+ ---
44
+ route: /invoices/:id
45
+ title: Invoice detail
46
+ ---
47
+
48
+ ## Total
49
+ target: [data-tour="invoice-total"]
50
+ data: GET /api/invoices/:id → total
51
+ code: frontend/src/screens/InvoiceDetail.tsx
52
+ source: backend/src/billing.ts
53
+ docs: https://example.com/docs/billing
54
+
55
+ The amount due: the subtotal plus tax, computed by `totals()` in the backend.
52
56
  ```
53
57
 
54
- Then push, and publish the space from your
55
- [DocuGate dashboard](https://www.trydocugate.site/dashboard/new).
58
+ Fields:
59
+
60
+ | Field | Required | Meaning |
61
+ | --- | --- | --- |
62
+ | `target` | yes | CSS selector for the element. Prefer `[data-tour="…"]`. |
63
+ | `data` | no | Request and field: `METHOD /path → field`. |
64
+ | `code` | yes | File that renders the element, relative to the repository root. |
65
+ | `source` | no | File where the value is computed, when that is elsewhere. |
66
+ | `docs` | no | URL to relevant documentation. |
67
+
68
+ The prose paragraph after the fields explains what the element is and why its value is what it is.
69
+
70
+ Tour files are yours. `docugate tour init` never overwrites a file that already exists.
71
+
72
+ ---
73
+
74
+ ## The pill
56
75
 
57
- ## `check`
76
+ When `docugate tour serve` is running, the pill appears in the corner of your app. It has two modes:
58
77
 
59
- Reads your docs folder with DocuGate's own rules and reports what the
60
- published page would get wrong:
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.
61
80
 
62
- - **Errors:** broken links between pages, links that leave the docs folder,
63
- two files that become the same URL, an unreadable `docugate.json`, an empty
64
- docs folder, or more than 300 pages.
65
- - **Warnings:** pages with no `# Title`, front matter, `.mdx` files, no landing
66
- page, and `sidebar` entries that match nothing.
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.
67
82
 
68
- It exits with 1 when there are errors (add `--strict` to fail on warnings too),
69
- so it works as a CI step:
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.
84
+
85
+ ---
86
+
87
+ ## Keeping tours true
88
+
89
+ A tour is only useful while it is true, so `docugate check` verifies every claim in `.docugate/tour/` against the code. For each stop it warns when:
90
+
91
+ - the `code` file, or the `source` file, no longer exists;
92
+ - the `code` file, and the files it imports, no longer mention the endpoint in `data`;
93
+ - the `code` file no longer contains the `data-tour` value in `target` (a value passed through a prop counts);
94
+ - a required field is missing, or two stops on one screen point at the same element.
95
+
96
+ Rename `invoice-total` in a component and `check` tells you which stop went stale.
97
+
98
+ It checks your docs folder too:
99
+
100
+ - **Errors:** broken links between pages, links that leave the docs folder, two files that become the same URL, an unreadable `docugate.json`, an empty docs folder, or more than 300 pages.
101
+ - **Warnings:** pages with no `# Title`, front matter, `.mdx` files, no landing page, and `sidebar` entries that match nothing.
102
+
103
+ It exits with 1 when there are errors. Add `--strict` to fail on warnings too, which is how stale tour stops fail a build.
104
+
105
+ Use it in CI:
70
106
 
71
107
  ```yaml
72
108
  # .github/workflows/docs.yml
@@ -80,19 +116,69 @@ jobs:
80
116
  - uses: actions/setup-node@v4
81
117
  with:
82
118
  node-version: 22
83
- - run: npx docugate@0.1 check
119
+ - run: npx docugate@0.2 check --strict
84
120
  ```
85
121
 
86
- Pin the version in CI, as above, or add the package to the repository and run
87
- it from there. Without a pin, the rules being enforced can change between two
88
- runs of the same workflow, which turns a green build red with nothing in the
89
- commit to explain it.
122
+ Pin the version in CI, as above. Without a pin, the rules being enforced can change between two runs of the same workflow, which turns a green build red with nothing in the commit to explain it.
123
+
124
+ ---
125
+
126
+ ## Commands
127
+
128
+ | Command | Main flags | What it does |
129
+ | --- | --- | --- |
130
+ | `docugate init` | `--dir`, `--title`, `--role frontend\|backend\|both`, `--yes` | Set up this repository for DocuGate (interactive). |
131
+ | `docugate check` | `--dir`, `--strict` | Check docs and tour files the way DocuGate will read them. |
132
+ | `docugate openapi` | `--from`, `--out`, `--include`, `--exclude`, `--redact`, `--check` | Prepare an OpenAPI spec for the API reference. |
133
+ | `docugate login` | | Sign in to DocuGate with GitHub. |
134
+ | `docugate logout` | | Sign out and delete saved credentials. |
135
+ | `docugate whoami` | | Print the currently signed-in user. |
136
+ | `docugate tour init` | `--max-cost <n>`, `--team-id <id>` | Write the first tour with IBM Bob (uses Bob Shell). |
137
+ | `docugate tour serve` | `--port <n>` (default 4178) | Start the local server that serves tour data for the pill. |
138
+ | `docugate tour install` | `--port <n>`, `--remove` | Add (or remove) the pill loader from your app's HTML or layout. |
139
+ | `docugate tour export <file>` | | Write every tour screen to a single JSON file. |
140
+
141
+ Run every command from the root of your repository.
142
+
143
+ ---
144
+
145
+ ## Try it on the demo
146
+
147
+ Clone [docugate-demo](https://github.com/Docugate/docugate-demo), a small full-stack app with a pre-written tour:
148
+
149
+ ```sh
150
+ git clone https://github.com/Docugate/docugate-demo
151
+ cd docugate-demo
152
+
153
+ # install dependencies and start the app
154
+ npm install
155
+ npm run dev
156
+ ```
157
+
158
+ In a second terminal:
159
+
160
+ ```sh
161
+ cd docugate-demo
162
+ docugate tour serve
163
+ ```
164
+
165
+ Open the app in the browser. The pill appears in the bottom-right corner.
166
+
167
+ ---
168
+
169
+ ## Several repositories in one space
170
+
171
+ A space can merge docs from a frontend, a backend, and a mobile app into one set of pages. `docugate init` connects the repository it runs in to one of your spaces once you are signed in; you can also add repositories from a space's settings on [trydocugate.site](https://www.trydocugate.site/dashboard).
172
+
173
+ Run `init` and `check` in each repository that contributes docs, and answer the role question in each: the tour lives in the frontend, and its `source` lines can point into the backend. Every one gets its own `docugate.json`, naming its own docs folder. `check` reads the repository it is run in, which is why its 300-page limit is per repository rather than per space.
174
+
175
+ See [Merging repositories](https://www.trydocugate.site/docs/merging-repositories).
176
+
177
+ ---
90
178
 
91
179
  ## `openapi`
92
180
 
93
- Takes the OpenAPI 3 spec your backend already produces and writes the version
94
- you want published: only the public paths, without sensitive fields, with keys
95
- in a stable order so it reviews cleanly in a pull request.
181
+ Takes the OpenAPI 3 spec your backend already produces and writes the version you want published: only the public paths, without sensitive fields, with keys in a stable order so it reviews cleanly in a pull request.
96
182
 
97
183
  ```sh
98
184
  docugate openapi --from http://localhost:3000/openapi.json \
@@ -100,15 +186,11 @@ docugate openapi --from http://localhost:3000/openapi.json \
100
186
  ```
101
187
 
102
188
  - `--from`: a `.json`, `.yaml` or `.yml` file, or a URL serving one.
103
- - `--include` / `--exclude`: path globs; `*` matches within one segment and
104
- `**` matches across segments. Both can be repeated.
105
- - `--redact`: a property name to remove from every schema and example.
106
- Can be repeated.
107
- - `--check`: don't write anything; exit with 1 if the committed file is out of
108
- date. Useful in CI.
189
+ - `--include` / `--exclude`: path globs; `*` matches within one segment and `**` matches across segments. Both can be repeated.
190
+ - `--redact`: a property name to remove from every schema and example. Can be repeated.
191
+ - `--check`: do not write anything; exit with 1 if the committed file is out of date. Useful in CI.
109
192
 
110
- Instead of passing flags every time, you can set the same options in
111
- `docugate.json`:
193
+ Instead of passing flags every time, set the same options in `docugate.json`:
112
194
 
113
195
  ```json
114
196
  {
@@ -124,14 +206,7 @@ Instead of passing flags every time, you can set the same options in
124
206
  }
125
207
  ```
126
208
 
127
- The `openapi` command doesn't read your code to infer a spec. Frameworks that
128
- already know their schemas can export one: NestJS (`@nestjs/swagger`), Fastify
129
- (`@fastify/swagger`), FastAPI, and Hono (`@hono/zod-openapi`). Point `--from`
130
- at that output.
131
-
132
- DocuGate's API reference pages, which are built from this file, are coming
133
- soon. See the
134
- [changelog](https://www.trydocugate.site/resources/changelog).
209
+ ---
135
210
 
136
211
  ## `docugate.json`
137
212
 
@@ -141,9 +216,36 @@ One per repository, at its root.
141
216
  | --- | --- |
142
217
  | `docsDir` | The folder DocuGate reads. Defaults to `docs`. |
143
218
  | `title` | The space's title. |
219
+ | `role` | Repository role: `frontend`, `backend`, or `both`. Set by `docugate init`. |
144
220
  | `sidebar` | File and folder names in the order the sidebar should show them. Orders this repository's own pages, not the whole space. |
145
221
  | `api.generate` | Defaults for `docugate openapi`. |
146
222
 
223
+ ---
224
+
225
+ ## IBM Bob 2.0 hackathon
226
+
227
+ This repository is DocuGate's entry to the IBM Bob 2.0 hackathon (lablab.ai, September 25 to 27, 2026). To be clear about what was built when:
228
+
229
+ **Before the event:** the CLI that was already in DocuGate's private repository: `docugate init`, `docugate check`, and `docugate openapi`. Moved here with its history.
230
+
231
+ **During the event, with IBM Bob.** Each task has its session summary and export in `bob_sessions/`, and a plan where Bob planned first:
232
+
233
+ | Task | What Bob built |
234
+ | --- | --- |
235
+ | 01 | The first tour: Bob read the Ledgerly demo and wrote `.docugate/tour/invoice-detail.md`, tracing each value to its endpoint and backend file. |
236
+ | 02 | The tour format in code (`parseTourFile`, `serializeTourFile`, `loadTours`, `matchRoute`) and the tour checks in `docugate check`. |
237
+ | 03 | `docugate tour serve` (tour data, live change events, saving stops) and `docugate tour export`. |
238
+ | 04 | The pill: walkthrough, inspector and Add to tour, in a shadow DOM with no dependencies. |
239
+ | 05 | One command: `docugate init` walks through the whole setup, and `docugate tour install` adds the pill to the app. |
240
+ | 06 | `docugate login`, `logout` and `whoami`, and connecting the repository to a space during `init`. |
241
+ | 07 | This README. |
242
+
243
+ `docugate tour init` also runs IBM Bob itself, through Bob Shell. It installs a Tour Writer mode that may only edit `.docugate/tour/`, asks Bob to read the app's code, and never changes a tour file the user already has.
244
+
245
+ **During the event, without Bob:** the Ledgerly demo app; the website side of `docugate login` on trydocugate.site; and fixes found while testing, such as Windows short paths in `tour serve` and the sign-in link on Windows.
246
+
247
+ ---
248
+
147
249
  ## Licence
148
250
 
149
251
  MIT
package/dist/api.js ADDED
@@ -0,0 +1,61 @@
1
+ import { loadCredentials, saveCredentials } from './auth.js';
2
+ /** Module-level default, captured at import time (used by CLI commands). */
3
+ export const BASE_URL = process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
4
+ const ERROR_MESSAGES = {
5
+ pro_required_sources: 'Connecting more than one repository to a space needs DocuGate Pro',
6
+ };
7
+ function friendlyError(code) {
8
+ return ERROR_MESSAGES[code] ?? `DocuGate said: ${code}`;
9
+ }
10
+ export async function apiFetch(path, init = {}) {
11
+ const creds = loadCredentials();
12
+ const headers = {
13
+ 'Content-Type': 'application/json',
14
+ ...init.headers,
15
+ };
16
+ if (creds) {
17
+ headers['Authorization'] = `Bearer ${creds.token}`;
18
+ }
19
+ // Re-read the env at call time so tests that set DOCUGATE_URL after import work.
20
+ const base = process.env.DOCUGATE_URL ?? 'https://www.trydocugate.site';
21
+ const res = await fetch(`${base}${path}`, { ...init, headers });
22
+ // Renew token if the server sent a fresh one.
23
+ const renewed = res.headers.get('x-docugate-token');
24
+ if (renewed)
25
+ saveCredentials(renewed);
26
+ if (res.status === 401) {
27
+ throw new Error('Run docugate login');
28
+ }
29
+ if (!res.ok) {
30
+ let code;
31
+ try {
32
+ const body = await res.clone().json();
33
+ if (body && typeof body === 'object' && !Array.isArray(body)) {
34
+ const b = body;
35
+ if (typeof b.error === 'string')
36
+ code = b.error;
37
+ }
38
+ }
39
+ catch {
40
+ // ignore JSON parse failure
41
+ }
42
+ if (code)
43
+ throw new Error(friendlyError(code));
44
+ throw new Error(`DocuGate request failed with status ${res.status}`);
45
+ }
46
+ return res;
47
+ }
48
+ export async function getSession() {
49
+ const res = await apiFetch('/api/auth/session');
50
+ return res.json();
51
+ }
52
+ export async function getSpaces() {
53
+ const res = await apiFetch('/api/spaces/mine');
54
+ return res.json();
55
+ }
56
+ export async function connectRepo(spaceId, repo, docsDir) {
57
+ await apiFetch(`/api/spaces/${spaceId}/sources`, {
58
+ method: 'POST',
59
+ body: JSON.stringify({ repo, docsDir }),
60
+ });
61
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,97 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { spawn } from 'node:child_process';
4
+ import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ const CREDENTIALS_DIR = join(homedir(), '.docugate');
8
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'credentials.json');
9
+ export function credentialsPath() {
10
+ return CREDENTIALS_FILE;
11
+ }
12
+ export function loadCredentials() {
13
+ try {
14
+ const raw = readFileSync(CREDENTIALS_FILE, 'utf8');
15
+ const parsed = JSON.parse(raw);
16
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
17
+ const obj = parsed;
18
+ if (typeof obj.token === 'string')
19
+ return { token: obj.token };
20
+ }
21
+ }
22
+ catch {
23
+ // missing, unreadable, or not valid JSON
24
+ }
25
+ return null;
26
+ }
27
+ export function saveCredentials(token) {
28
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
29
+ writeFileSync(CREDENTIALS_FILE, JSON.stringify({ token }) + '\n', { mode: 0o600 });
30
+ }
31
+ export function clearCredentials() {
32
+ rmSync(CREDENTIALS_FILE, { force: true });
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Browser-based login
36
+ // ---------------------------------------------------------------------------
37
+ function openBrowserDefault(url) {
38
+ // Not `cmd /c start`: cmd reads the `&` between query parameters as a second
39
+ // command, so the browser got the URL cut off after `port=` and the site
40
+ // refused the link. rundll32 hands the URL over untouched.
41
+ const [cmd, args] = process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]]
42
+ : process.platform === 'darwin' ? ['open', [url]]
43
+ : ['xdg-open', [url]];
44
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
45
+ }
46
+ /**
47
+ * Opens the browser at `baseUrl/api/auth/cli?port=…&state=…`, starts a local
48
+ * HTTP server to receive the callback, saves the token, and resolves once the
49
+ * user completes sign-in. Rejects after `timeoutMs` (default 5 minutes).
50
+ */
51
+ export function login(baseUrl, opts = {}) {
52
+ const openBrowser = opts.openBrowser ?? openBrowserDefault;
53
+ const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000;
54
+ const state = randomBytes(24).toString('base64url');
55
+ // Must match cliCode() on the website character for character, or the page
56
+ // tells the person their codes differ when nothing is wrong.
57
+ const code = `${state.slice(0, 4)}-${state.slice(4, 8)}`.toUpperCase();
58
+ return new Promise((resolve, reject) => {
59
+ const server = createServer((req, res) => {
60
+ try {
61
+ const u = new URL(req.url ?? '/', `http://127.0.0.1`);
62
+ if (u.pathname !== '/callback') {
63
+ res.writeHead(404).end('Not found');
64
+ return;
65
+ }
66
+ // The browser is sent back to the website's own finish page, so every
67
+ // screen of the sign-in looks like DocuGate rather than a bare local one.
68
+ const done = `${baseUrl}/api/auth/cli/done`;
69
+ const token = u.searchParams.get('token') ?? '';
70
+ if (u.searchParams.get('state') !== state || !token) {
71
+ res.writeHead(302, { Location: `${done}?error=state` }).end();
72
+ return;
73
+ }
74
+ res.writeHead(302, { Location: done }).end();
75
+ clearTimeout(timer);
76
+ server.close();
77
+ saveCredentials(token);
78
+ resolve({ token });
79
+ }
80
+ catch (err) {
81
+ res.writeHead(500).end('Internal error');
82
+ reject(err);
83
+ }
84
+ });
85
+ const timer = setTimeout(() => {
86
+ server.close();
87
+ reject(new Error('Sign-in timed out after 5 minutes.'));
88
+ }, timeoutMs);
89
+ server.listen(0, '127.0.0.1', () => {
90
+ const addr = server.address();
91
+ const port = addr && typeof addr === 'object' ? addr.port : 0;
92
+ const url = `${baseUrl}/api/auth/cli?port=${port}&state=${state}`;
93
+ console.log(`\nOpen this URL to sign in:\n ${url}\n\nConfirmation code: ${code}\n`);
94
+ openBrowser(url);
95
+ });
96
+ });
97
+ }
package/dist/check.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { loadConfig } from './config.js';
4
4
  import { MAX_FILES, firstHeading, landingPath, pathToSlug, resolveRelativeLink } from './rules.js';
5
+ import { loadTours } from './tour.js';
5
6
  /** Never part of a repository on GitHub, and huge when present locally. */
6
7
  const SKIP = new Set(['node_modules', '.git']);
7
8
  /** Every file under `dir`, as forward-slash paths relative to it. */
@@ -133,5 +134,149 @@ export function check(root, dirOverride) {
133
134
  });
134
135
  }
135
136
  }
137
+ warnings.push(...checkTours(root));
136
138
  return { docsDir, pages: pages.length, errors, warnings };
137
139
  }
140
+ /**
141
+ * Turn a data-tour route path (with :param segments) into a regex that matches
142
+ * the same path with any token in :param positions.
143
+ *
144
+ * The regex is intentionally loose so it matches template literals, Express
145
+ * route strings, Hono route strings, etc.
146
+ */
147
+ function routeToRegex(routePath) {
148
+ // Split on :param segments, escape the literal parts, join with a wildcard
149
+ const parts = routePath.split(/:[\w]+/);
150
+ const escaped = parts.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
151
+ return new RegExp(escaped.join('[^/\'"` ]+'));
152
+ }
153
+ /** Extract bare path from a data field like `GET /api/invoices/:id → total`. */
154
+ function parseDataPath(data) {
155
+ const m = data.match(/\w+\s+(\/[^\s→]+)/);
156
+ return m ? m[1].trim() : null;
157
+ }
158
+ /** Resolve a single-level import path inside a source file. */
159
+ function resolveImport(from, spec, root) {
160
+ if (!spec.startsWith('.'))
161
+ return null;
162
+ const dir = from.includes('/') ? from.slice(0, from.lastIndexOf('/')) : '';
163
+ const base = join(root, dir, spec);
164
+ for (const ext of ['.ts', '.tsx', '.js', '.jsx', '']) {
165
+ const full = base + ext;
166
+ if (existsSync(full))
167
+ return full;
168
+ // index file
169
+ const index = join(base, 'index') + ext;
170
+ if (existsSync(index))
171
+ return index;
172
+ }
173
+ return null;
174
+ }
175
+ /** All direct import specifiers found in a file. */
176
+ function importsIn(text) {
177
+ const specs = [];
178
+ for (const m of text.matchAll(/^import\s+[^'"]*['"]([^'"]+)['"]/gm))
179
+ specs.push(m[1]);
180
+ return specs;
181
+ }
182
+ /**
183
+ * Run tour checks against every tour file found under `.docugate/tour/`.
184
+ * Issues are warnings: the docs still publish; the tour may just be wrong.
185
+ */
186
+ function checkTours(root) {
187
+ const warnings = [];
188
+ let tours;
189
+ try {
190
+ tours = loadTours(root);
191
+ }
192
+ catch (err) {
193
+ warnings.push({ message: `tour: ${err.message}` });
194
+ return warnings;
195
+ }
196
+ if (tours.length === 0)
197
+ return warnings;
198
+ for (const tour of tours) {
199
+ const label = tour.file ?? `tour/${tour.route}`;
200
+ // required fields are validated by parseTourFile (throws); stops that
201
+ // survive parsing already have target and code.
202
+ // no two stops on a screen share a target
203
+ const seen = new Map();
204
+ for (const stop of tour.stops) {
205
+ const prev = seen.get(stop.target);
206
+ if (prev) {
207
+ warnings.push({
208
+ file: label,
209
+ message: `stops "${prev}" and "${stop.heading}" share the same target ${stop.target}.`,
210
+ });
211
+ }
212
+ else {
213
+ seen.set(stop.target, stop.heading);
214
+ }
215
+ }
216
+ for (const stop of tour.stops) {
217
+ const codeAbs = join(root, stop.code);
218
+ // code file must exist
219
+ if (!existsSync(codeAbs)) {
220
+ warnings.push({ file: label, message: `stop "${stop.heading}": code file "${stop.code}" does not exist.` });
221
+ continue;
222
+ }
223
+ // source file must exist if given
224
+ if (stop.source) {
225
+ const sourceAbs = join(root, stop.source);
226
+ if (!existsSync(sourceAbs)) {
227
+ warnings.push({ file: label, message: `stop "${stop.heading}": source file "${stop.source}" does not exist.` });
228
+ }
229
+ }
230
+ const codeText = readFileSync(codeAbs, 'utf8');
231
+ // code file contains the data-tour value from target.
232
+ // Accepts either the full attribute (data-tour="value") or the value as
233
+ // any quoted string literal ("value" / 'value'), so that passing the
234
+ // value through a prop like tour="invoice-status" also passes the check.
235
+ // A renamed value (e.g. "invoice-total-RENAMED") must still warn.
236
+ const tourValueMatch = stop.target.match(/data-tour=["']([^"']+)["']/);
237
+ if (tourValueMatch) {
238
+ const tourValue = tourValueMatch[1];
239
+ const esc = tourValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
240
+ const exactPattern = new RegExp(`["']${esc}["']`);
241
+ if (!exactPattern.test(codeText)) {
242
+ warnings.push({
243
+ file: label,
244
+ message: `stop "${stop.heading}": "${stop.code}" does not contain data-tour value "${tourValue}".`,
245
+ });
246
+ }
247
+ }
248
+ // code file (or a file it imports, one level deep) contains the endpoint path from data
249
+ if (stop.data) {
250
+ const endpointPath = parseDataPath(stop.data);
251
+ if (endpointPath) {
252
+ // build a regex that matches the path with :param as wildcard
253
+ const pathRegex = routeToRegex(endpointPath);
254
+ const filesForEndpoint = [codeAbs];
255
+ for (const spec of importsIn(codeText)) {
256
+ const resolved = resolveImport(stop.code, spec, root);
257
+ if (resolved)
258
+ filesForEndpoint.push(resolved);
259
+ }
260
+ const found = filesForEndpoint.some((f) => {
261
+ try {
262
+ const t = readFileSync(f, 'utf8');
263
+ // Check for literal path or a path where :param is present as /:id etc.
264
+ // We look for any substring matching the regex pattern by scanning lines
265
+ return t.split('\n').some((line) => pathRegex.test(line));
266
+ }
267
+ catch {
268
+ return false;
269
+ }
270
+ });
271
+ if (!found) {
272
+ warnings.push({
273
+ file: label,
274
+ message: `stop "${stop.heading}": endpoint "${endpointPath}" not found in "${stop.code}" or its imports.`,
275
+ });
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+ return warnings;
282
+ }