docugate 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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,44 +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 repository 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
- npx docugate init # set this repository up for DocuGate
9
- npx docugate check # check the docs the way DocuGate will read them
10
- npx docugate openapi # prepare an OpenAPI spec for the API reference
6
+ npm i -g docugate
11
7
  ```
12
8
 
13
- Works with npm, pnpm, yarn and bun. Requires Node 18.17 or later. Run every
14
- command from the root of your repository.
9
+ ---
15
10
 
16
- ## `init`
11
+ ## Quick start
17
12
 
18
- Creates a `docugate.json` and, if your docs folder has no markdown yet, a first
19
- `index.md`. It never overwrites a file, so it is safe to run in a repository
20
- that already has docs.
13
+ `cd` into your app's repository, then run:
21
14
 
22
15
  ```sh
23
- npx docugate init --dir docs --title "Acme API"
16
+ docugate init
24
17
  ```
25
18
 
26
- Then push, and publish the space from your
27
- [DocuGate dashboard](https://www.trydocugate.site/dashboard/new).
19
+ It walks you through four steps:
28
20
 
29
- ## `check`
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.
30
25
 
31
- Reads your docs folder with DocuGate's own rules and reports what the
32
- published page would get wrong:
26
+ Once that is done:
33
27
 
34
- - **Errors:** broken links between pages, links that leave the docs folder,
35
- two files that become the same URL, an unreadable `docugate.json`, an empty
36
- docs folder, or more than 300 pages.
37
- - **Warnings:** pages with no `# Title`, front matter, `.mdx` files, no landing
38
- page, and `sidebar` entries that match nothing.
28
+ ```sh
29
+ docugate tour serve
30
+ ```
31
+
32
+ Start your app in another terminal, open it in the browser, and the pill appears in the corner.
33
+
34
+ ---
35
+
36
+ ## Tours
37
+
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.
39
+
40
+ **Example stop:**
41
+
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.
56
+ ```
57
+
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
+ ---
39
73
 
40
- It exits with 1 when there are errors (add `--strict` to fail on warnings too),
41
- so it works as a CI step:
74
+ ## The pill
75
+
76
+ When `docugate tour serve` is running, the pill appears in the corner of your app. It has two modes:
77
+
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.
80
+
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.
82
+
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:
42
106
 
43
107
  ```yaml
44
108
  # .github/workflows/docs.yml
@@ -52,30 +116,81 @@ jobs:
52
116
  - uses: actions/setup-node@v4
53
117
  with:
54
118
  node-version: 22
55
- - run: npx docugate check
119
+ - run: npx docugate@0.2 check --strict
120
+ ```
121
+
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
56
163
  ```
57
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
+ ---
178
+
58
179
  ## `openapi`
59
180
 
60
- Takes the OpenAPI 3 spec your backend already produces and writes the version
61
- you want published: only the public paths, without sensitive fields, with keys
62
- 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.
63
182
 
64
183
  ```sh
65
- npx docugate openapi --from http://localhost:3000/openapi.json \
184
+ docugate openapi --from http://localhost:3000/openapi.json \
66
185
  --exclude "/internal/**" --redact passwordHash --out openapi.json
67
186
  ```
68
187
 
69
188
  - `--from`: a `.json`, `.yaml` or `.yml` file, or a URL serving one.
70
- - `--include` / `--exclude`: path globs; `*` matches within one segment and
71
- `**` matches across segments. Both can be repeated.
72
- - `--redact`: a property name to remove from every schema and example.
73
- Can be repeated.
74
- - `--check`: don't write anything; exit with 1 if the committed file is out of
75
- 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.
76
192
 
77
- Instead of passing flags every time, you can set the same options in
78
- `docugate.json`:
193
+ Instead of passing flags every time, set the same options in `docugate.json`:
79
194
 
80
195
  ```json
81
196
  {
@@ -91,24 +206,46 @@ Instead of passing flags every time, you can set the same options in
91
206
  }
92
207
  ```
93
208
 
94
- The `openapi` command doesn't read your code to infer a spec. Frameworks that
95
- already know their schemas can export one: NestJS (`@nestjs/swagger`), Fastify
96
- (`@fastify/swagger`), FastAPI, and Hono (`@hono/zod-openapi`). Point `--from`
97
- at that output.
98
-
99
- DocuGate's API reference pages, which are built from this file, are coming
100
- soon. See the
101
- [changelog](https://www.trydocugate.site/resources/changelog).
209
+ ---
102
210
 
103
211
  ## `docugate.json`
104
212
 
213
+ One per repository, at its root.
214
+
105
215
  | Key | Meaning |
106
216
  | --- | --- |
107
217
  | `docsDir` | The folder DocuGate reads. Defaults to `docs`. |
108
218
  | `title` | The space's title. |
109
- | `sidebar` | File and folder names in the order the sidebar should show them. |
219
+ | `role` | Repository role: `frontend`, `backend`, or `both`. Set by `docugate init`. |
220
+ | `sidebar` | File and folder names in the order the sidebar should show them. Orders this repository's own pages, not the whole space. |
110
221
  | `api.generate` | Defaults for `docugate openapi`. |
111
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
+
112
249
  ## Licence
113
250
 
114
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
+ }