@mintlify/scraping 4.0.1191 → 4.0.1193
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/__test__/sdk-sphinx-images.test.ts +79 -0
- package/__test__/sdk-sphinx-modules.test.ts +97 -0
- package/__test__/sdk-sphinx-navigation.test.ts +318 -0
- package/__test__/sdk-sphinx-signatures.test.ts +148 -0
- package/__test__/sdk-sphinx.test.ts +130 -1
- package/bin/sdk/converters/sphinx.js +202 -77
- package/bin/sdk/converters/sphinx.js.map +1 -1
- package/bin/sdk/sphinx-images.d.ts +2 -0
- package/bin/sdk/sphinx-images.js +23 -0
- package/bin/sdk/sphinx-images.js.map +1 -0
- package/bin/sdk/sphinx-modules.d.ts +5 -0
- package/bin/sdk/sphinx-modules.js +121 -0
- package/bin/sdk/sphinx-modules.js.map +1 -0
- package/bin/sdk/sphinx-navigation.d.ts +12 -0
- package/bin/sdk/sphinx-navigation.js +225 -0
- package/bin/sdk/sphinx-navigation.js.map +1 -0
- package/bin/sdk/sphinx-signatures.d.ts +3 -0
- package/bin/sdk/sphinx-signatures.js +244 -0
- package/bin/sdk/sphinx-signatures.js.map +1 -0
- package/bin/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/sdk/converters/sphinx.ts +219 -89
- package/src/sdk/sphinx-images.ts +25 -0
- package/src/sdk/sphinx-modules.ts +124 -0
- package/src/sdk/sphinx-navigation.ts +236 -0
- package/src/sdk/sphinx-signatures.ts +233 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { resolveRelativeUrl } from '@mintlify/common';
|
|
2
|
+
import type { Element } from 'hast';
|
|
3
|
+
import { visit } from 'unist-util-visit';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { htmlToHast } from '../src/pipeline/root.js';
|
|
7
|
+
import { hastToMdx, parseMarkdown } from '../src/sdk/convert.js';
|
|
8
|
+
import { rewriteSphinxImages } from '../src/sdk/sphinx-images.js';
|
|
9
|
+
|
|
10
|
+
function imageSource(src: string, source: string, destination: string): string {
|
|
11
|
+
const tree = htmlToHast(`<img src="${src}" alt="Synthetic diagram" title="Flow chart">`);
|
|
12
|
+
rewriteSphinxImages(tree, source, destination);
|
|
13
|
+
const sources: string[] = [];
|
|
14
|
+
visit(parseMarkdown(hastToMdx(tree)), 'image', (node) => sources.push(node.url));
|
|
15
|
+
const converted = sources[0];
|
|
16
|
+
if (converted === undefined) throw new Error('Missing converted image');
|
|
17
|
+
return converted;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function resolvedImage(slug: string, src: string): string {
|
|
21
|
+
const page = `sdk/${slug}`;
|
|
22
|
+
return new URL(resolveRelativeUrl(page, src, false), `https://docs.example.test/${page}`).href;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('rewriteSphinxImages', () => {
|
|
26
|
+
it('keeps split module images pointing to the asset used by the original page', () => {
|
|
27
|
+
const before = imageSource('./images/flow.png', 'api', 'api');
|
|
28
|
+
const after = imageSource('./images/flow.png', 'api', 'api/sample.client');
|
|
29
|
+
expect(after).toBe('../images/flow.png');
|
|
30
|
+
expect(resolvedImage('api/sample.client', after)).toBe(resolvedImage('api', before));
|
|
31
|
+
expect(resolvedImage('api/sample.client', after)).toBe(
|
|
32
|
+
'https://docs.example.test/sdk/images/flow.png'
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it.each([
|
|
37
|
+
['../_images/flow.png', 'api', 'api/sample.client'],
|
|
38
|
+
['../_images/flow.png', 'guides/api', 'guides/api/sample.client'],
|
|
39
|
+
['./images/flow.png', 'api/index', 'api/index/sample.client'],
|
|
40
|
+
['images/flow.png', 'api', 'api/sample.client'],
|
|
41
|
+
['./images/flow%20chart.png?view=wide#detail', 'api', 'api/sample.client'],
|
|
42
|
+
['./images/flow.png', 'guides/api', 'reference'],
|
|
43
|
+
])('preserves asset identity for %s moved from %s to %s', (src, source, destination) => {
|
|
44
|
+
const before = imageSource(src, source, source);
|
|
45
|
+
const after = imageSource(src, source, destination);
|
|
46
|
+
expect(resolvedImage(destination, after)).toBe(resolvedImage(source, before));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
'/images/flow.svg?view=wide#detail',
|
|
51
|
+
'https://assets.example.test/flow.svg?view=wide#detail',
|
|
52
|
+
'//assets.example.test/flow.svg',
|
|
53
|
+
'data:image/svg+xml,%3Csvg%3E%3C/svg%3E',
|
|
54
|
+
'javascript:alert(1)',
|
|
55
|
+
'#diagram',
|
|
56
|
+
'?version=1',
|
|
57
|
+
])('preserves existing non-file image references: %s', (src) => {
|
|
58
|
+
const tree = htmlToHast('<img alt="Synthetic diagram">');
|
|
59
|
+
const images: Element[] = [];
|
|
60
|
+
visit(tree, 'element', (node: Element) => {
|
|
61
|
+
if (node.tagName === 'img') {
|
|
62
|
+
node.properties.src = src;
|
|
63
|
+
images.push(node);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
rewriteSphinxImages(tree, 'api', 'api/sample.client');
|
|
67
|
+
expect(images[0]?.properties.src).toBe(src);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('retains image metadata and unrelated links', () => {
|
|
71
|
+
const tree = htmlToHast(
|
|
72
|
+
'<p><a href="./guide.html">Guide</a><img src="./images/flow.png" alt="Synthetic diagram" title="Flow chart"></p>'
|
|
73
|
+
);
|
|
74
|
+
rewriteSphinxImages(tree, 'api', 'api/sample.client');
|
|
75
|
+
const content = hastToMdx(tree);
|
|
76
|
+
expect(content).toContain('[Guide](./guide.html)');
|
|
77
|
+
expect(content).toContain('');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { splitSphinxModules } from '../src/sdk/sphinx-modules.js';
|
|
4
|
+
import type { SphinxNavigationDoc } from '../src/sdk/sphinx-navigation.js';
|
|
5
|
+
|
|
6
|
+
const clientSection =
|
|
7
|
+
'<section id="module-sample.clients"><span id="clients-module"></span><h2>Client module<a class="headerlink" href="#module-sample.clients">¶</a></h2><dl class="py class"><dt class="sig" id="sample.clients.Client">Client</dt><dd>Client documentation.<dl class="py method"><dt class="sig" id="sample.clients.Client.send">send</dt><dt class="sig" id="sample.clients.Client.send-1">send overload</dt></dl></dd></dl></section>';
|
|
8
|
+
const modelSection =
|
|
9
|
+
'<section id="module-sample.models"><h2>Models module</h2><dl class="py class"><dt class="sig" id="sample.models.Request">Request</dt><dd>Request documentation.</dd></dl></section>';
|
|
10
|
+
|
|
11
|
+
function combined(body = `${clientSection}${modelSection}`): SphinxNavigationDoc {
|
|
12
|
+
return {
|
|
13
|
+
slug: 'api',
|
|
14
|
+
title: 'Sample API',
|
|
15
|
+
body: `<section id="api"><h1>Sample API</h1><p>API introduction.</p>${body}<p>API footer.</p></section>`,
|
|
16
|
+
tag: 'MODULE',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('splitSphinxModules', () => {
|
|
21
|
+
it('splits peer modules with stable routes and preserves their original source metadata', () => {
|
|
22
|
+
const [overview, clients, models] = splitSphinxModules([combined()]);
|
|
23
|
+
|
|
24
|
+
expect(clients).toMatchObject({
|
|
25
|
+
slug: 'api/sample.clients',
|
|
26
|
+
title: 'Client module',
|
|
27
|
+
body: clientSection,
|
|
28
|
+
sourceSlug: 'api',
|
|
29
|
+
tag: 'MODULE',
|
|
30
|
+
parents: [{ link: '/api' }],
|
|
31
|
+
});
|
|
32
|
+
expect(models).toMatchObject({
|
|
33
|
+
slug: 'api/sample.models',
|
|
34
|
+
title: 'Models module',
|
|
35
|
+
body: modelSection,
|
|
36
|
+
sourceSlug: 'api',
|
|
37
|
+
});
|
|
38
|
+
expect(overview?.slug).toBe('api');
|
|
39
|
+
expect(overview?.tag).toBe('MODULE');
|
|
40
|
+
expect(overview?.body).toContain('<h1>Sample API</h1><p>API introduction.</p>');
|
|
41
|
+
expect(overview?.body).toContain('<p>API footer.</p>');
|
|
42
|
+
expect(overview?.body).toContain('href="/api/sample.clients">Client module</a>');
|
|
43
|
+
expect(overview?.body).toContain('href="/api/sample.models">Models module</a>');
|
|
44
|
+
expect(overview?.body).not.toContain('Client documentation.');
|
|
45
|
+
expect(overview?.body).not.toContain('Request documentation.');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps original module, symbol, and overload anchors on the overview for existing URLs', () => {
|
|
49
|
+
const [overview] = splitSphinxModules([combined()]);
|
|
50
|
+
for (const id of [
|
|
51
|
+
'module-sample.clients',
|
|
52
|
+
'clients-module',
|
|
53
|
+
'sample.clients.Client',
|
|
54
|
+
'sample.clients.Client.send',
|
|
55
|
+
'sample.clients.Client.send-1',
|
|
56
|
+
'sample.models.Request',
|
|
57
|
+
]) {
|
|
58
|
+
expect(overview?.body).toContain(`<span id="${id}"></span>`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('supports legacy Sphinx module sections without splitting nested modules twice', () => {
|
|
63
|
+
const nested =
|
|
64
|
+
'<section id="module-sample.clients.nested"><h3>Nested module</h3><p>Nested documentation.</p></section>';
|
|
65
|
+
const legacyClients = `<div class="section" id="module-sample.clients"><h2>Client module</h2>${nested}</div>`;
|
|
66
|
+
const docs = splitSphinxModules([combined(`${legacyClients}${modelSection}`)]);
|
|
67
|
+
|
|
68
|
+
expect(docs.map((doc) => doc.slug)).toEqual(['api', 'api/sample.clients', 'api/sample.models']);
|
|
69
|
+
expect(docs[1]?.body).toBe(legacyClients);
|
|
70
|
+
expect(docs[0]?.body).toContain('id="module-sample.clients.nested"');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('leaves single-module pages unchanged', () => {
|
|
74
|
+
const original = combined(clientSection);
|
|
75
|
+
expect(splitSphinxModules([original])).toEqual([original]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('keeps original pages when a generated module route already exists', () => {
|
|
79
|
+
const original = combined();
|
|
80
|
+
const existing = {
|
|
81
|
+
slug: 'api/sample.clients',
|
|
82
|
+
title: 'Authored clients',
|
|
83
|
+
body: '<p>Authored content.</p>',
|
|
84
|
+
};
|
|
85
|
+
expect(splitSphinxModules([original, existing])).toEqual([original, existing]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not create colliding pages from duplicate module identifiers', () => {
|
|
89
|
+
const original = combined(`${clientSection}${clientSection}`);
|
|
90
|
+
expect(splitSphinxModules([original])).toEqual([original]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('does not treat isolated module sections in separate containers as peer modules', () => {
|
|
94
|
+
const original = combined(`<div>${clientSection}</div><div>${modelSection}</div>`);
|
|
95
|
+
expect(splitSphinxModules([original])).toEqual([original]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { buildSphinxNavigation, type SphinxNavigationDoc } from '../src/sdk/sphinx-navigation.js';
|
|
4
|
+
import type { SdkNavGroup } from '../src/sdk/types.js';
|
|
5
|
+
|
|
6
|
+
function doc(
|
|
7
|
+
slug: string,
|
|
8
|
+
title: string,
|
|
9
|
+
body = '',
|
|
10
|
+
tag?: string,
|
|
11
|
+
toc?: string
|
|
12
|
+
): SphinxNavigationDoc {
|
|
13
|
+
return { slug, title, body, tag, toc };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function pages(groups: SdkNavGroup[]): string[] {
|
|
17
|
+
return groups.flatMap((group) =>
|
|
18
|
+
group.pages.flatMap((page) => (typeof page === 'string' ? [page] : pages([page])))
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('buildSphinxNavigation', () => {
|
|
23
|
+
it('preserves nested toctrees and puts module indexes with their reference pages', () => {
|
|
24
|
+
const groups = buildSphinxNavigation([
|
|
25
|
+
doc(
|
|
26
|
+
'index',
|
|
27
|
+
'Example SDK',
|
|
28
|
+
'<div class="toctree-wrapper"><ul><li><a href="modules.html">Modules</a><ul><li><a href="example.html">Example package</a><ul><li><a href="example.zeta.html">Zeta</a></li><li><a href="example.alpha.html">Alpha</a></li></ul></li></ul></li></ul></div>'
|
|
29
|
+
),
|
|
30
|
+
doc('modules', 'Modules'),
|
|
31
|
+
doc('example', 'Example package', '', 'MODULE'),
|
|
32
|
+
doc('example.alpha', 'Alpha module', '', 'MODULE'),
|
|
33
|
+
doc('example.zeta', 'Zeta module', '', 'MODULE'),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
expect(groups).toEqual([
|
|
37
|
+
{
|
|
38
|
+
group: 'API Reference',
|
|
39
|
+
pages: [
|
|
40
|
+
{
|
|
41
|
+
group: 'Example SDK',
|
|
42
|
+
pages: [
|
|
43
|
+
'index',
|
|
44
|
+
{
|
|
45
|
+
group: 'Modules',
|
|
46
|
+
pages: [
|
|
47
|
+
'modules',
|
|
48
|
+
{
|
|
49
|
+
group: 'Example package',
|
|
50
|
+
pages: ['example', 'example.zeta', 'example.alpha'],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps authored guides apart from reference hierarchy in a mixed root toctree', () => {
|
|
62
|
+
const groups = buildSphinxNavigation([
|
|
63
|
+
doc(
|
|
64
|
+
'index',
|
|
65
|
+
'Example SDK',
|
|
66
|
+
'<p>Build an application with this SDK.</p><div class="toctree-wrapper"><ul><li><a href="quickstart.html">Quickstart</a></li><li><a href="api/index.html">API reference</a><ul><li><a href="api/client.html">Client</a></li></ul></li></ul></div>'
|
|
67
|
+
),
|
|
68
|
+
doc('quickstart', 'Quickstart', '<p>Install the SDK.</p>'),
|
|
69
|
+
doc('api/index', 'API reference'),
|
|
70
|
+
doc('api/client', 'Client module', '', 'MODULE'),
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
expect(groups).toEqual([
|
|
74
|
+
{
|
|
75
|
+
group: 'Getting Started',
|
|
76
|
+
pages: [{ group: 'Example SDK', pages: ['index', 'quickstart'] }],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
group: 'API Reference',
|
|
80
|
+
pages: [{ group: 'API reference', pages: ['api/index', 'api/client'] }],
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('uses per-page toctrees and relative URLs instead of filename sorting', () => {
|
|
86
|
+
const groups = buildSphinxNavigation([
|
|
87
|
+
doc('api/sample.alpha', 'Alpha', '', 'MODULE'),
|
|
88
|
+
doc('api/sample.zeta', 'Zeta', '', 'MODULE'),
|
|
89
|
+
doc(
|
|
90
|
+
'api/sample',
|
|
91
|
+
'Sample package',
|
|
92
|
+
'<div class="toctree-wrapper"><ul><li><a href="sample.zeta.html">Zeta</a></li><li><a href="sample.alpha.html">Alpha</a></li></ul></div>',
|
|
93
|
+
'MODULE'
|
|
94
|
+
),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
expect(groups).toEqual([
|
|
98
|
+
{
|
|
99
|
+
group: 'API Reference',
|
|
100
|
+
pages: [
|
|
101
|
+
{ group: 'Sample package', pages: ['api/sample', 'api/sample.zeta', 'api/sample.alpha'] },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('falls back to the serialized toc when the body has no toctree wrapper', () => {
|
|
108
|
+
const groups = buildSphinxNavigation([
|
|
109
|
+
doc(
|
|
110
|
+
'index',
|
|
111
|
+
'Sample package',
|
|
112
|
+
'<h1>Sample package</h1>',
|
|
113
|
+
undefined,
|
|
114
|
+
'<ul><li><a href="#sample">Sample package</a><ul><li><a href="sample.html">Sample</a></li></ul></li></ul>'
|
|
115
|
+
),
|
|
116
|
+
doc('sample', 'Sample module', '', 'MODULE'),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(groups).toEqual([
|
|
120
|
+
{
|
|
121
|
+
group: 'API Reference',
|
|
122
|
+
pages: [{ group: 'Sample package', pages: ['index', 'sample'] }],
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('resolves directory-style and HTML-style links when both candidate pages exist', () => {
|
|
128
|
+
expect(
|
|
129
|
+
buildSphinxNavigation([
|
|
130
|
+
doc(
|
|
131
|
+
'parent',
|
|
132
|
+
'Parent package',
|
|
133
|
+
'<div class="toctree-wrapper"><ul><li><a href="child/">Nested child</a></li><li><a href="child.html">Root child</a></li></ul></div>',
|
|
134
|
+
'MODULE'
|
|
135
|
+
),
|
|
136
|
+
doc('parent/child', 'Nested child', '', 'MODULE'),
|
|
137
|
+
doc('child', 'Root child', '', 'MODULE'),
|
|
138
|
+
])
|
|
139
|
+
).toEqual([
|
|
140
|
+
{
|
|
141
|
+
group: 'API Reference',
|
|
142
|
+
pages: [{ group: 'Parent package', pages: ['parent', 'parent/child', 'child'] }],
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('falls back to dotted module and directory ancestry without a toctree', () => {
|
|
148
|
+
expect(
|
|
149
|
+
buildSphinxNavigation([
|
|
150
|
+
doc('api/sample', 'Sample package', '', 'MODULE'),
|
|
151
|
+
doc('api/sample.transport', 'Transport package', '', 'MODULE'),
|
|
152
|
+
doc('api/sample.transport.http', 'HTTP module', '', 'MODULE'),
|
|
153
|
+
doc('guides', 'Guides', '<p>Guides for the SDK.</p>'),
|
|
154
|
+
doc('guides/install', 'Installation', '<p>Install the SDK.</p>'),
|
|
155
|
+
])
|
|
156
|
+
).toEqual([
|
|
157
|
+
{
|
|
158
|
+
group: 'Getting Started',
|
|
159
|
+
pages: [{ group: 'Guides', pages: ['guides', 'guides/install'] }],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
group: 'API Reference',
|
|
163
|
+
pages: [
|
|
164
|
+
{
|
|
165
|
+
group: 'Sample package',
|
|
166
|
+
pages: [
|
|
167
|
+
'api/sample',
|
|
168
|
+
{
|
|
169
|
+
group: 'Transport package',
|
|
170
|
+
pages: ['api/sample.transport', 'api/sample.transport.http'],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('uses Sphinx parent metadata when hidden toctrees are absent from the body', () => {
|
|
180
|
+
expect(
|
|
181
|
+
buildSphinxNavigation([
|
|
182
|
+
doc('modules', 'Modules'),
|
|
183
|
+
{
|
|
184
|
+
...doc('sample', 'Sample package', '', 'MODULE'),
|
|
185
|
+
parents: [{ link: '../modules/' }],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
...doc('client', 'Client module', '', 'MODULE'),
|
|
189
|
+
parents: [{ link: '../modules/' }, { link: '../sample/' }],
|
|
190
|
+
},
|
|
191
|
+
])
|
|
192
|
+
).toEqual([
|
|
193
|
+
{
|
|
194
|
+
group: 'API Reference',
|
|
195
|
+
pages: [
|
|
196
|
+
{
|
|
197
|
+
group: 'Modules',
|
|
198
|
+
pages: ['modules', { group: 'Sample package', pages: ['sample', 'client'] }],
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('groups related module and path siblings without generating overview routes', () => {
|
|
206
|
+
const groups = buildSphinxNavigation([
|
|
207
|
+
doc('sample.transport.http', 'HTTP module', '', 'MODULE'),
|
|
208
|
+
doc('sample.transport.websocket', 'WebSocket module', '', 'MODULE'),
|
|
209
|
+
doc('sample.users', 'Users module', '', 'MODULE'),
|
|
210
|
+
doc('guides/install', 'Install', '<p>Install the SDK.</p>'),
|
|
211
|
+
doc('guides/quickstart', 'Quickstart', '<p>Create a client.</p>'),
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
expect(groups).toEqual([
|
|
215
|
+
{
|
|
216
|
+
group: 'Getting Started',
|
|
217
|
+
pages: [{ group: 'guides', pages: ['guides/install', 'guides/quickstart'] }],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
group: 'API Reference',
|
|
221
|
+
pages: [
|
|
222
|
+
{
|
|
223
|
+
group: 'sample',
|
|
224
|
+
pages: [
|
|
225
|
+
{
|
|
226
|
+
group: 'sample.transport',
|
|
227
|
+
pages: ['sample.transport.http', 'sample.transport.websocket'],
|
|
228
|
+
},
|
|
229
|
+
'sample.users',
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
]);
|
|
235
|
+
expect(pages(groups).sort()).toEqual([
|
|
236
|
+
'guides/install',
|
|
237
|
+
'guides/quickstart',
|
|
238
|
+
'sample.transport.http',
|
|
239
|
+
'sample.transport.websocket',
|
|
240
|
+
'sample.users',
|
|
241
|
+
]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('preserves virtual API groups when a folder also contains a guide', () => {
|
|
245
|
+
const groups = buildSphinxNavigation([
|
|
246
|
+
doc('api/models', 'Models module', '', 'MODULE'),
|
|
247
|
+
doc('api/quickstart', 'Quickstart', '<p>Create a client.</p>'),
|
|
248
|
+
doc('api/client', 'Client module', '', 'MODULE'),
|
|
249
|
+
]);
|
|
250
|
+
|
|
251
|
+
expect(groups).toEqual([
|
|
252
|
+
{ group: 'Getting Started', pages: ['api/quickstart'] },
|
|
253
|
+
{
|
|
254
|
+
group: 'API Reference',
|
|
255
|
+
pages: [{ group: 'api', pages: ['api/client', 'api/models'] }],
|
|
256
|
+
},
|
|
257
|
+
]);
|
|
258
|
+
expect(pages(groups).sort()).toEqual(['api/client', 'api/models', 'api/quickstart']);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('traverses nested virtual folders separately for guides and modules', () => {
|
|
262
|
+
const groups = buildSphinxNavigation([
|
|
263
|
+
doc('api/client', 'Client module', '', 'MODULE'),
|
|
264
|
+
doc('api/models', 'Models module', '', 'MODULE'),
|
|
265
|
+
doc('api/guides/install', 'Install', '<p>Install the SDK.</p>'),
|
|
266
|
+
doc('api/guides/quickstart', 'Quickstart', '<p>Create a client.</p>'),
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
expect(groups).toEqual([
|
|
270
|
+
{
|
|
271
|
+
group: 'Getting Started',
|
|
272
|
+
pages: [{ group: 'guides', pages: ['api/guides/install', 'api/guides/quickstart'] }],
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
group: 'API Reference',
|
|
276
|
+
pages: [{ group: 'api', pages: ['api/client', 'api/models'] }],
|
|
277
|
+
},
|
|
278
|
+
]);
|
|
279
|
+
expect(pages(groups).sort()).toEqual([
|
|
280
|
+
'api/client',
|
|
281
|
+
'api/guides/install',
|
|
282
|
+
'api/guides/quickstart',
|
|
283
|
+
'api/models',
|
|
284
|
+
]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('avoids duplicates and cycles while keeping every generated page reachable', () => {
|
|
288
|
+
const groups = buildSphinxNavigation([
|
|
289
|
+
doc(
|
|
290
|
+
'index',
|
|
291
|
+
'Sample',
|
|
292
|
+
'<div class="toctree-wrapper"><ul><li><a href="sample.html">Sample</a></li><li><a href="sample.html">Duplicate</a></li><li><a href="missing.html">Missing</a></li><li><a href="sample.html#method">Method</a></li><li><a href="https://example.com/">External</a></li></ul></div>'
|
|
293
|
+
),
|
|
294
|
+
doc(
|
|
295
|
+
'sample',
|
|
296
|
+
'Sample module',
|
|
297
|
+
'<div class="toctree-wrapper"><ul><li><a href="index.html">Index</a></li></ul></div>',
|
|
298
|
+
'MODULE'
|
|
299
|
+
),
|
|
300
|
+
doc('orphan', 'Orphan module', '', 'MODULE'),
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
expect(pages(groups).sort()).toEqual(['index', 'orphan', 'sample']);
|
|
304
|
+
expect(groups.every((group) => group.group === 'API Reference')).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('keeps legacy guide and module groups when no hierarchy is available', () => {
|
|
308
|
+
expect(
|
|
309
|
+
buildSphinxNavigation([
|
|
310
|
+
doc('index', 'Example SDK', '<p>Welcome to the SDK.</p>'),
|
|
311
|
+
doc('widgets', 'Widgets module', '', 'MODULE'),
|
|
312
|
+
])
|
|
313
|
+
).toEqual([
|
|
314
|
+
{ group: 'Getting Started', pages: ['index'] },
|
|
315
|
+
{ group: 'API Reference', pages: ['widgets'] },
|
|
316
|
+
]);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { Element } from 'hast';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { htmlToHast } from '../src/pipeline/root.js';
|
|
5
|
+
import { renderSphinxLinkedCode, renderSphinxSignature } from '../src/sdk/sphinx-signatures.js';
|
|
6
|
+
|
|
7
|
+
function signature(html: string): Element {
|
|
8
|
+
const element = htmlToHast(`<dt>${html}</dt>`).children.find((node) => node.type === 'element');
|
|
9
|
+
if (!element || element.type !== 'element') throw new Error('Missing signature element');
|
|
10
|
+
return element;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('renderSphinxSignature', () => {
|
|
14
|
+
it('preserves the Python signature and links parameter and return types', () => {
|
|
15
|
+
const content = renderSphinxSignature(
|
|
16
|
+
signature(
|
|
17
|
+
'create(<em class="sig-param"><span>body</span>: <a href="/models#example.Request">Request</a></em>) → <span class="sig-return-typehint"><a href="/models#example.Response">Response</a></span>'
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
expect(content).toContain('```python\ncreate(body: Request) → Response\n```');
|
|
21
|
+
expect(content).toContain(
|
|
22
|
+
'<ResponseField name={"body"} type={"Request"} typeHref={"/models#example.Request"} required>'
|
|
23
|
+
);
|
|
24
|
+
expect(content).toContain('**Returns:** <code>[Response](/models#example.Response)</code>');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('links individual references within compound annotations and preserves default values', () => {
|
|
28
|
+
const content = renderSphinxSignature(
|
|
29
|
+
signature(
|
|
30
|
+
'create(<em class="sig-param">body: list[<a href="/models#example.Request">Request</a>] | None = None</em>)'
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
expect(content).toContain('create(body: list[Request] | None = None)');
|
|
34
|
+
expect(content).toContain('<ResponseField name={"body"} type={"list[Request] | None"}>');
|
|
35
|
+
expect(content).not.toContain('typeHref=');
|
|
36
|
+
expect(content).toContain(
|
|
37
|
+
'**Type:** <code>{"list["}[Request](/models#example.Request){"] | None"}</code>'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not interpret equals signs in literals as defaults', () => {
|
|
42
|
+
const content = renderSphinxSignature(
|
|
43
|
+
signature('create(<em class="sig-param">operator: Literal["=", ":"]</em>)')
|
|
44
|
+
);
|
|
45
|
+
expect(content).toContain(`type={${JSON.stringify('Literal["=", ":"]')}} required>`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('marks typed variadic parameters optional and skips untyped separators', () => {
|
|
49
|
+
const content = renderSphinxSignature(
|
|
50
|
+
signature(
|
|
51
|
+
'create(<em class="sig-param">self</em>, <em class="sig-param">/</em>, <em class="sig-param">*args: str</em>, <em class="sig-param">**kwargs: object</em>)'
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
expect(content).toContain('<ResponseField name={"*args"} type={"str"}>');
|
|
55
|
+
expect(content).toContain('<ResponseField name={"**kwargs"} type={"object"}>');
|
|
56
|
+
expect(content).not.toContain('required');
|
|
57
|
+
expect(content).not.toContain('name={"self"}');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('keeps malicious annotation text inert and drops unsafe hyperlinks', () => {
|
|
61
|
+
const content = renderSphinxSignature(
|
|
62
|
+
signature(
|
|
63
|
+
'create(<em class="sig-param">body: <a href="javascript:alert(1)">BadType</a></em>) → <span class="sig-return-typehint">{execute()} <script>unsafe</script></span>'
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
expect(content).not.toContain('javascript:');
|
|
67
|
+
expect(content).not.toContain('typeHref=');
|
|
68
|
+
expect(content).toContain('**Returns:** `{execute()} <script>unsafe</script>`');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('encodes Markdown destination delimiters without changing the linked type text', () => {
|
|
72
|
+
const content = renderSphinxSignature(
|
|
73
|
+
signature(
|
|
74
|
+
'create() → <span class="sig-return-typehint"><a href="/models#Type)(payload)">Type</a></span>'
|
|
75
|
+
)
|
|
76
|
+
);
|
|
77
|
+
expect(content).toContain('[Type](/models#Type%29%28payload%29)');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('uses a longer fence when a default value contains backticks', () => {
|
|
81
|
+
const content = renderSphinxSignature(
|
|
82
|
+
signature('create(<em class="sig-param">value: str = "```"</em>)')
|
|
83
|
+
);
|
|
84
|
+
expect(content).toContain('````python\ncreate(value: str = "```")\n````');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('uses existing parameter and return type links without repeating generated details', () => {
|
|
88
|
+
const description = signature(
|
|
89
|
+
'<dl class="field-list simple"><dt>Parameters:</dt><dd><ul><li><p><strong>body</strong> (<a href="/models#example.Request"><em>example.Request</em></a>) – The request.</p></li><li><p><strong>options</strong> (<a href="/models#example.Options">Options</a>) – The options.</p></li></ul></dd><dt>Return type:</dt><dd><p><code><a href="/models#example.Response">Response</a></code></p></dd></dl>'
|
|
90
|
+
);
|
|
91
|
+
const content = renderSphinxSignature(
|
|
92
|
+
signature(
|
|
93
|
+
'create(<em class="sig-param">body: <a href="/models#example.Request">Request</a></em>, <em class="sig-param">options: <a href="/models#example.Options">Options</a></em>) → <span class="sig-return-typehint"><a href="/models#example.Response">Response</a></span>'
|
|
94
|
+
),
|
|
95
|
+
description
|
|
96
|
+
);
|
|
97
|
+
expect(content).toContain('```python');
|
|
98
|
+
expect(content).not.toContain('<ResponseField');
|
|
99
|
+
expect(content).not.toContain('**Returns:**');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('keeps fallback details for missing, unrelated, or unsafe documented links', () => {
|
|
103
|
+
const description = signature(
|
|
104
|
+
'<dl class="field-list"><dt>Parameters:</dt><dd><p><strong>body</strong> (<a href="javascript:alert(1)">Request</a>) – See <a href="/models#example.Request">Request</a>.</p></dd><dt>Return type:</dt><dd><p><a href="/models#example.OtherResponse">Response</a></p></dd></dl><dl class="py method"><dd><dl class="field-list"><dt>Parameters:</dt><dd><p><strong>body</strong> (<a href="/models#example.Request">Request</a>) – A nested method request.</p></dd></dl></dd></dl>'
|
|
105
|
+
);
|
|
106
|
+
const content = renderSphinxSignature(
|
|
107
|
+
signature(
|
|
108
|
+
'create(<em class="sig-param">body: <a href="/models#example.Request">Request</a></em>) → <span class="sig-return-typehint"><a href="/models#example.Response">Response</a></span>'
|
|
109
|
+
),
|
|
110
|
+
description
|
|
111
|
+
);
|
|
112
|
+
expect(content).toContain('<ResponseField name={"body"}');
|
|
113
|
+
expect(content).toContain('typeHref={"/models#example.Request"}');
|
|
114
|
+
expect(content).toContain('**Returns:** <code>[Response](/models#example.Response)</code>');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not repeat primitive types already documented in field lists', () => {
|
|
118
|
+
const content = renderSphinxSignature(
|
|
119
|
+
signature('get(<em class="sig-param">client_id: str</em>)'),
|
|
120
|
+
signature(
|
|
121
|
+
'<dl class="field-list"><dt>Parameters:</dt><dd><p><strong>client_id</strong> (<em>str</em>) – Identifier.</p></dd></dl>'
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
expect(content).not.toContain('<ResponseField');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('renderSphinxLinkedCode', () => {
|
|
129
|
+
it('preserves legacy field-list references nested inside inline code', () => {
|
|
130
|
+
const content = renderSphinxLinkedCode(
|
|
131
|
+
signature(
|
|
132
|
+
'<code class="xref py py-class">list[<a href="/models#example.Request"><span class="pre">Request</span></a>] | None</code>'
|
|
133
|
+
)
|
|
134
|
+
);
|
|
135
|
+
expect(content).toBe('<code>{"list["}[Request](/models#example.Request){"] | None"}</code>');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('leaves unlinked code to the normal conversion pipeline', () => {
|
|
139
|
+
expect(renderSphinxLinkedCode(signature('<code>str | None</code>'))).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('keeps surrounding expression-like text inert', () => {
|
|
143
|
+
const content = renderSphinxLinkedCode(
|
|
144
|
+
signature('<code>{execute()} | <a href="/models#example.Request">Request</a></code>')
|
|
145
|
+
);
|
|
146
|
+
expect(content).toBe('<code>{"{execute()} | "}[Request](/models#example.Request)</code>');
|
|
147
|
+
});
|
|
148
|
+
});
|