@readme/cli 0.0.31 → 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/README.md +10 -0
- package/bin/readme.js +1 -1
- package/package.json +34 -24
- package/src/bootstrap.js +9 -13
- package/src/cli.js +17 -11
- package/src/commands/components.js +35 -18
- package/src/commands/dev.js +12 -8
- package/src/commands/eyes.js +12 -8
- package/src/commands/import.js +2263 -2066
- package/src/commands/lint.js +33 -29
- package/src/commands/oas-bundle.js +89 -0
- package/src/commands/oas-sync.js +96 -93
- package/src/commands/oas-validate.js +10 -7
- package/src/commands/pretty.js +177 -164
- package/src/commands/setup.js +24 -26
- package/src/commands/versions.js +6 -2
- package/src/dev/app/[...slug]/page.js +1 -0
- package/src/dev/app/layout.js +18 -11
- package/src/dev/app/page.js +1 -0
- package/src/dev/lib/docs.js +27 -19
- package/src/dev/next.config.mjs +2 -2
- package/src/gha.js +7 -7
- package/src/prompts/index.js +16 -6
- package/src/utils/eyes.js +143 -51
- package/src/utils/git.js +3 -3
- package/src/utils/lint.js +39 -38
- package/src/utils/llms.js +94 -91
- package/src/utils/markdown.js +256 -0
- package/src/utils/marketplace.js +12 -5
- package/src/utils/multi-spinner.js +5 -7
- package/src/utils/promise-pool.js +7 -5
- package/src/utils/reporter.js +88 -91
- package/src/utils/setup-templates.js +12 -6
- package/src/utils/styles.js +2 -1
- package/src/utils/tamagotchi.js +257 -91
- package/src/utils/tips.js +18 -9
- package/src/utils/url-segs.js +46 -41
- package/src/validators/components.js +55 -63
- package/src/validators/content.js +5 -10
- package/src/validators/duplicates.js +6 -2
- package/src/validators/frontmatter.js +19 -21
- package/src/validators/links.js +4 -8
- package/src/validators/oas-reference.js +7 -14
- package/src/validators/oas-schema.js +2 -0
- package/src/validators/ordering.js +5 -5
- package/src/validators/recipes.js +4 -12
- package/src/commands/import-oas.test.js +0 -277
- package/src/commands/import.classify.test.js +0 -172
- package/src/commands/import.landing.test.js +0 -217
- package/src/commands/import.slug-planner.test.js +0 -86
- package/src/commands/import.staging.test.js +0 -158
- package/src/commands/import.test.js +0 -382
- package/src/utils/llms.test.js +0 -302
- package/src/utils/url-segs.test.js +0 -239
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
3
|
+
|
|
4
4
|
import {
|
|
5
5
|
findOasFiles,
|
|
6
6
|
extractOperations,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
operationKey,
|
|
10
10
|
findIgnoredInternalExtensions,
|
|
11
11
|
} from '../commands/oas-sync.js';
|
|
12
|
+
import { parseFrontmatter } from '../utils/markdown.js';
|
|
12
13
|
|
|
13
14
|
export const name = 'oas-reference';
|
|
14
15
|
|
|
@@ -37,17 +38,11 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
// Collect all reference pages with api frontmatter.
|
|
40
|
-
const refPages = files.filter(
|
|
41
|
+
const refPages = files.filter(f => f.startsWith('reference/') && f.endsWith('.md'));
|
|
41
42
|
|
|
42
43
|
for (const relPath of refPages) {
|
|
43
44
|
const filePath = path.join(gitRoot, relPath);
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
({ data } = matter(fs.readFileSync(filePath, 'utf-8')));
|
|
47
|
-
} catch {
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
|
|
45
|
+
const { data } = parseFrontmatter(fs.readFileSync(filePath, 'utf-8'));
|
|
51
46
|
if (!data.api || !data.api.file) continue;
|
|
52
47
|
|
|
53
48
|
const oasFilename = data.api.file;
|
|
@@ -85,11 +80,9 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
85
80
|
// Check for missing pages: operations in the spec with no corresponding page.
|
|
86
81
|
const existingPages = collectExistingPages(refDir);
|
|
87
82
|
for (const [oasFilename, { ops }] of oasMap) {
|
|
88
|
-
const pagesForOas = existingPages.filter(
|
|
83
|
+
const pagesForOas = existingPages.filter(p => p.data.api.file === oasFilename);
|
|
89
84
|
const coveredOps = new Set(
|
|
90
|
-
pagesForOas.map((p)
|
|
91
|
-
operationKey({ operationId: p.data.api.operationId, isWebhook: !!p.data.api.webhook }),
|
|
92
|
-
),
|
|
85
|
+
pagesForOas.map(p => operationKey({ operationId: p.data.api.operationId, isWebhook: !!p.data.api.webhook })),
|
|
93
86
|
);
|
|
94
87
|
|
|
95
88
|
for (const op of ops.values()) {
|
|
@@ -109,7 +102,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
109
102
|
// is actually fixable. The sync adds, deletes, moves, hides and reorders
|
|
110
103
|
// reference files, which is far too much to do on the strength of an
|
|
111
104
|
// unfixable warning (`x-readme.internal`, a page pointing at a missing spec).
|
|
112
|
-
if (fix && results.some(
|
|
105
|
+
if (fix && results.some(r => r.fixable)) {
|
|
113
106
|
const syncResults = syncOas(gitRoot);
|
|
114
107
|
if (syncResults) {
|
|
115
108
|
for (const r of results) {
|
|
@@ -33,7 +33,7 @@ function parseOrderYaml(content) {
|
|
|
33
33
|
return content
|
|
34
34
|
.split('\n')
|
|
35
35
|
.map(parseOrderEntry)
|
|
36
|
-
.filter(
|
|
36
|
+
.filter(entry => entry !== null);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function isIndex(name) {
|
|
@@ -108,7 +108,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
108
108
|
|
|
109
109
|
// On disk but not ordered.
|
|
110
110
|
if (requireOrder) {
|
|
111
|
-
const missing = [...onDisk].filter(
|
|
111
|
+
const missing = [...onDisk].filter(slug => !ordered.has(slug)).sort();
|
|
112
112
|
if (missing.length > 0) {
|
|
113
113
|
results.push({
|
|
114
114
|
file: relOrder,
|
|
@@ -152,7 +152,7 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
152
152
|
for (const r of results) {
|
|
153
153
|
if (!r._fixAdd) continue;
|
|
154
154
|
const { orderPath, missing } = r._fixAdd;
|
|
155
|
-
const newEntries = missing.map(
|
|
155
|
+
const newEntries = missing.map(slug => `- ${yamlSafeSlug(slug)}`).join('\n');
|
|
156
156
|
|
|
157
157
|
if (fs.existsSync(orderPath)) {
|
|
158
158
|
const existing = fs.readFileSync(orderPath, 'utf-8');
|
|
@@ -180,11 +180,11 @@ export function validateAll(files, gitRoot, { fix } = {}) {
|
|
|
180
180
|
const kept = fs
|
|
181
181
|
.readFileSync(orderPath, 'utf-8')
|
|
182
182
|
.split('\n')
|
|
183
|
-
.filter(
|
|
183
|
+
.filter(line => {
|
|
184
184
|
const entry = parseOrderEntry(line);
|
|
185
185
|
return entry === null || !toRemove.has(entry);
|
|
186
186
|
});
|
|
187
|
-
if (kept.some(
|
|
187
|
+
if (kept.some(line => parseOrderEntry(line) !== null)) {
|
|
188
188
|
fs.writeFileSync(orderPath, kept.join('\n'));
|
|
189
189
|
} else {
|
|
190
190
|
fs.unlinkSync(orderPath);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { parseFrontmatter } from '../utils/markdown.js';
|
|
2
2
|
|
|
3
3
|
export const name = 'recipes';
|
|
4
4
|
|
|
@@ -76,16 +76,8 @@ export function validate({ content, relativePath }) {
|
|
|
76
76
|
if (!relativePath.endsWith('.md')) return null;
|
|
77
77
|
|
|
78
78
|
const results = [];
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
try {
|
|
83
|
-
({ data, content: body } = matter(content));
|
|
84
|
-
} catch {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!data.recipe) return null;
|
|
79
|
+
const { data, content: body, error } = parseFrontmatter(content);
|
|
80
|
+
if (error || !data.recipe) return null;
|
|
89
81
|
|
|
90
82
|
const codeBlocks = parseCodeBlocks(body);
|
|
91
83
|
const snippetRefs = parseSnippetRefs(body);
|
|
@@ -118,7 +110,7 @@ export function validate({ content, relativePath }) {
|
|
|
118
110
|
const block = blocksByLang.get(ref.lang);
|
|
119
111
|
|
|
120
112
|
if (!block) {
|
|
121
|
-
const available = codeBlocks.map(
|
|
113
|
+
const available = codeBlocks.map(b => b.lang).join(', ');
|
|
122
114
|
results.push({
|
|
123
115
|
file: relativePath,
|
|
124
116
|
rule: name,
|
|
@@ -1,277 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,172 +0,0 @@
|
|
|
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
|
-
})
|