@readme/cli 0.0.29 → 0.0.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/package.json +8 -4
- package/src/cli.js +2 -2
- package/src/commands/import-oas.test.js +277 -0
- package/src/commands/import.classify.test.js +172 -0
- package/src/commands/import.js +793 -92
- package/src/commands/import.landing.test.js +217 -0
- package/src/commands/import.staging.test.js +158 -0
- package/src/commands/import.test.js +246 -0
- package/src/commands/lint.js +4 -4
- package/src/commands/oas-sync.js +797 -59
- package/src/commands/oas-validate.js +23 -0
- package/src/gha-pre.js +29 -0
- package/src/gha.js +104 -0
- package/src/utils/gha-output.js +32 -0
- package/src/utils/lint.js +30 -15
- package/src/utils/llms.test.js +45 -0
- package/src/utils/reporter.js +2 -1
- package/src/utils/slug-for-entry.js +16 -0
- package/src/validators/components.js +10 -7
- package/src/validators/duplicates.js +2 -4
- package/src/validators/links.js +2 -8
- package/src/validators/oas-reference.js +35 -9
package/README.md
CHANGED
|
@@ -50,6 +50,33 @@ Starts a local dev server so you can preview your docs as you write them. Watche
|
|
|
50
50
|
npx @readme/cli dev
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
## GitHub Action
|
|
54
|
+
|
|
55
|
+
This repo also publishes itself as a GitHub Action, so a workflow can run `lint`, `oas:validate`, or `oas:sync` directly, pinned to a version, without an `npx` install step on every run:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
- uses: readmeio/cli@v1
|
|
59
|
+
with:
|
|
60
|
+
readme: oas:sync
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The `readme` input is the same command you'd run on the CLI, e.g. `lint`, `oas:validate --dereference`, or `oas:sync`. Each command exits non-zero on a real problem, same as running it locally, and publishes its results as step outputs — `has-errors`, `skipped-count`, `total-errors`, and more (see [`action.yml`](./action.yml) for the full list) — so a later step can check them directly instead of parsing log output:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- name: Sync reference pages from OpenAPI specs
|
|
67
|
+
id: sync
|
|
68
|
+
uses: readmeio/cli@v1
|
|
69
|
+
with:
|
|
70
|
+
readme: oas:sync
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- if: steps.sync.outputs.skipped-count != '0'
|
|
75
|
+
run: echo "::error::oas:sync skipped a page — see the sync step above."
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Note: the first run of a workflow using this Action installs one extra dependency (`@readme/markdown`) behind the scenes before your command runs — this is expected, and only adds a few seconds.
|
|
79
|
+
|
|
53
80
|
## Feedback
|
|
54
81
|
|
|
55
82
|
If something doesn't work right, or if you have suggestions, please [open an issue](https://github.com/readmeio/cli/issues). We're actively building this and want to hear from you.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@readme/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.31",
|
|
4
4
|
"description": "The ReadMe CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,10 +25,11 @@
|
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
27
|
"start": "node bin/readme.js",
|
|
28
|
-
"test": "node --test src/**/*.test.js test/*.test.js"
|
|
28
|
+
"test": "node --test src/**/*.test.js test/*.test.js",
|
|
29
|
+
"build:gha": "ncc build src/gha.js -o dist-gha --external @readme/markdown"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
32
|
+
"node": ">=22"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
34
35
|
"readme",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@anthropic-ai/claude-agent-sdk": "^0.3.207",
|
|
45
46
|
"@readme/markdown": "^13.3.0",
|
|
46
|
-
"@readmeio/git-format": "readmeio/git-format",
|
|
47
|
+
"@readmeio/git-format": "git+https://github.com/readmeio/git-format.git",
|
|
47
48
|
"ajv": "^8.18.0",
|
|
48
49
|
"chalk": "^5.4.1",
|
|
49
50
|
"cli-spinners": "^3.4.0",
|
|
@@ -56,5 +57,8 @@
|
|
|
56
57
|
"react": "^18.3.1",
|
|
57
58
|
"react-dom": "^18.3.1",
|
|
58
59
|
"update-notifier": "^7.3.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@vercel/ncc": "^0.45.0"
|
|
59
63
|
}
|
|
60
64
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import updateNotifier from 'update-notifier';
|
|
@@ -56,7 +56,7 @@ export async function main() {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Auto-discover and register every command in src/commands/
|
|
59
|
-
const commandsDir = path.join(path.dirname(
|
|
59
|
+
const commandsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'commands');
|
|
60
60
|
const files = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.js') && !f.endsWith('.test.js'));
|
|
61
61
|
|
|
62
62
|
// Load all modules, then sort by order (default 0)
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { afterEach, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { __test__ } from './import.js'
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
sniffOasContent,
|
|
10
|
+
buildWellKnownOasProbeUrls,
|
|
11
|
+
probeWellKnownOasPaths,
|
|
12
|
+
extractOasUrlsFromHtml,
|
|
13
|
+
extractMintlifyOasCandidates,
|
|
14
|
+
sitemapUrlsToKnownUrls,
|
|
15
|
+
extractOasSpecUrlsFromParsed,
|
|
16
|
+
downloadOasSpecs,
|
|
17
|
+
} = __test__
|
|
18
|
+
|
|
19
|
+
const originalFetch = globalThis.fetch
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
globalThis.fetch = originalFetch
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const JSON_SPEC = JSON.stringify({ openapi: '3.0.0', info: { title: 'T', version: '1' }, paths: {} })
|
|
26
|
+
const YAML_SPEC = 'openapi: 3.0.0\ninfo:\n title: T\n version: "1"\npaths: {}\n'
|
|
27
|
+
const HTML_SHELL = '<!DOCTYPE html><html><head><title>Docs</title></head><body>hi</body></html>'
|
|
28
|
+
|
|
29
|
+
function toArrayBuffer(str) {
|
|
30
|
+
const buf = Buffer.from(str)
|
|
31
|
+
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mockProbeFetch(routes) {
|
|
35
|
+
const seen = []
|
|
36
|
+
globalThis.fetch = async (url) => {
|
|
37
|
+
const href = String(url)
|
|
38
|
+
seen.push(href)
|
|
39
|
+
const entry = routes[href] || routes['*']
|
|
40
|
+
if (!entry) return { ok: false, status: 404, url: href, arrayBuffer: async () => new ArrayBuffer(0) }
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
status: 200,
|
|
44
|
+
url: entry.finalUrl || href,
|
|
45
|
+
arrayBuffer: async () => toArrayBuffer(entry.body),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return seen
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('sniffOasContent accepts JSON and YAML specs, rejects HTML and random content', () => {
|
|
52
|
+
assert.equal(sniffOasContent(JSON_SPEC), true)
|
|
53
|
+
assert.equal(sniffOasContent(JSON.stringify({ swagger: '2.0', paths: {} })), true)
|
|
54
|
+
assert.equal(sniffOasContent(YAML_SPEC), true)
|
|
55
|
+
assert.equal(sniffOasContent('swagger: "2.0"\npaths: {}\n'), true)
|
|
56
|
+
assert.equal(sniffOasContent("'openapi': 3.1.0\n"), true)
|
|
57
|
+
assert.equal(sniffOasContent(HTML_SHELL), false)
|
|
58
|
+
assert.equal(sniffOasContent(' <svg></svg>'), false)
|
|
59
|
+
assert.equal(sniffOasContent(JSON.stringify({ name: 'pkg', version: '1.0.0' })), false)
|
|
60
|
+
assert.equal(sniffOasContent(''), false)
|
|
61
|
+
assert.equal(sniffOasContent('just some prose about openapi stuff'), false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('buildWellKnownOasProbeUrls covers both origins and path-kind docs bases without duplicates', () => {
|
|
65
|
+
const docsBase = { kind: 'path', url: new URL('https://foo.com/docs/') }
|
|
66
|
+
const urls = buildWellKnownOasProbeUrls(new URL('https://foo.com/docs/'), docsBase, 'https://foo.com')
|
|
67
|
+
assert(urls.includes('https://foo.com/openapi.json'))
|
|
68
|
+
assert(urls.includes('https://foo.com/v3/api-docs'))
|
|
69
|
+
assert(urls.includes('https://foo.com/docs/openapi.yaml'))
|
|
70
|
+
assert(!urls.includes('https://foo.com/docs/openapi.yml'))
|
|
71
|
+
assert.equal(new Set(urls).size, urls.length)
|
|
72
|
+
|
|
73
|
+
const subdomain = buildWellKnownOasProbeUrls(
|
|
74
|
+
new URL('https://docs.foo.com/'),
|
|
75
|
+
{ kind: 'subdomain', url: new URL('https://docs.foo.com/') },
|
|
76
|
+
'https://foo.com',
|
|
77
|
+
)
|
|
78
|
+
assert(subdomain.includes('https://docs.foo.com/openapi.json'))
|
|
79
|
+
assert(subdomain.includes('https://foo.com/openapi.json'))
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('probeWellKnownOasPaths returns zero hits when every path 200s with HTML', async () => {
|
|
83
|
+
const seen = mockProbeFetch({ '*': { body: HTML_SHELL } })
|
|
84
|
+
const hits = await probeWellKnownOasPaths(new URL('https://foo.com/'), null, 'https://foo.com')
|
|
85
|
+
assert.equal(hits.length, 0)
|
|
86
|
+
assert(seen.length <= 27)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('probeWellKnownOasPaths returns a hit with prefetched body for a real YAML spec', async () => {
|
|
90
|
+
mockProbeFetch({ 'https://foo.com/openapi.yaml': { body: YAML_SPEC } })
|
|
91
|
+
const hits = await probeWellKnownOasPaths(new URL('https://foo.com/'), null, 'https://foo.com')
|
|
92
|
+
assert.equal(hits.length, 1)
|
|
93
|
+
assert.equal(hits[0].url, 'https://foo.com/openapi.yaml')
|
|
94
|
+
assert.equal(hits[0].source, 'probe')
|
|
95
|
+
assert(Buffer.isBuffer(hits[0].body))
|
|
96
|
+
assert.equal(hits[0].body.toString('utf8'), YAML_SPEC)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('probeWellKnownOasPaths skips hits that redirect off the probe origins', async () => {
|
|
100
|
+
mockProbeFetch({
|
|
101
|
+
'https://foo.com/openapi.json': { body: JSON_SPEC, finalUrl: 'https://other.example.com/openapi.json' },
|
|
102
|
+
'https://foo.com/swagger.json': { body: JSON_SPEC },
|
|
103
|
+
})
|
|
104
|
+
const hits = await probeWellKnownOasPaths(new URL('https://foo.com/'), null, 'https://foo.com')
|
|
105
|
+
assert.deepEqual(hits.map((h) => h.url), ['https://foo.com/swagger.json'])
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('extractMintlifyOasCandidates handles string, string[], {source}, and nested nav nodes', () => {
|
|
109
|
+
const config = {
|
|
110
|
+
openapi: '/specs/root.json',
|
|
111
|
+
navigation: {
|
|
112
|
+
tabs: [
|
|
113
|
+
{
|
|
114
|
+
tab: 'API',
|
|
115
|
+
openapi: ['https://cdn.foo.com/a.yaml', '/specs/b.json'],
|
|
116
|
+
groups: [{ group: 'Endpoints', openapi: { source: 'specs/c.json' }, pages: ['api/list'] }],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
const items = extractMintlifyOasCandidates(config, 'https://foo.com')
|
|
122
|
+
const urls = items.map((i) => i.url)
|
|
123
|
+
assert.deepEqual(urls, [
|
|
124
|
+
'https://foo.com/specs/root.json',
|
|
125
|
+
'https://cdn.foo.com/a.yaml',
|
|
126
|
+
'https://foo.com/specs/b.json',
|
|
127
|
+
'https://foo.com/specs/c.json',
|
|
128
|
+
])
|
|
129
|
+
assert(items.every((i) => i.source === 'mintlify' && i.text === null))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('extractMintlifyOasCandidates ignores non-url values and dedupes', () => {
|
|
133
|
+
const config = {
|
|
134
|
+
navigation: {
|
|
135
|
+
anchors: [{ openapi: '/spec.json' }, { openapi: '/spec.json' }, { openapi: { notSource: true } }],
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
const items = extractMintlifyOasCandidates(config, 'https://foo.com')
|
|
139
|
+
assert.deepEqual(items.map((i) => i.url), ['https://foo.com/spec.json'])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('extractOasUrlsFromHtml finds Swagger UI single url and urls arrays', () => {
|
|
143
|
+
const single = `<script>SwaggerUIBundle({ dom_id: '#swagger', url: "/openapi.json" })</script>`
|
|
144
|
+
assert.deepEqual(extractOasUrlsFromHtml(single, 'https://foo.com/reference'), ['https://foo.com/openapi.json'])
|
|
145
|
+
|
|
146
|
+
const multi = `<script>SwaggerUI({ urls: [{ url: "https://foo.com/v1.json", name: "v1" }, { url: '/v2.yaml', name: "v2" }] })</script>`
|
|
147
|
+
assert.deepEqual(extractOasUrlsFromHtml(multi, 'https://foo.com/reference'), [
|
|
148
|
+
'https://foo.com/v1.json',
|
|
149
|
+
'https://foo.com/v2.yaml',
|
|
150
|
+
])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('extractOasUrlsFromHtml finds redoc in every quoting variant plus Redoc.init', () => {
|
|
154
|
+
for (const html of [
|
|
155
|
+
'<redoc spec-url="/spec.yaml"></redoc>',
|
|
156
|
+
"<redoc spec-url='/spec.yaml'></redoc>",
|
|
157
|
+
'<redoc lazy-rendering spec-url=/spec.yaml></redoc>',
|
|
158
|
+
]) {
|
|
159
|
+
assert.deepEqual(extractOasUrlsFromHtml(html, 'https://foo.com/api'), ['https://foo.com/spec.yaml'])
|
|
160
|
+
}
|
|
161
|
+
const init = `<script>Redoc.init("https://foo.com/openapi.json", {}, document.getElementById('redoc'))</script>`
|
|
162
|
+
assert.deepEqual(extractOasUrlsFromHtml(init, 'https://foo.com/api'), ['https://foo.com/openapi.json'])
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('extractOasUrlsFromHtml finds Scalar script tags in both attribute orders', () => {
|
|
166
|
+
const a = '<script id="api-reference" data-url="/openapi.json"></script>'
|
|
167
|
+
const b = "<script data-url='/openapi.json' id='api-reference'></script>"
|
|
168
|
+
assert.deepEqual(extractOasUrlsFromHtml(a, 'https://foo.com/'), ['https://foo.com/openapi.json'])
|
|
169
|
+
assert.deepEqual(extractOasUrlsFromHtml(b, 'https://foo.com/'), ['https://foo.com/openapi.json'])
|
|
170
|
+
const noId = '<script data-url="/openapi.json"></script>'
|
|
171
|
+
assert.deepEqual(extractOasUrlsFromHtml(noId, 'https://foo.com/'), [])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('extractOasUrlsFromHtml finds Stoplight apiDescriptionUrl and skips marketing pages', () => {
|
|
175
|
+
const stoplight = '<elements-api apiDescriptionUrl="https://foo.com/api.yaml" router="hash" />'
|
|
176
|
+
assert.deepEqual(extractOasUrlsFromHtml(stoplight, 'https://foo.com/'), ['https://foo.com/api.yaml'])
|
|
177
|
+
const marketing = '<html><body><a href="/pricing">Pricing</a><script>analytics.track("url: nope")</script></body></html>'
|
|
178
|
+
assert.deepEqual(extractOasUrlsFromHtml(marketing, 'https://foo.com/'), [])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('sitemapUrlsToKnownUrls routes spec-shaped locs to the sink instead of known pages', () => {
|
|
182
|
+
const sink = []
|
|
183
|
+
const known = sitemapUrlsToKnownUrls(
|
|
184
|
+
[
|
|
185
|
+
'https://foo.com/docs/getting-started',
|
|
186
|
+
'https://foo.com/openapi.json',
|
|
187
|
+
'https://foo.com/specs/api.yaml',
|
|
188
|
+
'https://foo.com/specs/api.yml',
|
|
189
|
+
],
|
|
190
|
+
sink,
|
|
191
|
+
)
|
|
192
|
+
assert.deepEqual(known.map((k) => k.url), ['https://foo.com/docs/getting-started'])
|
|
193
|
+
assert.deepEqual(
|
|
194
|
+
sink.map((s) => s.url),
|
|
195
|
+
['https://foo.com/openapi.json', 'https://foo.com/specs/api.yaml', 'https://foo.com/specs/api.yml'],
|
|
196
|
+
)
|
|
197
|
+
assert(sink.every((s) => s.source === 'sitemap'))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('extractOasSpecUrlsFromParsed captures .yaml links too and prunes emptied sections', () => {
|
|
201
|
+
const parsed = {
|
|
202
|
+
sections: [
|
|
203
|
+
{ title: 'Specs', items: [{ text: 'OpenAPI', url: 'https://foo.com/openapi.yaml' }] },
|
|
204
|
+
{
|
|
205
|
+
title: 'Guides',
|
|
206
|
+
items: [
|
|
207
|
+
{ text: 'Intro', url: 'https://foo.com/docs/intro' },
|
|
208
|
+
{ text: 'Spec', url: 'https://foo.com/spec.json' },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
}
|
|
213
|
+
const captured = extractOasSpecUrlsFromParsed(parsed)
|
|
214
|
+
assert.deepEqual(captured, [
|
|
215
|
+
{ url: 'https://foo.com/openapi.yaml', text: 'OpenAPI', source: 'llms' },
|
|
216
|
+
{ url: 'https://foo.com/spec.json', text: 'Spec', source: 'llms' },
|
|
217
|
+
])
|
|
218
|
+
assert.equal(parsed.sections.length, 1)
|
|
219
|
+
assert.deepEqual(parsed.sections[0].items.map((i) => i.url), ['https://foo.com/docs/intro'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('downloadOasSpecs writes prefetched bodies without fetching and preserves extensions', async () => {
|
|
223
|
+
const seen = []
|
|
224
|
+
globalThis.fetch = async (url) => {
|
|
225
|
+
seen.push(String(url))
|
|
226
|
+
return { ok: true, status: 200, url: String(url), arrayBuffer: async () => toArrayBuffer(YAML_SPEC) }
|
|
227
|
+
}
|
|
228
|
+
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'import-oas-test-'))
|
|
229
|
+
const written = await downloadOasSpecs(
|
|
230
|
+
[
|
|
231
|
+
{ url: 'https://foo.com/openapi.json', text: null, source: 'probe', body: Buffer.from(JSON_SPEC) },
|
|
232
|
+
{ url: 'https://foo.com/specs/api.yaml', text: null, source: 'llms' },
|
|
233
|
+
],
|
|
234
|
+
stagingDir,
|
|
235
|
+
)
|
|
236
|
+
assert.equal(written, 2)
|
|
237
|
+
assert.deepEqual(seen, ['https://foo.com/specs/api.yaml'])
|
|
238
|
+
const files = fs.readdirSync(path.join(stagingDir, 'oas')).sort()
|
|
239
|
+
assert.deepEqual(files, ['api.yaml', 'openapi.json'])
|
|
240
|
+
assert.equal(fs.readFileSync(path.join(stagingDir, 'oas', 'openapi.json'), 'utf8'), JSON_SPEC)
|
|
241
|
+
assert.equal(fs.readFileSync(path.join(stagingDir, 'oas', 'api.yaml'), 'utf8'), YAML_SPEC)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('downloadOasSpecs sniffs an extension for extensionless URLs', async () => {
|
|
245
|
+
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'import-oas-test-'))
|
|
246
|
+
const written = await downloadOasSpecs(
|
|
247
|
+
[
|
|
248
|
+
{ url: 'https://foo.com/v3/api-docs', text: null, source: 'probe', body: Buffer.from(JSON_SPEC) },
|
|
249
|
+
{ url: 'https://foo.com/spec/latest', text: null, source: 'probe', body: Buffer.from(YAML_SPEC) },
|
|
250
|
+
],
|
|
251
|
+
stagingDir,
|
|
252
|
+
)
|
|
253
|
+
assert.equal(written, 2)
|
|
254
|
+
const files = fs.readdirSync(path.join(stagingDir, 'oas')).sort()
|
|
255
|
+
assert.deepEqual(files, ['api-docs.json', 'latest.yaml'])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('downloadOasSpecs skips HTML bodies and suffixes filename collisions', async () => {
|
|
259
|
+
globalThis.fetch = async (url) => ({
|
|
260
|
+
ok: true,
|
|
261
|
+
status: 200,
|
|
262
|
+
url: String(url),
|
|
263
|
+
arrayBuffer: async () => toArrayBuffer(HTML_SHELL),
|
|
264
|
+
})
|
|
265
|
+
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'import-oas-test-'))
|
|
266
|
+
const written = await downloadOasSpecs(
|
|
267
|
+
[
|
|
268
|
+
{ url: 'https://foo.com/a/openapi.json', text: null, source: 'probe', body: Buffer.from(JSON_SPEC) },
|
|
269
|
+
{ url: 'https://foo.com/b/openapi.json', text: null, source: 'probe', body: Buffer.from(JSON_SPEC) },
|
|
270
|
+
{ url: 'https://foo.com/shell.json', text: null, source: 'html' },
|
|
271
|
+
],
|
|
272
|
+
stagingDir,
|
|
273
|
+
)
|
|
274
|
+
assert.equal(written, 2)
|
|
275
|
+
const files = fs.readdirSync(path.join(stagingDir, 'oas')).sort()
|
|
276
|
+
assert.deepEqual(files, ['openapi-2.json', 'openapi.json'])
|
|
277
|
+
})
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { __test__ } from './import.js'
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
isDiscoverableLink,
|
|
7
|
+
isAuthChromeUrl,
|
|
8
|
+
isDocsUnderApiPath,
|
|
9
|
+
filterUrlPagesTree,
|
|
10
|
+
urlIsApiReference,
|
|
11
|
+
urlIsChangelog,
|
|
12
|
+
reclassifyReferencePages,
|
|
13
|
+
extractChangelogFromSections,
|
|
14
|
+
decodeEntities,
|
|
15
|
+
stripTags,
|
|
16
|
+
} = __test__
|
|
17
|
+
|
|
18
|
+
test('isDocsUnderApiPath treats /api/docs guides as docs, not reference', () => {
|
|
19
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/docs/guides/intro'), true)
|
|
20
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/docs/pricing'), true)
|
|
21
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/docs'), true)
|
|
22
|
+
assert.equal(isDocsUnderApiPath('https://example.com/API/DOC/guides'), true)
|
|
23
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/docs/api'), false)
|
|
24
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/docs/reference'), false)
|
|
25
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/docs/api-reference'), false)
|
|
26
|
+
assert.equal(isDocsUnderApiPath('https://example.com/docs/guides'), false)
|
|
27
|
+
assert.equal(isDocsUnderApiPath('https://example.com/api/reference/pets'), false)
|
|
28
|
+
assert.equal(isDocsUnderApiPath('not a url'), false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('filterUrlPagesTree keeps matching URL pages and empty parents with survivors', () => {
|
|
32
|
+
const tree = [
|
|
33
|
+
{ title: 'Guides', url: 'https://example.com/api/docs/guides' },
|
|
34
|
+
{ title: 'Pets', url: 'https://example.com/api/reference/pets' },
|
|
35
|
+
{
|
|
36
|
+
title: 'Mixed',
|
|
37
|
+
pages: [
|
|
38
|
+
{ title: 'Pricing', url: 'https://example.com/api/docs/pricing' },
|
|
39
|
+
{ title: 'Endpoint', url: 'https://example.com/api/reference/create' },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{ title: 'Empty leftover', pages: [{ title: 'Gone', url: 'https://example.com/api/reference/gone' }] },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const kept = filterUrlPagesTree(tree, isDocsUnderApiPath)
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
kept.map((p) => p.title),
|
|
48
|
+
['Guides', 'Mixed'],
|
|
49
|
+
)
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
kept[1].pages.map((p) => p.title),
|
|
52
|
+
['Pricing'],
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('urlIsApiReference matches api-reference / endpoints segments only', () => {
|
|
57
|
+
assert.equal(urlIsApiReference('https://example.com/api-reference/pets'), true)
|
|
58
|
+
assert.equal(urlIsApiReference('https://example.com/api_reference/pets'), true)
|
|
59
|
+
assert.equal(urlIsApiReference('https://example.com/endpoints/create'), true)
|
|
60
|
+
assert.equal(urlIsApiReference('https://example.com/endpoint/create'), true)
|
|
61
|
+
assert.equal(urlIsApiReference('https://example.com/api/docs/guides'), false)
|
|
62
|
+
assert.equal(urlIsApiReference('https://example.com/docs/api'), false)
|
|
63
|
+
assert.equal(urlIsApiReference('://bad'), false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('urlIsChangelog matches changelog-ish slugs without sweeping release-pipeline', () => {
|
|
67
|
+
assert.equal(urlIsChangelog('https://example.com/docs/changelog'), true)
|
|
68
|
+
assert.equal(urlIsChangelog('https://example.com/docs/changelog-javascript-agent'), true)
|
|
69
|
+
assert.equal(urlIsChangelog('https://example.com/docs/release-notes'), true)
|
|
70
|
+
assert.equal(urlIsChangelog('https://example.com/docs/whats-new'), true)
|
|
71
|
+
assert.equal(urlIsChangelog('https://example.com/docs/releases'), true)
|
|
72
|
+
assert.equal(urlIsChangelog('https://example.com/docs/release-2026-2'), true)
|
|
73
|
+
assert.equal(urlIsChangelog('https://example.com/docs/release-pipeline'), false)
|
|
74
|
+
assert.equal(urlIsChangelog('https://example.com/docs/getting-started'), false)
|
|
75
|
+
assert.equal(urlIsChangelog('://bad'), false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('extractChangelogFromSections pulls changelog rows out of llms sections in place', () => {
|
|
79
|
+
const sections = [
|
|
80
|
+
{
|
|
81
|
+
title: 'Guides',
|
|
82
|
+
items: [
|
|
83
|
+
{ text: 'Intro', url: 'https://example.com/docs/intro' },
|
|
84
|
+
{ text: 'Notes', url: 'https://example.com/docs/changelog', description: 'What shipped' },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
title: 'History',
|
|
89
|
+
items: [{ text: 'v1', url: 'https://example.com/docs/release-notes/v1' }],
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
const extracted = extractChangelogFromSections(sections)
|
|
94
|
+
assert.deepEqual(extracted, [
|
|
95
|
+
{ title: 'Notes', url: 'https://example.com/docs/changelog', description: 'What shipped' },
|
|
96
|
+
{ title: 'v1', url: 'https://example.com/docs/release-notes/v1' },
|
|
97
|
+
])
|
|
98
|
+
assert.deepEqual(sections[0].items.map((i) => i.url), ['https://example.com/docs/intro'])
|
|
99
|
+
assert.deepEqual(sections[1].items, [])
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('reclassifyReferencePages sweeps api-reference URLs into one category and drops emptied ones', () => {
|
|
103
|
+
const scraped = {
|
|
104
|
+
categories: [
|
|
105
|
+
{
|
|
106
|
+
title: 'Developers',
|
|
107
|
+
pages: [
|
|
108
|
+
{ title: 'Guide', url: 'https://example.com/docs/guide' },
|
|
109
|
+
{
|
|
110
|
+
title: 'List pets',
|
|
111
|
+
url: 'https://example.com/api-reference/pets',
|
|
112
|
+
pages: [{ title: 'Create pet', url: 'https://example.com/endpoints/create' }],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
title: 'Only endpoints',
|
|
118
|
+
pages: [{ title: 'Delete pet', url: 'https://example.com/endpoints/delete' }],
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
assert.equal(reclassifyReferencePages(scraped), 3)
|
|
124
|
+
assert.deepEqual(
|
|
125
|
+
scraped.categories.map((c) => c.title),
|
|
126
|
+
['Developers', 'API Reference'],
|
|
127
|
+
)
|
|
128
|
+
assert.deepEqual(
|
|
129
|
+
scraped.categories[0].pages.map((p) => p.title),
|
|
130
|
+
['Guide'],
|
|
131
|
+
)
|
|
132
|
+
assert.deepEqual(
|
|
133
|
+
scraped.categories[1].pages.map((p) => p.url),
|
|
134
|
+
[
|
|
135
|
+
'https://example.com/api-reference/pets',
|
|
136
|
+
'https://example.com/endpoints/create',
|
|
137
|
+
'https://example.com/endpoints/delete',
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('isAuthChromeUrl matches leading login/account routes only', () => {
|
|
143
|
+
assert.equal(isAuthChromeUrl('https://example.com/auth'), true)
|
|
144
|
+
assert.equal(isAuthChromeUrl('https://example.com/login/callback'), true)
|
|
145
|
+
assert.equal(isAuthChromeUrl('https://example.com/sign-in'), true)
|
|
146
|
+
assert.equal(isAuthChromeUrl('https://example.com/signup'), true)
|
|
147
|
+
assert.equal(isAuthChromeUrl('https://example.com/oauth2/authorize'), true)
|
|
148
|
+
assert.equal(isAuthChromeUrl('https://example.com/sso'), true)
|
|
149
|
+
assert.equal(isAuthChromeUrl('https://example.com/docs/auth'), false)
|
|
150
|
+
assert.equal(isAuthChromeUrl('https://example.com/authenticate'), false)
|
|
151
|
+
assert.equal(isAuthChromeUrl('://bad'), false)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('isDiscoverableLink keeps same-origin doc paths and drops assets, chrome, and off-origin', () => {
|
|
155
|
+
const base = new URL('https://example.com/docs')
|
|
156
|
+
assert.equal(isDiscoverableLink('https://example.com/docs/intro', base), true)
|
|
157
|
+
assert.equal(isDiscoverableLink('https://other.com/docs/intro', base), false)
|
|
158
|
+
assert.equal(isDiscoverableLink('https://example.com/', base), false)
|
|
159
|
+
assert.equal(isDiscoverableLink('https://example.com/logo.png', base), false)
|
|
160
|
+
assert.equal(isDiscoverableLink('https://example.com/_next/static/chunk.js', base), false)
|
|
161
|
+
assert.equal(isDiscoverableLink('https://example.com/assets/app.js', base), false)
|
|
162
|
+
assert.equal(isDiscoverableLink('not a url', base), false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('decodeEntities and stripTags clean sidebar titles that would otherwise miss category routing', () => {
|
|
166
|
+
assert.equal(decodeEntities('New Features & Upgrade Changes'), 'New Features & Upgrade Changes')
|
|
167
|
+
assert.equal(decodeEntities('A & B'), 'A & B')
|
|
168
|
+
assert.equal(decodeEntities('hex & amp'), 'hex & amp')
|
|
169
|
+
assert.equal(decodeEntities('plain'), 'plain')
|
|
170
|
+
assert.equal(stripTags('<span>API\u200B Reference</span>'), 'API Reference')
|
|
171
|
+
assert.equal(stripTags('Guides & <b>Recipes</b>'), 'Guides & Recipes')
|
|
172
|
+
})
|