docugate 0.1.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 +21 -0
- package/README.md +114 -0
- package/dist/check.js +137 -0
- package/dist/cli.js +159 -0
- package/dist/config.js +53 -0
- package/dist/init.js +36 -0
- package/dist/openapi.js +176 -0
- package/dist/rules.js +61 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +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.
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# docugate
|
|
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.
|
|
6
|
+
|
|
7
|
+
```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
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Works with npm, pnpm, yarn and bun. Requires Node 18.17 or later. Run every
|
|
14
|
+
command from the root of your repository.
|
|
15
|
+
|
|
16
|
+
## `init`
|
|
17
|
+
|
|
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.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx docugate init --dir docs --title "Acme API"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Then push, and publish the space from your
|
|
27
|
+
[DocuGate dashboard](https://www.trydocugate.site/dashboard/new).
|
|
28
|
+
|
|
29
|
+
## `check`
|
|
30
|
+
|
|
31
|
+
Reads your docs folder with DocuGate's own rules and reports what the
|
|
32
|
+
published page would get wrong:
|
|
33
|
+
|
|
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.
|
|
39
|
+
|
|
40
|
+
It exits with 1 when there are errors (add `--strict` to fail on warnings too),
|
|
41
|
+
so it works as a CI step:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
# .github/workflows/docs.yml
|
|
45
|
+
name: Docs
|
|
46
|
+
on: [push, pull_request]
|
|
47
|
+
jobs:
|
|
48
|
+
check:
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
- uses: actions/setup-node@v4
|
|
53
|
+
with:
|
|
54
|
+
node-version: 22
|
|
55
|
+
- run: npx docugate check
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## `openapi`
|
|
59
|
+
|
|
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.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npx docugate openapi --from http://localhost:3000/openapi.json \
|
|
66
|
+
--exclude "/internal/**" --redact passwordHash --out openapi.json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `--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.
|
|
76
|
+
|
|
77
|
+
Instead of passing flags every time, you can set the same options in
|
|
78
|
+
`docugate.json`:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"docsDir": "docs",
|
|
83
|
+
"api": {
|
|
84
|
+
"generate": {
|
|
85
|
+
"from": "http://localhost:3000/openapi.json",
|
|
86
|
+
"output": "openapi.json",
|
|
87
|
+
"exclude": ["/internal/**"],
|
|
88
|
+
"redact": ["passwordHash"]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
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).
|
|
102
|
+
|
|
103
|
+
## `docugate.json`
|
|
104
|
+
|
|
105
|
+
| Key | Meaning |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `docsDir` | The folder DocuGate reads. Defaults to `docs`. |
|
|
108
|
+
| `title` | The space's title. |
|
|
109
|
+
| `sidebar` | File and folder names in the order the sidebar should show them. |
|
|
110
|
+
| `api.generate` | Defaults for `docugate openapi`. |
|
|
111
|
+
|
|
112
|
+
## Licence
|
|
113
|
+
|
|
114
|
+
MIT
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
import { MAX_FILES, firstHeading, landingPath, pathToSlug, resolveRelativeLink } from './rules.js';
|
|
5
|
+
/** Never part of a repository on GitHub, and huge when present locally. */
|
|
6
|
+
const SKIP = new Set(['node_modules', '.git']);
|
|
7
|
+
/** Every file under `dir`, as forward-slash paths relative to it. */
|
|
8
|
+
function walk(dir) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const visit = (current) => {
|
|
11
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
12
|
+
if (SKIP.has(entry.name))
|
|
13
|
+
continue;
|
|
14
|
+
const full = join(current, entry.name);
|
|
15
|
+
if (entry.isDirectory())
|
|
16
|
+
visit(full);
|
|
17
|
+
else if (entry.isFile())
|
|
18
|
+
out.push(relative(dir, full).split(sep).join('/'));
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
visit(dir);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
/** Links in a page, skipping fenced code, where a link is only an example. */
|
|
25
|
+
function linksIn(content) {
|
|
26
|
+
const links = [];
|
|
27
|
+
let fenced = false;
|
|
28
|
+
for (const line of content.split('\n')) {
|
|
29
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
30
|
+
fenced = !fenced;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (fenced)
|
|
34
|
+
continue;
|
|
35
|
+
for (const match of line.replace(/`[^`]*`/g, '').matchAll(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g)) {
|
|
36
|
+
links.push(match[1]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return links;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Checks a repository's docs the way DocuGate will read them, so problems turn
|
|
43
|
+
* up on the command line or in CI rather than on the published page.
|
|
44
|
+
*
|
|
45
|
+
* Errors are things DocuGate will get wrong or refuse. Warnings are things it
|
|
46
|
+
* handles, but probably not the way the author meant.
|
|
47
|
+
*/
|
|
48
|
+
export function check(root, dirOverride) {
|
|
49
|
+
const loaded = loadConfig(root);
|
|
50
|
+
const errors = [...loaded.errors];
|
|
51
|
+
const warnings = [...loaded.warnings];
|
|
52
|
+
const docsDir = (dirOverride ?? loaded.docsDir).replace(/^\/|\/$/g, '') || '.';
|
|
53
|
+
const base = docsDir === '.' ? root : join(root, docsDir);
|
|
54
|
+
if (!existsSync(base) || !statSync(base).isDirectory()) {
|
|
55
|
+
errors.push({
|
|
56
|
+
message: `The docs folder "${docsDir}" does not exist.${loaded.found ? '' : ' Set "docsDir" in docugate.json, or run `docugate init`.'}`,
|
|
57
|
+
});
|
|
58
|
+
return { docsDir, pages: 0, errors, warnings };
|
|
59
|
+
}
|
|
60
|
+
const files = walk(base);
|
|
61
|
+
const pages = files.filter((f) => /\.md$/i.test(f));
|
|
62
|
+
const shown = (p) => (docsDir === '.' ? p : `${docsDir}/${p}`);
|
|
63
|
+
for (const f of files.filter((f) => /\.mdx$/i.test(f))) {
|
|
64
|
+
warnings.push({ file: shown(f), message: 'is MDX, which DocuGate does not read. Rename it to .md.' });
|
|
65
|
+
}
|
|
66
|
+
if (pages.length === 0) {
|
|
67
|
+
errors.push({ message: `There are no .md files in "${docsDir}", so the space would be empty.` });
|
|
68
|
+
return { docsDir, pages: 0, errors, warnings };
|
|
69
|
+
}
|
|
70
|
+
if (pages.length > MAX_FILES) {
|
|
71
|
+
errors.push({
|
|
72
|
+
message: `"${docsDir}" has ${pages.length} markdown files. DocuGate refuses more than ${MAX_FILES}.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// Two files that route to the same URL: only one of them can be reached.
|
|
76
|
+
const bySlug = new Map();
|
|
77
|
+
for (const p of pages)
|
|
78
|
+
bySlug.set(pathToSlug(p), [...(bySlug.get(pathToSlug(p)) ?? []), p]);
|
|
79
|
+
for (const [slug, clash] of bySlug) {
|
|
80
|
+
if (clash.length > 1) {
|
|
81
|
+
errors.push({
|
|
82
|
+
message: `${clash.map(shown).join(' and ')} both become the page "/${slug}". Rename one.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const landing = landingPath(pages);
|
|
87
|
+
if (!landing) {
|
|
88
|
+
warnings.push({
|
|
89
|
+
message: `"${docsDir}" has no index.md or README.md, so the space has no landing page.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Every slug a link could land on, with the README standing in as the index.
|
|
93
|
+
const slugs = new Set(pages.map(pathToSlug));
|
|
94
|
+
if (landing)
|
|
95
|
+
slugs.add('');
|
|
96
|
+
for (const page of pages) {
|
|
97
|
+
const content = readFileSync(join(base, page), 'utf8').replace(/\r\n/g, '\n');
|
|
98
|
+
if (content.startsWith('---\n')) {
|
|
99
|
+
warnings.push({
|
|
100
|
+
file: shown(page),
|
|
101
|
+
message: 'starts with front matter, which DocuGate shows as text rather than reading.',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (!firstHeading(content)) {
|
|
105
|
+
warnings.push({
|
|
106
|
+
file: shown(page),
|
|
107
|
+
message: 'has no "# Title" heading, so its title is made from the file name.',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const href of linksIn(content)) {
|
|
111
|
+
const target = resolveRelativeLink(href, page);
|
|
112
|
+
if (target === null)
|
|
113
|
+
continue;
|
|
114
|
+
if (target === '\0outside') {
|
|
115
|
+
errors.push({
|
|
116
|
+
file: shown(page),
|
|
117
|
+
message: `links to ${href}, which is outside "${docsDir}" and will not be published.`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else if (!slugs.has(target)) {
|
|
121
|
+
errors.push({ file: shown(page), message: `links to ${href}, which does not exist.` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Sidebar entries are file or folder names, matched at whatever level they
|
|
126
|
+
// sit. One that matches nothing is almost always a rename it missed.
|
|
127
|
+
const names = new Set(pages.flatMap((p) => p.split('/')));
|
|
128
|
+
for (const entry of loaded.config.sidebar ?? []) {
|
|
129
|
+
if (typeof entry === 'string' && !names.has(entry)) {
|
|
130
|
+
warnings.push({
|
|
131
|
+
file: 'docugate.json',
|
|
132
|
+
message: `"sidebar" lists "${entry}", which is not a file or folder in "${docsDir}".`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { docsDir, pages: pages.length, errors, warnings };
|
|
137
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { check } from './check.js';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
import { init } from './init.js';
|
|
6
|
+
import { buildOpenApi, isCurrent, writeOpenApi } from './openapi.js';
|
|
7
|
+
const SITE = 'https://www.trydocugate.site';
|
|
8
|
+
const HELP = `docugate — set up and check the docs DocuGate reads from your repository
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
npx docugate <command> [options]
|
|
12
|
+
|
|
13
|
+
Commands
|
|
14
|
+
init Set up this repository for DocuGate
|
|
15
|
+
check Check the docs the way DocuGate will read them
|
|
16
|
+
openapi Prepare an OpenAPI spec for DocuGate's API reference
|
|
17
|
+
|
|
18
|
+
init
|
|
19
|
+
--dir <folder> Docs folder (default: docs)
|
|
20
|
+
--title <text> Space title (default: this folder's name)
|
|
21
|
+
|
|
22
|
+
check
|
|
23
|
+
--dir <folder> Check this folder instead of docugate.json's docsDir
|
|
24
|
+
--strict Treat warnings as errors
|
|
25
|
+
|
|
26
|
+
openapi
|
|
27
|
+
--from <file|url> The spec to read (or api.generate.from in docugate.json)
|
|
28
|
+
--out <file> Where to write it (default: openapi.json)
|
|
29
|
+
--include <glob> Keep only paths that match; repeatable
|
|
30
|
+
--exclude <glob> Drop paths that match; repeatable
|
|
31
|
+
--redact <name> Remove a property from every schema and example; repeatable
|
|
32
|
+
--check Fail if the file is out of date, instead of writing it
|
|
33
|
+
|
|
34
|
+
Run it from the root of your repository.
|
|
35
|
+
${SITE}
|
|
36
|
+
`;
|
|
37
|
+
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
38
|
+
const paint = (code, text) => (color ? `\x1b[${code}m${text}\x1b[0m` : text);
|
|
39
|
+
const red = (t) => paint(31, t);
|
|
40
|
+
const yellow = (t) => paint(33, t);
|
|
41
|
+
const green = (t) => paint(32, t);
|
|
42
|
+
const dim = (t) => paint(2, t);
|
|
43
|
+
const VALUE_FLAGS = new Set(['dir', 'title', 'from', 'out', 'include', 'exclude', 'redact']);
|
|
44
|
+
function parse(argv) {
|
|
45
|
+
const flags = new Map();
|
|
46
|
+
const bools = new Set();
|
|
47
|
+
let command;
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const arg = argv[i];
|
|
50
|
+
if (arg === '-h')
|
|
51
|
+
bools.add('help');
|
|
52
|
+
else if (arg === '-v')
|
|
53
|
+
bools.add('version');
|
|
54
|
+
else if (arg.startsWith('--')) {
|
|
55
|
+
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
56
|
+
if (VALUE_FLAGS.has(name)) {
|
|
57
|
+
const value = inline ?? argv[++i];
|
|
58
|
+
if (value === undefined)
|
|
59
|
+
fail(`--${name} needs a value.`);
|
|
60
|
+
flags.set(name, [...(flags.get(name) ?? []), value]);
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
bools.add(name);
|
|
64
|
+
}
|
|
65
|
+
else if (!command)
|
|
66
|
+
command = arg;
|
|
67
|
+
else
|
|
68
|
+
fail(`Unexpected argument "${arg}".`);
|
|
69
|
+
}
|
|
70
|
+
return { command, flags, bools };
|
|
71
|
+
}
|
|
72
|
+
function fail(message) {
|
|
73
|
+
console.error(`${red('error')} ${message}\n${dim('Run `npx docugate --help` for usage.')}`);
|
|
74
|
+
process.exit(2);
|
|
75
|
+
}
|
|
76
|
+
function version() {
|
|
77
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
78
|
+
return pkg.version;
|
|
79
|
+
}
|
|
80
|
+
function report(label, issues, paintLabel) {
|
|
81
|
+
for (const issue of issues) {
|
|
82
|
+
console.log(` ${paintLabel(label.padEnd(5))} ${issue.file ? `${issue.file} ` : ''}${issue.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
86
|
+
async function main() {
|
|
87
|
+
const args = parse(process.argv.slice(2));
|
|
88
|
+
const one = (name) => args.flags.get(name)?.at(-1);
|
|
89
|
+
const many = (name) => args.flags.get(name) ?? [];
|
|
90
|
+
const root = process.cwd();
|
|
91
|
+
if (args.bools.has('version'))
|
|
92
|
+
return console.log(version());
|
|
93
|
+
if (args.bools.has('help') || !args.command)
|
|
94
|
+
return console.log(HELP);
|
|
95
|
+
switch (args.command) {
|
|
96
|
+
case 'init': {
|
|
97
|
+
const result = init(root, { dir: one('dir'), title: one('title') });
|
|
98
|
+
for (const f of result.created)
|
|
99
|
+
console.log(` ${green('created')} ${f}`);
|
|
100
|
+
for (const f of result.kept)
|
|
101
|
+
console.log(` ${dim('kept')} ${f}`);
|
|
102
|
+
console.log(`\nNext: commit and push, then publish the space at ${SITE}/dashboard/new` +
|
|
103
|
+
`\nand pick "${result.docsDir}" as the docs folder.`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
case 'check': {
|
|
107
|
+
const result = check(root, one('dir'));
|
|
108
|
+
const strict = args.bools.has('strict');
|
|
109
|
+
console.log(`docugate check ${dim(`— ${result.docsDir}/, ${plural(result.pages, 'page')}`)}`);
|
|
110
|
+
report('error', result.errors, red);
|
|
111
|
+
report('warn', result.warnings, yellow);
|
|
112
|
+
const failed = result.errors.length + (strict ? result.warnings.length : 0);
|
|
113
|
+
if (!result.errors.length && !result.warnings.length)
|
|
114
|
+
console.log(green('\nNo problems.'));
|
|
115
|
+
else
|
|
116
|
+
console.log(`\n${plural(result.errors.length, 'error')}, ${plural(result.warnings.length, 'warning')}`);
|
|
117
|
+
if (failed)
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
case 'openapi': {
|
|
122
|
+
const generate = loadConfig(root).config.api?.generate ?? {};
|
|
123
|
+
const from = one('from') ?? generate.from;
|
|
124
|
+
if (!from)
|
|
125
|
+
fail('Say which spec to read: --from openapi.yaml, or api.generate.from in docugate.json.');
|
|
126
|
+
const output = one('out') ?? generate.output ?? 'openapi.json';
|
|
127
|
+
const result = await buildOpenApi(root, {
|
|
128
|
+
from,
|
|
129
|
+
output,
|
|
130
|
+
include: args.flags.has('include') ? many('include') : (generate.include ?? []),
|
|
131
|
+
exclude: args.flags.has('exclude') ? many('exclude') : (generate.exclude ?? []),
|
|
132
|
+
redact: args.flags.has('redact') ? many('redact') : (generate.redact ?? []),
|
|
133
|
+
});
|
|
134
|
+
const summary = `${plural(result.operations, 'operation')}` +
|
|
135
|
+
(result.droppedPaths.length ? `, ${plural(result.droppedPaths.length, 'path')} left out` : '') +
|
|
136
|
+
(result.redacted ? `, ${plural(result.redacted, 'field')} redacted` : '');
|
|
137
|
+
if (args.bools.has('check')) {
|
|
138
|
+
if (isCurrent(root, output, result.text)) {
|
|
139
|
+
console.log(`${green('up to date')} ${output} ${dim(`(${summary})`)}`);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
console.log(`${red('out of date')} ${output} does not match ${from}.`);
|
|
143
|
+
console.log(dim('Run `npx docugate openapi` and commit the result.'));
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
writeOpenApi(root, output, result.text);
|
|
149
|
+
console.log(`${green('wrote')} ${output} ${dim(`(${summary})`)}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
fail(`Unknown command "${args.command}".`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
main().catch((error) => {
|
|
157
|
+
console.error(`${red('error')} ${error.message}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
});
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { KNOWN_KEYS } from './rules.js';
|
|
4
|
+
const FILE = 'docugate.json';
|
|
5
|
+
/**
|
|
6
|
+
* Reads docugate.json from the repository root, where DocuGate looks for it.
|
|
7
|
+
*
|
|
8
|
+
* The server treats an unreadable file as if it were not there and falls back
|
|
9
|
+
* to `docs/`, silently. That is the right thing for a reader and the wrong
|
|
10
|
+
* thing for the person who wrote the file, so here it is an error.
|
|
11
|
+
*/
|
|
12
|
+
export function loadConfig(root) {
|
|
13
|
+
const path = join(root, FILE);
|
|
14
|
+
const errors = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
if (!existsSync(path)) {
|
|
17
|
+
return { found: false, config: {}, docsDir: 'docs', errors, warnings };
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
errors.push({
|
|
25
|
+
file: FILE,
|
|
26
|
+
message: `is not valid JSON (${error.message}). DocuGate ignores it and reads docs/.`,
|
|
27
|
+
});
|
|
28
|
+
return { found: true, config: {}, docsDir: 'docs', errors, warnings };
|
|
29
|
+
}
|
|
30
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
31
|
+
errors.push({ file: FILE, message: 'must be a JSON object.' });
|
|
32
|
+
return { found: true, config: {}, docsDir: 'docs', errors, warnings };
|
|
33
|
+
}
|
|
34
|
+
const config = parsed;
|
|
35
|
+
for (const key of Object.keys(config)) {
|
|
36
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
37
|
+
warnings.push({ file: FILE, message: `has an unknown key "${key}", which DocuGate ignores.` });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (config.docsDir !== undefined && typeof config.docsDir !== 'string') {
|
|
41
|
+
errors.push({ file: FILE, message: '"docsDir" must be a string, like "docs".' });
|
|
42
|
+
}
|
|
43
|
+
if (config.title !== undefined && typeof config.title !== 'string') {
|
|
44
|
+
errors.push({ file: FILE, message: '"title" must be a string.' });
|
|
45
|
+
}
|
|
46
|
+
if (config.sidebar !== undefined &&
|
|
47
|
+
(!Array.isArray(config.sidebar) || config.sidebar.some((s) => typeof s !== 'string'))) {
|
|
48
|
+
errors.push({ file: FILE, message: '"sidebar" must be a list of file and folder names.' });
|
|
49
|
+
}
|
|
50
|
+
const docsDir = typeof config.docsDir === 'string' ? config.docsDir.replace(/^\/|\/$/g, '') || 'docs' : 'docs';
|
|
51
|
+
return { found: true, config: config, docsDir, errors, warnings };
|
|
52
|
+
}
|
|
53
|
+
export const CONFIG_FILE = FILE;
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { CONFIG_FILE, loadConfig } from './config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Sets a repository up for DocuGate: a docugate.json, and a first page if the
|
|
6
|
+
* docs folder has none. Never overwrites — running it twice is harmless, and
|
|
7
|
+
* running it in a repository that already has docs only adds what is missing.
|
|
8
|
+
*/
|
|
9
|
+
export function init(root, opts = {}) {
|
|
10
|
+
const created = [];
|
|
11
|
+
const kept = [];
|
|
12
|
+
const existing = loadConfig(root);
|
|
13
|
+
const docsDir = (opts.dir ?? existing.docsDir).replace(/^\/|\/$/g, '') || 'docs';
|
|
14
|
+
const title = opts.title ?? basename(root);
|
|
15
|
+
if (existing.found) {
|
|
16
|
+
kept.push(CONFIG_FILE);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
const config = { docsDir, title };
|
|
20
|
+
writeFileSync(join(root, CONFIG_FILE), JSON.stringify(config, null, 2) + '\n');
|
|
21
|
+
created.push(CONFIG_FILE);
|
|
22
|
+
}
|
|
23
|
+
const base = join(root, docsDir);
|
|
24
|
+
const hasMarkdown = existsSync(base) && readdirSync(base, { recursive: true }).some((f) => /\.md$/i.test(String(f)));
|
|
25
|
+
if (hasMarkdown) {
|
|
26
|
+
kept.push(`${docsDir}/`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
mkdirSync(base, { recursive: true });
|
|
30
|
+
writeFileSync(join(base, 'index.md'), `# ${title}\n\nWelcome. This page is the landing page of your DocuGate space.\n\n` +
|
|
31
|
+
`Every \`.md\` file in \`${docsDir}/\` becomes a page, and every folder becomes a\n` +
|
|
32
|
+
`section in the sidebar. The first \`# heading\` in a file is its title.\n`);
|
|
33
|
+
created.push(`${docsDir}/index.md`);
|
|
34
|
+
}
|
|
35
|
+
return { created, kept, docsDir };
|
|
36
|
+
}
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
const METHODS = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']);
|
|
5
|
+
/**
|
|
6
|
+
* `*` matches within one path segment and `**` across them, so
|
|
7
|
+
* `/internal/**` drops everything under /internal and `/v1/*` keeps only
|
|
8
|
+
* direct children of /v1.
|
|
9
|
+
*/
|
|
10
|
+
export function globToRegExp(glob) {
|
|
11
|
+
let pattern = '';
|
|
12
|
+
for (let i = 0; i < glob.length; i++) {
|
|
13
|
+
const c = glob[i];
|
|
14
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
15
|
+
pattern += '.*';
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
else if (c === '*') {
|
|
19
|
+
pattern += '[^/]*';
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
pattern += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return new RegExp(`^${pattern}$`);
|
|
26
|
+
}
|
|
27
|
+
async function readSource(root, from) {
|
|
28
|
+
if (/^https?:\/\//i.test(from)) {
|
|
29
|
+
const res = await fetch(from, { signal: AbortSignal.timeout(20_000) });
|
|
30
|
+
if (!res.ok)
|
|
31
|
+
throw new Error(`${from} answered ${res.status}.`);
|
|
32
|
+
const type = res.headers.get('content-type') ?? '';
|
|
33
|
+
return { text: await res.text(), yaml: /ya?ml/i.test(type) || /\.ya?ml(\?|$)/i.test(from) };
|
|
34
|
+
}
|
|
35
|
+
const path = resolve(root, from);
|
|
36
|
+
if (!existsSync(path))
|
|
37
|
+
throw new Error(`${from} does not exist.`);
|
|
38
|
+
return { text: readFileSync(path, 'utf8'), yaml: /\.ya?ml$/i.test(from) };
|
|
39
|
+
}
|
|
40
|
+
/** Every `$ref` in the document, so external ones can be refused. */
|
|
41
|
+
function refsIn(value, out = []) {
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
for (const v of value)
|
|
44
|
+
refsIn(v, out);
|
|
45
|
+
else if (value && typeof value === 'object') {
|
|
46
|
+
for (const [k, v] of Object.entries(value)) {
|
|
47
|
+
if (k === '$ref' && typeof v === 'string')
|
|
48
|
+
out.push(v);
|
|
49
|
+
else
|
|
50
|
+
refsIn(v, out);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Removes redacted property names from schemas (and their `required` lists)
|
|
57
|
+
* and from examples. Returns how many were removed.
|
|
58
|
+
*/
|
|
59
|
+
function redact(value, names, inExample = false) {
|
|
60
|
+
if (!names.size || !value || typeof value !== 'object')
|
|
61
|
+
return 0;
|
|
62
|
+
let removed = 0;
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
for (const v of value)
|
|
65
|
+
removed += redact(v, names, inExample);
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
const obj = value;
|
|
69
|
+
if (inExample) {
|
|
70
|
+
for (const key of Object.keys(obj)) {
|
|
71
|
+
if (names.has(key)) {
|
|
72
|
+
delete obj[key];
|
|
73
|
+
removed++;
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
removed += redact(obj[key], names, true);
|
|
77
|
+
}
|
|
78
|
+
return removed;
|
|
79
|
+
}
|
|
80
|
+
const props = obj.properties;
|
|
81
|
+
if (props && typeof props === 'object' && !Array.isArray(props)) {
|
|
82
|
+
for (const key of Object.keys(props)) {
|
|
83
|
+
if (names.has(key)) {
|
|
84
|
+
delete props[key];
|
|
85
|
+
removed++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(obj.required))
|
|
89
|
+
obj.required = obj.required.filter((r) => !names.has(String(r)));
|
|
90
|
+
}
|
|
91
|
+
for (const [key, child] of Object.entries(obj)) {
|
|
92
|
+
removed += redact(child, names, key === 'example' || key === 'examples');
|
|
93
|
+
}
|
|
94
|
+
return removed;
|
|
95
|
+
}
|
|
96
|
+
/** Keys sorted at every level, so the same spec always writes the same bytes. */
|
|
97
|
+
function sortKeys(value) {
|
|
98
|
+
if (Array.isArray(value))
|
|
99
|
+
return value.map(sortKeys);
|
|
100
|
+
if (!value || typeof value !== 'object')
|
|
101
|
+
return value;
|
|
102
|
+
return Object.fromEntries(Object.keys(value)
|
|
103
|
+
.sort()
|
|
104
|
+
.map((k) => [k, sortKeys(value[k])]));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Turns an existing OpenAPI 3 spec into the file DocuGate will read from the
|
|
108
|
+
* repository: only the paths meant to be public, without redacted fields, in
|
|
109
|
+
* a stable order so it diffs cleanly in review.
|
|
110
|
+
*
|
|
111
|
+
* It does not infer a spec from code. Frameworks that know their own schemas
|
|
112
|
+
* (NestJS, Fastify, FastAPI, Hono with zod-openapi) already emit one; point
|
|
113
|
+
* `from` at that file or at the URL the running server serves it on.
|
|
114
|
+
*/
|
|
115
|
+
export async function buildOpenApi(root, opts) {
|
|
116
|
+
const source = await readSource(root, opts.from);
|
|
117
|
+
let doc;
|
|
118
|
+
try {
|
|
119
|
+
doc = source.yaml ? parseYaml(source.text, { maxAliasCount: 100 }) : JSON.parse(source.text);
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// A .json URL that actually serves YAML, or the other way round.
|
|
123
|
+
try {
|
|
124
|
+
doc = source.yaml ? JSON.parse(source.text) : parseYaml(source.text, { maxAliasCount: 100 });
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Error(`${opts.from} is neither valid JSON nor YAML: ${error.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
131
|
+
throw new Error(`${opts.from} is not an OpenAPI document.`);
|
|
132
|
+
}
|
|
133
|
+
const spec = doc;
|
|
134
|
+
if (typeof spec.swagger === 'string') {
|
|
135
|
+
throw new Error(`${opts.from} is Swagger ${spec.swagger}. DocuGate reads OpenAPI 3.0 and 3.1; convert it first, for example with swagger2openapi.`);
|
|
136
|
+
}
|
|
137
|
+
if (typeof spec.openapi !== 'string' || !/^3\.[01]\./.test(spec.openapi)) {
|
|
138
|
+
throw new Error(`${opts.from} has no "openapi": "3.x" field, so it is not an OpenAPI 3 document.`);
|
|
139
|
+
}
|
|
140
|
+
if (!spec.paths || typeof spec.paths !== 'object') {
|
|
141
|
+
throw new Error(`${opts.from} has no "paths".`);
|
|
142
|
+
}
|
|
143
|
+
const external = refsIn(spec).filter((ref) => !ref.startsWith('#/'));
|
|
144
|
+
if (external.length) {
|
|
145
|
+
throw new Error(`${opts.from} references other files (${[...new Set(external)].slice(0, 3).join(', ')}). ` +
|
|
146
|
+
'Bundle it into one document first, for example with `npx @redocly/cli bundle`.');
|
|
147
|
+
}
|
|
148
|
+
const include = opts.include.map(globToRegExp);
|
|
149
|
+
const exclude = opts.exclude.map(globToRegExp);
|
|
150
|
+
const paths = spec.paths;
|
|
151
|
+
const droppedPaths = [];
|
|
152
|
+
for (const path of Object.keys(paths)) {
|
|
153
|
+
const kept = (!include.length || include.some((r) => r.test(path))) && !exclude.some((r) => r.test(path));
|
|
154
|
+
if (!kept) {
|
|
155
|
+
delete paths[path];
|
|
156
|
+
droppedPaths.push(path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const redacted = redact(spec, new Set(opts.redact));
|
|
160
|
+
let operations = 0;
|
|
161
|
+
for (const item of Object.values(paths)) {
|
|
162
|
+
for (const method of Object.keys(item ?? {}))
|
|
163
|
+
if (METHODS.has(method))
|
|
164
|
+
operations++;
|
|
165
|
+
}
|
|
166
|
+
const text = JSON.stringify(sortKeys(spec), null, 2) + '\n';
|
|
167
|
+
return { spec, text, operations, droppedPaths, redacted };
|
|
168
|
+
}
|
|
169
|
+
/** Whether the committed file already matches what would be written. */
|
|
170
|
+
export function isCurrent(root, output, text) {
|
|
171
|
+
const path = join(root, output);
|
|
172
|
+
return existsSync(path) && readFileSync(path, 'utf8').replace(/\r\n/g, '\n') === text;
|
|
173
|
+
}
|
|
174
|
+
export function writeOpenApi(root, output, text) {
|
|
175
|
+
writeFileSync(join(root, output), text);
|
|
176
|
+
}
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How DocuGate reads a repository, restated for the command line.
|
|
3
|
+
*
|
|
4
|
+
* These mirror src/lib/docs.ts and server/github.ts in the DocuGate app. They
|
|
5
|
+
* are copied rather than imported because this package is published on its
|
|
6
|
+
* own; if the app changes how it reads a repository, change them here too, or
|
|
7
|
+
* `docugate check` will pass docs the server then renders differently.
|
|
8
|
+
*/
|
|
9
|
+
/** The server refuses a docs folder with more markdown files than this. */
|
|
10
|
+
export const MAX_FILES = 300;
|
|
11
|
+
/** Turn `guides/deploying.md` into `guides/deploying`, and `index.md` into ''. */
|
|
12
|
+
export function pathToSlug(path) {
|
|
13
|
+
const withoutExt = path.replace(/\.md$/i, '');
|
|
14
|
+
return withoutExt === 'index' ? '' : withoutExt.replace(/\/index$/i, '');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a relative markdown link, as authored inside `fromPath`, to the slug
|
|
18
|
+
* DocuGate routes it under. Null for links this does not apply to: external
|
|
19
|
+
* URLs, in-page anchors and anything that is not a markdown file.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveRelativeLink(href, fromPath) {
|
|
22
|
+
if (!href || href.startsWith('#'))
|
|
23
|
+
return null;
|
|
24
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//'))
|
|
25
|
+
return null;
|
|
26
|
+
const [rawPath] = href.split('#');
|
|
27
|
+
if (!/\.mdx?$/i.test(rawPath))
|
|
28
|
+
return null;
|
|
29
|
+
const dir = fromPath.includes('/') ? fromPath.slice(0, fromPath.lastIndexOf('/')) : '';
|
|
30
|
+
const segments = rawPath.startsWith('/') ? [] : dir.split('/').filter(Boolean);
|
|
31
|
+
for (const part of rawPath.split('/')) {
|
|
32
|
+
if (part === '' || part === '.')
|
|
33
|
+
continue;
|
|
34
|
+
if (part === '..') {
|
|
35
|
+
// Climbing above the docs folder leaves it, and DocuGate only serves
|
|
36
|
+
// what is inside it.
|
|
37
|
+
if (!segments.length)
|
|
38
|
+
return '\0outside';
|
|
39
|
+
segments.pop();
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
segments.push(part);
|
|
43
|
+
}
|
|
44
|
+
return pathToSlug(segments.join('/'));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Where a docs folder has no index.md, its top-level README becomes the
|
|
48
|
+
* landing page — the same file GitHub shows first.
|
|
49
|
+
*/
|
|
50
|
+
export function landingPath(paths) {
|
|
51
|
+
const index = paths.find((p) => pathToSlug(p) === '');
|
|
52
|
+
if (index)
|
|
53
|
+
return index;
|
|
54
|
+
return paths.find((p) => !p.includes('/') && /^readme\.md$/i.test(p)) ?? null;
|
|
55
|
+
}
|
|
56
|
+
/** The page title DocuGate shows: the first `# heading`, if there is one. */
|
|
57
|
+
export function firstHeading(content) {
|
|
58
|
+
const heading = content.match(/^#\s+(.+)$/m);
|
|
59
|
+
return heading ? heading[1].trim() : null;
|
|
60
|
+
}
|
|
61
|
+
export const KNOWN_KEYS = new Set(['$schema', 'docsDir', 'title', 'sidebar', 'api']);
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "docugate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line tools for DocuGate: set up, check and prepare the documentation DocuGate reads straight from your repository.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"docugate": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18.17"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"test": "npm run build && node --test \"test/*.test.mjs\"",
|
|
20
|
+
"prepublishOnly": "npm test"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"docugate",
|
|
24
|
+
"documentation",
|
|
25
|
+
"docs",
|
|
26
|
+
"markdown",
|
|
27
|
+
"openapi",
|
|
28
|
+
"github",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"homepage": "https://www.trydocugate.site",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"yaml": "^2.9.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^24.13.3",
|
|
38
|
+
"typescript": "~6.0.2"
|
|
39
|
+
}
|
|
40
|
+
}
|