@mintlify/scraping 4.0.1161 → 4.0.1162

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.
@@ -0,0 +1,1671 @@
1
+ import { preprocessCustomHeadingIds } from '@mintlify/common';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import remarkMdx from 'remark-mdx';
7
+ import remarkParse from 'remark-parse';
8
+ import { unified } from 'unified';
9
+ import { visit } from 'unist-util-visit';
10
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
11
+
12
+ import { convertGo } from '../src/sdk/converters/go.js';
13
+ import { findSdkPage, renderSdkTarget } from '../src/sdk/findSdkPage.js';
14
+ import { safeHref } from '../src/sdk/mdx.js';
15
+ import type { SdkPage, SdkReference } from '../src/sdk/types.js';
16
+
17
+ const goldenDir = fileURLToPath(
18
+ new URL('../../mintlify-go-extractor/internal/extract/testdata/golden/', import.meta.url)
19
+ );
20
+ const golden = (name: string) => path.join(goldenDir, `${name}.json`);
21
+
22
+ function page(reference: SdkReference, slug: string): SdkPage {
23
+ const found = reference.pages.find((entry) => entry.slug === slug);
24
+ if (!found) throw new Error(`missing page ${slug}`);
25
+ return found;
26
+ }
27
+
28
+ // Components the converter emits, plus the h1-h6 that heading ids become.
29
+ const ALLOWED_JSX = [
30
+ 'ResponseField',
31
+ 'Warning',
32
+ 'code',
33
+ 'Steps',
34
+ 'Step',
35
+ 'h1',
36
+ 'h2',
37
+ 'h3',
38
+ 'h4',
39
+ 'h5',
40
+ 'h6',
41
+ ];
42
+
43
+ // The converter writes literal text inside <code> as {"..."}; an expression
44
+ // that is exactly one JSON string evaluates to that text and nothing else.
45
+ function isStringLiteral(source: string): boolean {
46
+ try {
47
+ return typeof JSON.parse(source) === 'string';
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ // Everything in a rendered page that MDX would execute or a browser would
54
+ // follow as script: ESM, expressions, unknown JSX, and javascript:/data: URLs.
55
+ function liveMdx(mdx: string): string[] {
56
+ const tree = unified().use(remarkParse).use(remarkMdx).parse(preprocessCustomHeadingIds(mdx));
57
+ const found: string[] = [];
58
+ visit(tree, (node) => {
59
+ if (node.type === 'mdxjsEsm') found.push(node.type);
60
+ if (node.type === 'mdxFlowExpression' || node.type === 'mdxTextExpression') {
61
+ const value = (node as { value: string }).value;
62
+ if (!isStringLiteral(value)) found.push(`${node.type}:${value}`);
63
+ }
64
+ if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
65
+ const name = (node as { name?: string | null }).name ?? '';
66
+ if (!ALLOWED_JSX.includes(name)) found.push(`jsx:${name}`);
67
+ }
68
+ const url = (node as { url?: string }).url;
69
+ if (url !== undefined && /^\s*(?:javascript|data|vbscript):/i.test(url)) {
70
+ found.push(`${node.type}:${url}`);
71
+ }
72
+ });
73
+ return found;
74
+ }
75
+
76
+ function expectNoLiveMdx(reference: SdkReference) {
77
+ for (const entry of reference.pages) {
78
+ expect(liveMdx(entry.content), entry.slug).toEqual([]);
79
+ if (entry.intro) expect(liveMdx(entry.intro), `${entry.slug} intro`).toEqual([]);
80
+ }
81
+ }
82
+
83
+ function within(ms: number, run: () => unknown) {
84
+ const start = performance.now();
85
+ run();
86
+ expect(performance.now() - start).toBeLessThan(ms);
87
+ }
88
+
89
+ describe('convertGo', () => {
90
+ let basic: SdkReference;
91
+
92
+ beforeAll(async () => {
93
+ basic = await convertGo(golden('basic'));
94
+ });
95
+
96
+ it('groups pages by package with the overview first', () => {
97
+ expect(basic.groups).toEqual([
98
+ {
99
+ group: 'acme',
100
+ pages: [
101
+ 'package-overview',
102
+ 'Alias',
103
+ 'Client',
104
+ 'Color',
105
+ 'Handler',
106
+ 'Settings',
107
+ 'Store',
108
+ 'Widget',
109
+ 'WidgetService',
110
+ 'NewClient',
111
+ 'Parse',
112
+ ],
113
+ },
114
+ { group: 'legacy', pages: ['legacy/package-overview', 'legacy/Client'] },
115
+ {
116
+ group: 'option',
117
+ pages: [
118
+ 'option/package-overview',
119
+ 'option/Config',
120
+ 'option/RequestOption',
121
+ 'option/WithAPIKey',
122
+ ],
123
+ },
124
+ ]);
125
+ });
126
+
127
+ it('renders package overview pages', () => {
128
+ const overview = page(basic, 'package-overview');
129
+ expect(overview.title).toBe('package acme');
130
+ expect(overview.tag).toBe('PACKAGE');
131
+ expect(overview.target).toEqual({ kind: 'package', name: 'acme' });
132
+ expect(overview.intro).toBe(
133
+ [
134
+ 'The Acme Go library provides access to the Acme API.',
135
+ '',
136
+ '## Installation',
137
+ '',
138
+ '```sh',
139
+ 'go get example.com/acme',
140
+ '```',
141
+ '',
142
+ 'See [the option package](option/README.md) and ![logo](assets/logo.png).',
143
+ ].join('\n')
144
+ );
145
+ expect(overview.content).not.toContain('Acme Go library');
146
+ const option = page(basic, 'option/package-overview');
147
+ expect(option).toMatchObject({
148
+ description: 'Request options for the Acme client.',
149
+ target: { kind: 'package', name: 'option' },
150
+ });
151
+ expect(option.intro).toBeUndefined();
152
+ expect(page(basic, 'legacy/package-overview').intro).toBeUndefined();
153
+ expect(renderSdkTarget(basic, { kind: 'package', name: 'option' }).slug).toBe(
154
+ 'option/package-overview'
155
+ );
156
+ expect(overview.description).toBe('Package acme is the Go client for the Acme API.');
157
+ expect(overview.content).toContain('- [Client](/Client)');
158
+ expect(overview.content).toContain(
159
+ '## Functions\n\n- [NewClient](/NewClient)\n- [Parse](/Parse)'
160
+ );
161
+ expect(overview.content).toContain('## Constants\n\n```go\nconst DefaultTimeout = 30\n```');
162
+ expect(page(basic, 'legacy/package-overview').content).toContain(
163
+ '<Warning>\n Deprecated: Use [example.com/acme.Client](/Client) instead.\n</Warning>'
164
+ );
165
+ });
166
+
167
+ it('renders type pages with fields, constructors, and methods', () => {
168
+ const client = page(basic, 'Client');
169
+ expect(client).toMatchObject({
170
+ title: 'Client',
171
+ tag: 'STRUCT',
172
+ description: 'Client calls the Acme API.',
173
+ target: { kind: 'type', name: 'Client' },
174
+ });
175
+ expect(client.content).toContain('```go\ntype Client struct\n```');
176
+ expect(client.content).not.toContain('BaseURL string `json:"base_url"`');
177
+ expect(page(basic, 'Color').content).toContain('```go\ntype Color int\n```');
178
+ expect(client.content).toContain(
179
+ '<ResponseField name={"Widgets"} id={"Widgets"} type={"*WidgetService"} typeHref={"/WidgetService"}>'
180
+ );
181
+ expect(client.content).toContain(
182
+ '<ResponseField name={"Options"} id={"Options"} type={"[]option.RequestOption"} typeHref={"/option/RequestOption"}>'
183
+ );
184
+ expect(client.content).toContain(
185
+ '<ResponseField name={"Reader"} id={"Reader"} type={"io.Reader"}>\n **Type:** <code>{"io."}[Reader](https://pkg.go.dev/io#Reader)</code>\n\n Embedded field.'
186
+ );
187
+ expect(client.content).toContain(
188
+ '<ResponseField name={"BaseURL"} id={"BaseURL"} type={"string"}>\n **Tag:** `json:"base_url"`'
189
+ );
190
+ expect(client.content).toContain(
191
+ '<Warning>\n Deprecated: Use [Client.Do](/Client#Client.Do) instead.\n </Warning>'
192
+ );
193
+ expect(client.content).toContain('## Constructors\n\n### NewClient()');
194
+ expect(client.content).toContain('#### Returns\n\n<code>{"*"}[Client](/Client)</code>');
195
+ expect(client.content).toContain(
196
+ '#### Returns\n\n<code>{"(*"}[Widget](/Widget){", error)"}</code>'
197
+ );
198
+ expect(client.content).toContain(
199
+ '##### Example\n\n```go\nclient := acme.NewClient()\nfmt.Println(client.BaseURL == "")\n```\n\nOutput:\n\n```text\ntrue\n```'
200
+ );
201
+ });
202
+
203
+ it('resolves doc links to pages, member anchors, and pkg.go.dev', () => {
204
+ const client = page(basic, 'Client');
205
+ expect(client.content).toContain(
206
+ 'Create one with [NewClient](/NewClient) and see [Client.Do](/Client#Client.Do).'
207
+ );
208
+ expect(client.content).toContain('[io.Reader](https://pkg.go.dev/io#Reader)');
209
+ expect(client.content).toContain('\\[Missing]');
210
+ expect(client.content).toContain('`[Widget]`');
211
+ });
212
+
213
+ it('gives method headings explicit ids that survive author content and section headings', () => {
214
+ const widget = page(basic, 'Widget');
215
+ expect(widget.content).toContain('## Fields\n\n');
216
+ expect(widget.content).toContain('## Methods\n\n### Fields() {#Widget.Fields}');
217
+ expect(widget.content).toContain('Call [Widget.Fields](/Widget#Widget.Fields) to list');
218
+ });
219
+
220
+ it('demotes comment headings below page sections', () => {
221
+ expect(page(basic, 'Widget').content).toContain(
222
+ 'Parse parses a widget.\n\n#### Format\n\nThe input looks like:'
223
+ );
224
+ });
225
+
226
+ it('marks deprecated types and interfaces', () => {
227
+ expect(page(basic, 'WidgetService').content).toContain(
228
+ '<Warning>\n Deprecated: Use [Client](/Client) directly.\n</Warning>'
229
+ );
230
+ expect(page(basic, 'Store')).toMatchObject({
231
+ tag: 'INTERFACE',
232
+ target: { kind: 'interface', name: 'Store' },
233
+ });
234
+ expect(page(basic, 'Store').content).toContain('```go\ntype Store interface\n```');
235
+ });
236
+
237
+ it('adds member pages that resolve frontmatter targets', () => {
238
+ expect(() => renderSdkTarget(basic, { kind: 'type', name: 'Client' })).toThrow(
239
+ 'SDK symbol is ambiguous: type Client matches acme.Client, acme.legacy.Client'
240
+ );
241
+ expect(renderSdkTarget(basic, { kind: 'type', name: 'Widget' }).slug).toBe('Widget');
242
+ expect(renderSdkTarget(basic, { kind: 'function', name: 'NewClient' })).toMatchObject({
243
+ slug: 'NewClient',
244
+ tag: 'FUNCTION',
245
+ });
246
+ expect(renderSdkTarget(basic, { kind: 'type', name: 'acme.Client' }).slug).toBe('Client');
247
+ expect(renderSdkTarget(basic, { kind: 'type', name: 'legacy.Client' }).slug).toBe(
248
+ 'legacy/Client'
249
+ );
250
+ expect(
251
+ renderSdkTarget(basic, { kind: 'method', name: 'Do', parent: 'acme.Client' })
252
+ ).toMatchObject({
253
+ slug: 'Client/Do',
254
+ title: 'Client.Do',
255
+ tag: 'METHOD',
256
+ });
257
+ expect(() =>
258
+ renderSdkTarget(basic, { kind: 'method', name: 'Do', parent: 'legacy.Client' })
259
+ ).toThrow('SDK symbol not found: method legacy.Client.Do');
260
+ expect(
261
+ renderSdkTarget(basic, { kind: 'property', name: 'BaseURL', parent: 'acme.Client' })
262
+ ).toMatchObject({ slug: 'Client/BaseURL', tag: 'FIELD' });
263
+ expect(
264
+ basic.groups
265
+ .flatMap((group) => group.pages)
266
+ .filter((entry) => typeof entry === 'string' && entry.startsWith('Client/'))
267
+ ).toEqual([]);
268
+ });
269
+
270
+ it('renders Go doc comment syntax literally in godoc mode', async () => {
271
+ const reference = await convertGo(golden('basic-godoc'));
272
+ expect(page(reference, 'Client').content).toContain(
273
+ 'It is safe for \\*\\*concurrent\\*\\* use.'
274
+ );
275
+ });
276
+
277
+ it('renders generics and package-level functions', async () => {
278
+ const reference = await convertGo(golden('generics'));
279
+ const sum = page(reference, 'Sum');
280
+ expect(sum).toMatchObject({ tag: 'FUNCTION', target: { kind: 'function', name: 'Sum' } });
281
+ expect(sum.content).toContain(
282
+ '## Type Parameters\n\n<ResponseField name={"Item"} type={"Number"} typeHref={"/Number"}>'
283
+ );
284
+ expect(sum.content).toContain('## Returns\n\n`Item`');
285
+ expect(page(reference, 'Number').content).toContain(
286
+ '```go\ntype Number interface {\n\t~int | ~int64 | ~float64\n}\n```'
287
+ );
288
+ expect(page(reference, 'Opt').content).toContain(
289
+ '#### Returns\n\n<code>[Opt](/Opt){"[T]"}</code>'
290
+ );
291
+ });
292
+
293
+ it('links in-module packages and leaves private references unlinked', async () => {
294
+ const record = page(await convertGo(golden('privacy')), 'Record');
295
+ expect(record.content).toContain(
296
+ '<ResponseField name={"Sub"} id={"Sub"} type={"subpkg.Thing"} typeHref={"/sub/Thing"}>'
297
+ );
298
+ expect(record.content).toContain(
299
+ '<ResponseField name={"Private"} id={"Private"} type={"priv.Model"}>\n\n</ResponseField>'
300
+ );
301
+ expect(record.content).toContain(
302
+ '[Widget](https://pkg.go.dev/github.com/public/widgets/v2#Widget)'
303
+ );
304
+ expect(record.content).toContain('Its \\[corp.example/sdk/models.Model] stays private.');
305
+ });
306
+
307
+ it('produces valid MDX for every page', async () => {
308
+ const processor = unified().use(remarkParse).use(remarkMdx);
309
+ for (const name of ['basic', 'basic-godoc', 'generics', 'privacy', 'hostile']) {
310
+ const reference = await convertGo(golden(name));
311
+ for (const entry of reference.pages) {
312
+ expect(
313
+ () => processor.parse(preprocessCustomHeadingIds(entry.content)),
314
+ `${name}: ${entry.slug}`
315
+ ).not.toThrow();
316
+ }
317
+ }
318
+ });
319
+
320
+ describe('synthetic artifacts', () => {
321
+ let dir: string;
322
+
323
+ beforeAll(async () => {
324
+ dir = await mkdtemp(path.join(os.tmpdir(), 'go-artifact-'));
325
+ });
326
+
327
+ afterAll(async () => {
328
+ await rm(dir, { recursive: true, force: true });
329
+ });
330
+
331
+ async function artifact(
332
+ name: string,
333
+ packages: unknown[],
334
+ module = 'example.com/acme'
335
+ ): Promise<string> {
336
+ const file = path.join(dir, `${name}.json`);
337
+ await writeFile(
338
+ file,
339
+ JSON.stringify({
340
+ schemaVersion: 1,
341
+ generator: { name: 'mintlify-go-extractor', version: 'test', go: 'go1.22' },
342
+ module,
343
+ goos: 'linux',
344
+ goarch: 'amd64',
345
+ commentSyntax: 'markdown',
346
+ packages,
347
+ })
348
+ );
349
+ return file;
350
+ }
351
+
352
+ it('keeps Unicode names distinct in slugs', async () => {
353
+ const reference = await convertGo(
354
+ await artifact('unicode', [
355
+ {
356
+ importPath: 'example.com/acme',
357
+ name: 'acme',
358
+ dir: '.',
359
+ types: [
360
+ { name: 'Éclair', kind: 'struct', decl: [{ t: 'type Éclair struct{}' }] },
361
+ { name: 'Üclair', kind: 'struct', decl: [{ t: 'type Üclair struct{}' }] },
362
+ ],
363
+ },
364
+ ])
365
+ );
366
+ expect(reference.groups[0]?.pages).toEqual(['package-overview', 'Éclair', 'Üclair']);
367
+ });
368
+
369
+ it('links same-named constructors and methods to their own pages and headings', async () => {
370
+ const reference = await convertGo(
371
+ await artifact('anchors', [
372
+ {
373
+ importPath: 'example.com/acme',
374
+ name: 'acme',
375
+ dir: '.',
376
+ types: [
377
+ {
378
+ name: 'Client',
379
+ kind: 'struct',
380
+ decl: [{ t: 'type Client struct{}' }],
381
+ doc: 'Use [Close](go:example.com/acme#Close) or [Client.Close](go:example.com/acme#Client.Close).',
382
+ funcs: [{ name: 'Close', decl: [{ t: 'func Close() *Client' }] }],
383
+ methods: [
384
+ { name: 'Close', recv: '*Client', decl: [{ t: 'func (c *Client) Close()' }] },
385
+ ],
386
+ },
387
+ ],
388
+ },
389
+ ])
390
+ );
391
+ expect(page(reference, 'Client').content).toContain(
392
+ 'Use [Close](/Close) or [Client.Close](/Client#Client.Close).'
393
+ );
394
+ });
395
+
396
+ it('links field references to the rendered field', async () => {
397
+ const reference = await convertGo(
398
+ await artifact('fields', [
399
+ {
400
+ importPath: 'example.com/acme',
401
+ name: 'acme',
402
+ dir: '.',
403
+ types: [
404
+ {
405
+ name: 'Client',
406
+ kind: 'struct',
407
+ decl: [{ t: 'type Client struct{ BaseURL string }' }],
408
+ doc: 'Set [Client.BaseURL](go:example.com/acme#Client.BaseURL) first.',
409
+ fields: [{ name: 'BaseURL', type: [{ t: 'string' }] }],
410
+ },
411
+ ],
412
+ },
413
+ ])
414
+ );
415
+ expect(page(reference, 'Client').content).toContain(
416
+ 'Set [Client.BaseURL](/Client#BaseURL) first.'
417
+ );
418
+ });
419
+
420
+ it('keeps routes distinct when names differ only by case', async () => {
421
+ const reference = await convertGo(
422
+ await artifact('case', [
423
+ {
424
+ importPath: 'example.com/acme',
425
+ name: 'acme',
426
+ dir: '.',
427
+ funcs: [{ name: 'Parse', decl: [{ t: 'func Parse()' }] }],
428
+ types: [
429
+ {
430
+ name: 'URL',
431
+ kind: 'struct',
432
+ decl: [{ t: 'type URL struct{}' }],
433
+ fields: [
434
+ { name: 'Host', type: [{ t: 'string' }] },
435
+ { name: 'HOST', type: [{ t: 'string' }] },
436
+ ],
437
+ doc: 'See [Url](go:example.com/acme#Url) and [URL.HOST](go:example.com/acme#URL.HOST).',
438
+ },
439
+ { name: 'Url', kind: 'alias', decl: [{ t: 'type Url = URL' }] },
440
+ { name: 'PARSE', kind: 'defined', decl: [{ t: 'type PARSE int' }] },
441
+ ],
442
+ },
443
+ ])
444
+ );
445
+ expect(reference.groups[0]?.pages).toEqual([
446
+ 'package-overview',
447
+ 'URL',
448
+ 'Url-2',
449
+ 'PARSE-2',
450
+ 'Parse',
451
+ ]);
452
+ expect(page(reference, 'URL').content).toContain(
453
+ 'See [Url](/Url-2) and [URL.HOST](/URL#HOST).'
454
+ );
455
+ expect(
456
+ renderSdkTarget(reference, { kind: 'property', name: 'HOST', parent: 'URL' }).slug
457
+ ).toBe('URL/HOST-2');
458
+ });
459
+
460
+ it('reserves package routes before member routes', async () => {
461
+ const reference = await convertGo(
462
+ await artifact('member-routes', [
463
+ {
464
+ importPath: 'example.com/acme',
465
+ name: 'acme',
466
+ dir: '.',
467
+ types: [
468
+ {
469
+ name: 'Client',
470
+ kind: 'struct',
471
+ decl: [{ t: 'type Client struct{}' }],
472
+ methods: [{ name: 'Do', recv: '*Client', decl: [{ t: 'func (c *Client) Do()' }] }],
473
+ },
474
+ ],
475
+ },
476
+ {
477
+ importPath: 'example.com/acme/client',
478
+ name: 'client',
479
+ dir: 'client',
480
+ funcs: [{ name: 'Do', decl: [{ t: 'func Do()' }] }],
481
+ },
482
+ ])
483
+ );
484
+ expect(reference.groups[1]?.pages).toEqual(['client/package-overview', 'client/Do']);
485
+ expect(
486
+ renderSdkTarget(reference, { kind: 'method', name: 'Do', parent: 'Client' }).slug
487
+ ).toBe('Client/Do-2');
488
+ });
489
+
490
+ it('resolves many colliding names without quadratic probing', async () => {
491
+ const count = 20_000;
492
+ const file = await artifact('collisions', [
493
+ {
494
+ importPath: 'example.com/acme',
495
+ name: 'acme',
496
+ dir: '.',
497
+ funcs: Array.from({ length: count }, () => ({
498
+ name: 'Dup',
499
+ decl: [{ t: 'func Dup()' }],
500
+ })),
501
+ },
502
+ ]);
503
+ const started = performance.now();
504
+ const reference = await convertGo(file);
505
+ expect(performance.now() - started).toBeLessThan(5_000);
506
+ expect(reference.groups[0]?.pages).toHaveLength(count + 1);
507
+ expect(reference.groups[0]?.pages.at(-1)).toBe(`Dup-${count}`);
508
+ });
509
+
510
+ it('renders unnamed parameters as a linked chip', async () => {
511
+ const reference = await convertGo(
512
+ await artifact('unnamed', [
513
+ {
514
+ importPath: 'example.com/acme',
515
+ name: 'acme',
516
+ dir: '.',
517
+ types: [
518
+ { name: 'Widget', kind: 'struct', decl: [{ t: 'type Widget struct{}' }] },
519
+ {
520
+ name: 'Store',
521
+ kind: 'interface',
522
+ decl: [{ t: 'type Store interface{ Save(*Widget, []byte) error }' }],
523
+ methods: [
524
+ {
525
+ name: 'Save',
526
+ decl: [{ t: 'Save(*Widget, []byte) error' }],
527
+ params: [
528
+ { type: [{ t: '*' }, { t: 'Widget', ref: { name: 'Widget' } }] },
529
+ { type: [{ t: '[]byte' }] },
530
+ ],
531
+ results: [{ type: [{ t: 'error' }] }],
532
+ },
533
+ ],
534
+ },
535
+ ],
536
+ },
537
+ ])
538
+ );
539
+ expect(page(reference, 'Store').content).toContain(
540
+ '#### Parameters\n\n<code>{"(*"}[Widget](/Widget){", []byte)"}</code>'
541
+ );
542
+ });
543
+
544
+ it('rejects names that are not Go identifiers', async () => {
545
+ const file = await artifact('injection', [
546
+ {
547
+ importPath: 'example.com/acme',
548
+ name: 'acme',
549
+ dir: '.',
550
+ funcs: [{ name: 'alert(3)', decl: [{ t: 'func alert(3)()' }] }],
551
+ },
552
+ ]);
553
+ await expect(convertGo(file)).rejects.toThrow(
554
+ 'packages.0.funcs.0.name: expected a Go identifier'
555
+ );
556
+ });
557
+
558
+ it('rewrites README links to the GitHub repository and demotes its headings', async () => {
559
+ const reference = await convertGo(
560
+ await artifact(
561
+ 'readme',
562
+ [
563
+ {
564
+ importPath: 'github.com/acme/acme-go/v2',
565
+ name: 'acme',
566
+ dir: '.',
567
+ doc: 'Package acme is the client.',
568
+ readme:
569
+ '# Acme\n\nSee [guide](docs/guide.md#start), ![logo](./assets/logo.png), [site](https://acme.dev), [top](#install), [ref][g] and ![badge][b].\n\n# Install\n\n### Deep\n\n[g]: docs/guide.md\n[b]: assets/badge.svg',
570
+ },
571
+ {
572
+ importPath: 'github.com/acme/acme-go/v2/option',
573
+ name: 'option',
574
+ dir: 'option',
575
+ readme: 'Options. See [parent](../README.md) and [local](README.md).',
576
+ },
577
+ {
578
+ importPath: 'github.com/acme/acme-go/v2/param',
579
+ name: 'param',
580
+ dir: 'param',
581
+ readme: '# Param\n\nHelpers for `param` **values**.\n\nMore detail follows.',
582
+ },
583
+ ],
584
+ 'github.com/acme/acme-go/v2'
585
+ )
586
+ );
587
+ expect(page(reference, 'package-overview').intro).toBe(
588
+ 'See [guide](https://github.com/acme/acme-go/blob/HEAD/docs/guide.md#start), ![logo](https://github.com/acme/acme-go/raw/HEAD/assets/logo.png), [site](https://acme.dev), [top](#install), [ref][g] and ![badge][b].\n\n## Install\n\n### Deep\n\n[g]: https://github.com/acme/acme-go/blob/HEAD/docs/guide.md\n\n[b]: https://github.com/acme/acme-go/raw/HEAD/assets/badge.svg'
589
+ );
590
+ const option = page(reference, 'option/package-overview');
591
+ expect(option).toMatchObject({
592
+ intro:
593
+ 'Options. See [parent](https://github.com/acme/acme-go/blob/HEAD/README.md) and [local](https://github.com/acme/acme-go/blob/HEAD/option/README.md).',
594
+ target: { kind: 'package', name: 'option' },
595
+ });
596
+ expect(option.description).toBeUndefined();
597
+ expect(page(reference, 'param/package-overview').description).toBe(
598
+ 'Helpers for param values.'
599
+ );
600
+ });
601
+
602
+ it('takes the description from the first prose paragraph after README badges', async () => {
603
+ const reference = await convertGo(
604
+ await artifact('badges', [
605
+ {
606
+ importPath: 'example.com/b',
607
+ name: 'b',
608
+ dir: '.',
609
+ readme:
610
+ '# B\n\n[![ci](https://x/y.svg)](https://x) ![cov](https://x/c.svg)\n\nPackage *b* does [things](https://x/docs).\n\nMore detail.',
611
+ },
612
+ {
613
+ importPath: 'example.com/b/long',
614
+ name: 'long',
615
+ dir: 'long',
616
+ readme: '[![ci](https://x/y.svg)](https://x)\n\nFirst sentence. Second sentence.',
617
+ },
618
+ ])
619
+ );
620
+ const overview = page(reference, 'package-overview');
621
+ expect(overview.description).toBe('Package b does things.');
622
+ expect(overview.intro).toBe(
623
+ '[![ci](https://x/y.svg)](https://x) ![cov](https://x/c.svg)\n\nMore detail.'
624
+ );
625
+ const long = page(reference, 'long/package-overview');
626
+ expect(long.description).toBeUndefined();
627
+ expect(long.intro).toBe(
628
+ '[![ci](https://x/y.svg)](https://x)\n\nFirst sentence. Second sentence.'
629
+ );
630
+ });
631
+
632
+ it('reads descriptions from the parsed doc, not the Markdown source', async () => {
633
+ const reference = await convertGo(
634
+ await artifact('descriptions', [
635
+ {
636
+ importPath: 'example.com/acme',
637
+ name: 'acme',
638
+ dir: '.',
639
+ doc: 'Package acme talks to the [Acme API](https://acme.dev) via `HTTP`\\*. It retries.\n\nMore.',
640
+ types: [
641
+ {
642
+ name: 'Client',
643
+ kind: 'struct',
644
+ decl: [{ t: 'type Client struct{}' }],
645
+ doc: `Client is <b>the</b> **client** for ${'[a]('.repeat(20)}. Second sentence.`,
646
+ },
647
+ ],
648
+ },
649
+ ])
650
+ );
651
+ expect(page(reference, 'package-overview').description).toBe(
652
+ 'Package acme talks to the Acme API via HTTP*.'
653
+ );
654
+ expect(page(reference, 'Client').description).toBe(
655
+ `Client is the client for ${'[a]('.repeat(20)}.`
656
+ );
657
+ });
658
+
659
+ it('cuts struct and interface headers after their own body opens', async () => {
660
+ const reference = await convertGo(
661
+ await artifact('generic-constraints', [
662
+ {
663
+ importPath: 'example.com/acme',
664
+ name: 'acme',
665
+ dir: '.',
666
+ types: [
667
+ {
668
+ name: 'Set',
669
+ kind: 'struct',
670
+ decl: [
671
+ { t: 'type Set[T interface {\n\tcomparable\n\tString() string\n}] struct {\n' },
672
+ { t: '\titems map[T]struct{}\n}' },
673
+ ],
674
+ },
675
+ {
676
+ name: 'Store',
677
+ kind: 'interface',
678
+ decl: [
679
+ { t: 'type Store[K interface { comparable }, V any] interface {\n' },
680
+ { t: '\tGet(key K) (V, bool)\n}' },
681
+ ],
682
+ },
683
+ ],
684
+ },
685
+ ])
686
+ );
687
+ expect(page(reference, 'Set').content).toContain(
688
+ '```go\ntype Set[T interface {\n\tcomparable\n\tString() string\n}] struct\n```'
689
+ );
690
+ expect(page(reference, 'Store').content).toContain(
691
+ '```go\ntype Store[K interface { comparable }, V any] interface {\n\tGet(key K) (V, bool)\n}\n```'
692
+ );
693
+ });
694
+
695
+ it('resolves README links for modules in a repository subdirectory', async () => {
696
+ const reference = await convertGo(
697
+ await artifact(
698
+ 'subdir-module',
699
+ [
700
+ {
701
+ importPath: 'github.com/acme/monorepo/sdk/go/v12',
702
+ name: 'acme',
703
+ dir: '.',
704
+ doc: 'Package acme is the client.',
705
+ readme: '![logo](./assets/logo.svg) and [sub](option/README.md)',
706
+ },
707
+ ],
708
+ 'github.com/acme/monorepo/sdk/go/v12'
709
+ )
710
+ );
711
+ expect(page(reference, 'package-overview').intro).toBe(
712
+ '![logo](https://github.com/acme/monorepo/raw/HEAD/sdk/go/assets/logo.svg) and [sub](https://github.com/acme/monorepo/blob/HEAD/sdk/go/option/README.md)'
713
+ );
714
+ });
715
+
716
+ it('addresses nested packages by their parent directory', async () => {
717
+ const reference = await convertGo(
718
+ await artifact('nested-packages', [
719
+ { importPath: 'example.com/acme/audio/client', name: 'client', dir: 'audio/client' },
720
+ { importPath: 'example.com/acme/batches/client', name: 'client', dir: 'batches/client' },
721
+ ])
722
+ );
723
+ expect(renderSdkTarget(reference, { kind: 'package', name: 'batches.client' }).slug).toBe(
724
+ 'batches/client/package-overview'
725
+ );
726
+ expect(() => renderSdkTarget(reference, { kind: 'package', name: 'client' })).toThrow(
727
+ 'matches audio.client, batches.client'
728
+ );
729
+ });
730
+
731
+ it('addresses packages by their directory when it differs from the package name', async () => {
732
+ const reference = await convertGo(
733
+ await artifact('versioned-packages', [
734
+ {
735
+ importPath: 'example.com/acme/api/v1',
736
+ name: 'api',
737
+ dir: 'api/v1',
738
+ types: [{ name: 'Request', kind: 'struct', decl: [{ t: 'type Request struct{}' }] }],
739
+ },
740
+ {
741
+ importPath: 'example.com/acme/api/v2',
742
+ name: 'api',
743
+ dir: 'api/v2',
744
+ types: [{ name: 'Request', kind: 'struct', decl: [{ t: 'type Request struct{}' }] }],
745
+ },
746
+ ])
747
+ );
748
+ expect(page(reference, 'api/v2/package-overview').target).toEqual({
749
+ kind: 'package',
750
+ name: 'v2',
751
+ namespace: 'api',
752
+ });
753
+ expect(renderSdkTarget(reference, { kind: 'package', name: 'api.v1' }).slug).toBe(
754
+ 'api/v1/package-overview'
755
+ );
756
+ expect(renderSdkTarget(reference, { kind: 'type', name: 'api.v2.Request' }).slug).toBe(
757
+ 'api/v2/Request'
758
+ );
759
+ expect(() => renderSdkTarget(reference, { kind: 'type', name: 'Request' })).toThrow(
760
+ 'matches api.v1.Request, api.v2.Request'
761
+ );
762
+ });
763
+
764
+ it('keeps a root package apart from a top-level directory of the same name', async () => {
765
+ const type = { name: 'C', kind: 'struct', decl: [{ t: 'type C struct{}' }] };
766
+ const reference = await convertGo(
767
+ await artifact('root-collision', [
768
+ { importPath: 'example.com/client', name: 'client', dir: '.', types: [type] },
769
+ { importPath: 'example.com/client/client', name: 'client', dir: 'client', types: [type] },
770
+ ])
771
+ );
772
+ expect(page(reference, 'package-overview').target).toEqual({
773
+ kind: 'package',
774
+ name: 'client',
775
+ });
776
+ expect(page(reference, 'client/package-overview').target).toEqual({
777
+ kind: 'package',
778
+ name: 'client',
779
+ namespace: 'client',
780
+ });
781
+ expect(renderSdkTarget(reference, { kind: 'package', name: 'client' }).slug).toBe(
782
+ 'package-overview'
783
+ );
784
+ expect(renderSdkTarget(reference, { kind: 'package', name: 'client.client' }).slug).toBe(
785
+ 'client/package-overview'
786
+ );
787
+ expect(renderSdkTarget(reference, { kind: 'type', name: 'client.C' }).slug).toBe('C');
788
+ expect(renderSdkTarget(reference, { kind: 'type', name: 'client.client.C' }).slug).toBe(
789
+ 'client/C'
790
+ );
791
+ expect(() => renderSdkTarget(reference, { kind: 'type', name: 'C' })).toThrow(
792
+ 'matches client.C, client.client.C'
793
+ );
794
+ });
795
+
796
+ it('rejects packages whose dotted directories would share an address', async () => {
797
+ const file = await artifact('dotted-dirs', [
798
+ { importPath: 'example.com/m/a.b/c', name: 'c', dir: 'a.b/c' },
799
+ { importPath: 'example.com/m/a/b.c', name: 'c', dir: 'a/b.c' },
800
+ ]);
801
+ await expect(convertGo(file)).rejects.toThrow(
802
+ 'packages.1.dir: packages a.b/c and a/b.c would both be addressed as a.b.c'
803
+ );
804
+ const duplicateDir = await artifact('duplicate-dir', [
805
+ { importPath: 'example.com/m/x', name: 'x', dir: 'x' },
806
+ { importPath: 'example.com/m/y', name: 'x', dir: 'x' },
807
+ ]);
808
+ await expect(convertGo(duplicateDir)).rejects.toThrow(
809
+ 'packages.1.dir: duplicate package directory x'
810
+ );
811
+ const dots = await artifact('dots-dir', [
812
+ { importPath: 'example.com/m/x', name: 'x', dir: 'a/...' },
813
+ ]);
814
+ await expect(convertGo(dots)).rejects.toThrow('packages.0.dir: expected "."');
815
+ const oddDir = await artifact('odd-dir', [
816
+ { importPath: 'example.com/m/x', name: 'x', dir: 'a b/{x}' },
817
+ ]);
818
+ await expect(convertGo(oddDir)).rejects.toThrow('packages.0.dir: expected "."');
819
+ });
820
+
821
+ it('selects symbols in same-named packages by their directory path', async () => {
822
+ const type = (dir: string) => ({
823
+ name: 'Foo',
824
+ kind: 'struct',
825
+ decl: [{ t: `type Foo struct{} // ${dir}` }],
826
+ });
827
+ const reference = await convertGo(
828
+ await artifact('same-named-packages', [
829
+ {
830
+ importPath: 'example.com/acme/audio/client',
831
+ name: 'client',
832
+ dir: 'audio/client',
833
+ types: [type('audio')],
834
+ },
835
+ {
836
+ importPath: 'example.com/acme/batches/client',
837
+ name: 'client',
838
+ dir: 'batches/client',
839
+ types: [type('batches')],
840
+ },
841
+ ])
842
+ );
843
+ expect(renderSdkTarget(reference, { kind: 'type', name: 'batches.client.Foo' }).slug).toBe(
844
+ 'batches/client/Foo'
845
+ );
846
+ expect(renderSdkTarget(reference, { kind: 'type', name: 'audio.client.Foo' }).slug).toBe(
847
+ 'audio/client/Foo'
848
+ );
849
+ expect(() => renderSdkTarget(reference, { kind: 'type', name: 'client.Foo' })).toThrow(
850
+ 'SDK symbol is ambiguous: type client.Foo matches audio.client.Foo, batches.client.Foo'
851
+ );
852
+ });
853
+
854
+ it('renders the hostile golden without live MDX', async () => {
855
+ const reference = await convertGo(golden('hostile'));
856
+ const processor = unified().use(remarkParse).use(remarkMdx);
857
+ const live = (mdx: string) => {
858
+ const tree = processor.parse(preprocessCustomHeadingIds(mdx));
859
+ const types: string[] = [];
860
+ visit(tree, (node) => {
861
+ if (/^mdx(?:jsEsm|FlowExpression|TextExpression)$/.test(node.type)) types.push(node.type);
862
+ const url = (node as { url?: string }).url;
863
+ if (url !== undefined && /^\s*(?:javascript|data|vbscript):/i.test(url)) {
864
+ types.push(`${node.type}:${url}`);
865
+ }
866
+ if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
867
+ const name = (node as { name?: string | null }).name ?? '';
868
+ if (!['ResponseField', 'Warning', 'code', 'Steps', 'Step'].includes(name))
869
+ types.push(`jsx:${name}`);
870
+ }
871
+ });
872
+ return types;
873
+ };
874
+ for (const entry of reference.pages) {
875
+ expect(live(entry.content), entry.slug).toEqual([]);
876
+ if (entry.intro) expect(live(entry.intro), `${entry.slug} intro`).toEqual([]);
877
+ }
878
+ const overview = page(reference, 'package-overview');
879
+ expect(overview.content).toContain('&#105;mport x from "y"');
880
+ expect(overview.content).toContain('&#101;xport GOPRIVATE=example.com/\\*');
881
+ const record = page(reference, 'Record');
882
+ expect(record.content).toContain('**Tag:** `json:"raw" extra:"{alert(1)}"`');
883
+ expect(record.content).not.toMatch(/\n\n\{alert/);
884
+ const intro = overview.intro ?? '';
885
+ expect(intro).toContain('&#105;mport fs from "node:fs"');
886
+ expect(intro).toContain('&#101;xport default function X()\\{}');
887
+ expect(intro).toContain('Env\n\n&#101;xport FOO=bar');
888
+ expect(intro).toContain('first\n\n&#105;mport os from "node:os"');
889
+ expect(intro).toContain('&#105;mport starts this paragraph after an inline tag.');
890
+ expect(page(reference, 'Do').content).toContain('&#101;xport is the first word after a tag.');
891
+ expect(intro).toContain('&#105;mport x from "y"');
892
+ expect(intro).toContain('&#101;xport const a = 1');
893
+ expect(intro).toContain('See one, two, three, and four.');
894
+ expect(page(reference, 'Do').content).toContain('&#105;mport x from "y"');
895
+ });
896
+
897
+ it('escapes ESM in HTML blocks with CRLF or bare CR line endings', async () => {
898
+ const block = (eol: string, open: string, close: string) =>
899
+ [open, 'import a from "a"', '', 'export const b = 1', close].join(eol);
900
+ const reference = await convertGo(
901
+ await artifact('cr-html', [
902
+ {
903
+ importPath: 'example.com/acme',
904
+ name: 'acme',
905
+ dir: '.',
906
+ doc: 'Package acme is the client.',
907
+ readme: [
908
+ block('\r\n', '<pre>', '</pre>'),
909
+ block('\r', '<!--', '-->'),
910
+ block('\r\n', '<details>', '</details>'),
911
+ ].join('\r\n\r\n'),
912
+ },
913
+ ])
914
+ );
915
+ const intro = page(reference, 'package-overview').intro ?? '';
916
+ expect(intro).not.toMatch(/\r/);
917
+ // The comment's text is dropped with it; <pre> and <details> keep theirs.
918
+ expect(intro.match(/&#105;mport a from "a"/g)).toHaveLength(2);
919
+ expect(intro.match(/&#101;xport const b = 1/g)).toHaveLength(2);
920
+ const tree = unified().use(remarkParse).use(remarkMdx).parse(intro);
921
+ const esm: string[] = [];
922
+ visit(tree, 'mdxjsEsm', (node: { value: string }) => {
923
+ esm.push(node.value);
924
+ });
925
+ expect(esm).toEqual([]);
926
+ });
927
+
928
+ it('keeps a bare carriage return from ending a code span', async () => {
929
+ const reference = await convertGo(
930
+ await artifact('carriage-return', [
931
+ {
932
+ importPath: 'example.com/acme',
933
+ name: 'acme',
934
+ dir: '.',
935
+ types: [
936
+ { name: 'Widget', kind: 'struct', decl: [{ t: 'type Widget struct{}' }] },
937
+ {
938
+ name: 'Record',
939
+ kind: 'struct',
940
+ decl: [{ t: 'type Record struct{}' }],
941
+ fields: [
942
+ { name: 'Raw', type: [{ t: 'string' }], tag: 'a\r\r<Danger>{alert(1)}</Danger>' },
943
+ ],
944
+ methods: [
945
+ {
946
+ name: 'Do',
947
+ decl: [{ t: 'func (r Record) Do() *Widget' }],
948
+ results: [
949
+ { type: [{ t: 'Opt\r\r' }, { t: 'Widget', ref: { name: 'Widget' } }] },
950
+ ],
951
+ },
952
+ ],
953
+ },
954
+ ],
955
+ },
956
+ ])
957
+ );
958
+ const record = page(reference, 'Record').content;
959
+ expect(record).toContain('**Tag:** `a <Danger>{alert(1)}</Danger>`');
960
+ expect(record).toContain('<code>{"Opt "}[Widget](/Widget)</code>');
961
+ expect(record).not.toMatch(/\r/);
962
+ });
963
+
964
+ it('collapses newlines inside code spans and linked code', async () => {
965
+ const reference = await convertGo(
966
+ await artifact('newlines', [
967
+ {
968
+ importPath: 'example.com/acme',
969
+ name: 'acme',
970
+ dir: '.',
971
+ types: [
972
+ { name: 'Widget', kind: 'struct', decl: [{ t: 'type Widget struct{}' }] },
973
+ {
974
+ name: 'Record',
975
+ kind: 'struct',
976
+ decl: [{ t: 'type Record struct{}' }],
977
+ fields: [
978
+ { name: 'Raw', type: [{ t: 'string' }], tag: 'json:"x"\n\n{alert(5)}\n\nhi' },
979
+ ],
980
+ methods: [
981
+ {
982
+ name: 'Do',
983
+ decl: [{ t: 'func (r Record) Do() (*Widget, error)' }],
984
+ results: [
985
+ {
986
+ type: [
987
+ { t: '*\n\n{alert(3)}\n\n' },
988
+ { t: 'Widget', ref: { name: 'Widget' } },
989
+ ],
990
+ },
991
+ { type: [{ t: 'error\n\n{alert(4)}' }] },
992
+ ],
993
+ },
994
+ {
995
+ name: 'Mode',
996
+ decl: [{ t: 'func (r Record) Mode() string' }],
997
+ results: [{ type: [{ t: '"a b" |\t"c"' }] }],
998
+ },
999
+ ],
1000
+ },
1001
+ ],
1002
+ },
1003
+ ])
1004
+ );
1005
+ const record = page(reference, 'Record').content;
1006
+ expect(record).toContain('**Tag:** `json:"x" {alert(5)} hi`');
1007
+ expect(record).toContain(
1008
+ '<code>{"(* {alert(3)} "}[Widget](/Widget){", error {alert(4)})"}</code>'
1009
+ );
1010
+ expect(record).toContain('`"a b" |\t"c"`');
1011
+ expect(record).not.toMatch(/\n\{alert/);
1012
+ });
1013
+
1014
+ it('drops unsafe URL schemes from READMEs and doc comments', async () => {
1015
+ const reference = await convertGo(
1016
+ await artifact(
1017
+ 'schemes',
1018
+ [
1019
+ {
1020
+ importPath: 'github.com/acme/acme-go',
1021
+ name: 'acme',
1022
+ dir: '.',
1023
+ doc: 'Package acme is the client.',
1024
+ readme:
1025
+ '[a](javascript:alert(2)) ![i](data:text/html,x) [ok](https://acme.dev) [mail](mailto:x@y.z) [rel](docs/a.md)',
1026
+ types: [
1027
+ {
1028
+ name: 'Client',
1029
+ kind: 'struct',
1030
+ decl: [{ t: 'type Client struct{}' }],
1031
+ doc: 'See [bad](javascript:alert(1)) and [good](https://acme.dev) and [Client](go:github.com/acme/acme-go#Client).',
1032
+ },
1033
+ ],
1034
+ },
1035
+ ],
1036
+ 'github.com/acme/acme-go'
1037
+ )
1038
+ );
1039
+ expect(page(reference, 'package-overview').intro).toBe(
1040
+ 'a i [ok](https://acme.dev) [mail](mailto:x@y.z) [rel](https://github.com/acme/acme-go/blob/HEAD/docs/a.md)'
1041
+ );
1042
+ expect(page(reference, 'Client').content).toContain(
1043
+ 'See bad and [good](https://acme.dev) and [Client](/Client).'
1044
+ );
1045
+ });
1046
+
1047
+ it('writes out long unlinked field types in full', async () => {
1048
+ const long = `struct { ${Array.from({ length: 12 }, (_, i) => `F${i} string`).join('; ')} }`;
1049
+ const reference = await convertGo(
1050
+ await artifact('long-types', [
1051
+ {
1052
+ importPath: 'example.com/acme',
1053
+ name: 'acme',
1054
+ dir: '.',
1055
+ types: [
1056
+ {
1057
+ name: 'Record',
1058
+ kind: 'struct',
1059
+ decl: [{ t: 'type Record struct{}' }],
1060
+ fields: [
1061
+ { name: 'Short', type: [{ t: 'map[string]int' }] },
1062
+ { name: 'Long', type: [{ t: long }] },
1063
+ ],
1064
+ },
1065
+ ],
1066
+ },
1067
+ ])
1068
+ );
1069
+ const record = page(reference, 'Record').content;
1070
+ expect(record).toContain(`**Type:** \`${long}\``);
1071
+ expect(record).not.toContain('**Type:** `map[string]int`');
1072
+ });
1073
+
1074
+ it('drops reference definitions that spell a scheme with character references', async () => {
1075
+ const readme = [
1076
+ 'See [one][r1], [two][r2], [three][r3], [four][r4], and [ok][r5].',
1077
+ '',
1078
+ '[r1]: javascript&amp;colon;alert(1)',
1079
+ '[r2]: javascript&#58;alert(2)',
1080
+ '[r3]: javascript&#x3a;alert(3)',
1081
+ '[r4]: <javascript&colon;alert(4)>',
1082
+ '[r5]: docs/a&amp;b.md',
1083
+ ].join('\n');
1084
+ const reference = await convertGo(
1085
+ await artifact(
1086
+ 'entity-schemes',
1087
+ [
1088
+ {
1089
+ importPath: 'gitlab.com/acme/acme-go',
1090
+ name: 'acme',
1091
+ dir: '.',
1092
+ doc: 'Package acme is the client.',
1093
+ readme,
1094
+ types: [
1095
+ {
1096
+ name: 'Client',
1097
+ kind: 'struct',
1098
+ decl: [{ t: 'type Client struct{}' }],
1099
+ doc: 'See [bad][r].\n\n[r]: javascript&colon;alert(1)',
1100
+ },
1101
+ ],
1102
+ },
1103
+ ],
1104
+ 'gitlab.com/acme/acme-go'
1105
+ )
1106
+ );
1107
+ const urls = (mdx: string) => {
1108
+ const found: string[] = [];
1109
+ visit(unified().use(remarkParse).use(remarkMdx).parse(mdx), (node) => {
1110
+ const url = (node as { url?: string }).url;
1111
+ if (url !== undefined) found.push(url);
1112
+ });
1113
+ return found;
1114
+ };
1115
+ const intro = page(reference, 'package-overview').intro ?? '';
1116
+ expect(intro).toContain('See one, two, three, four, and [ok][r5].');
1117
+ expect(urls(intro)).toEqual(['docs/a&b.md']);
1118
+ const client = page(reference, 'Client').content;
1119
+ expect(client).toContain('See bad.');
1120
+ expect(urls(client).filter((url) => /javascript/i.test(url))).toEqual([]);
1121
+ });
1122
+
1123
+ it('skips HTML titles for the description and drops a README sentence the synopsis repeats', async () => {
1124
+ const reference = await convertGo(
1125
+ await artifact('readme-descriptions', [
1126
+ {
1127
+ importPath: 'example.com/acme',
1128
+ name: 'acme',
1129
+ dir: '.',
1130
+ readme: '<h1 align="center">Acme</h1>\n\nPackage acme talks to\nthe API.',
1131
+ },
1132
+ {
1133
+ importPath: 'example.com/acme/client',
1134
+ name: 'client',
1135
+ dir: 'client',
1136
+ doc: 'Package client calls the API. It retries.',
1137
+ readme: 'Package client calls the API. Use [New] to start.\n\nMore.',
1138
+ },
1139
+ {
1140
+ importPath: 'example.com/acme/breaks',
1141
+ name: 'breaks',
1142
+ dir: 'breaks',
1143
+ doc: 'Package breaks has a hard \nline break.',
1144
+ },
1145
+ ])
1146
+ );
1147
+ const root = page(reference, 'package-overview');
1148
+ expect(root.description).toBe('Package acme talks to the API.');
1149
+ expect(root.intro).toBe('Acme');
1150
+ const client = page(reference, 'client/package-overview');
1151
+ expect(client.description).toBe('Package client calls the API.');
1152
+ expect(client.intro).toBe('Use \\[New] to start.\n\nMore.');
1153
+ expect(page(reference, 'breaks/package-overview').description).toBe(
1154
+ 'Package breaks has a hard line break.'
1155
+ );
1156
+ });
1157
+
1158
+ it('uses a long single-sentence README paragraph and drops a marked-up repeated synopsis', async () => {
1159
+ const long = `Package acme ${'talks to the API '.repeat(15).trim()}.`;
1160
+ const reference = await convertGo(
1161
+ await artifact('sentence-edges', [
1162
+ { importPath: 'example.com/acme', name: 'acme', dir: '.', readme: `${long}\n\nMore.` },
1163
+ {
1164
+ importPath: 'example.com/acme/client',
1165
+ name: 'client',
1166
+ dir: 'client',
1167
+ doc: 'Package client calls the Acme API. It retries.',
1168
+ readme:
1169
+ 'Package *client* calls the [Acme](https://acme.dev) API. Use [New] to start.\n\nMore.',
1170
+ },
1171
+ {
1172
+ importPath: 'example.com/acme/breaks',
1173
+ name: 'breaks',
1174
+ dir: 'breaks',
1175
+ doc: 'Package breaks calls the API. It retries.',
1176
+ readme: 'Package breaks calls the **API.**\\\nUse [New] to start.',
1177
+ },
1178
+ {
1179
+ importPath: 'example.com/acme/kept',
1180
+ name: 'kept',
1181
+ dir: 'kept',
1182
+ doc: 'Package kept calls the API. It retries.',
1183
+ readme: 'Package kept calls the *API. Really* well. Use it.',
1184
+ },
1185
+ ])
1186
+ );
1187
+ const root = page(reference, 'package-overview');
1188
+ expect(root.description).toBe(`${long.slice(0, 159)}…`);
1189
+ expect(root.intro).toBe('More.');
1190
+ const client = page(reference, 'client/package-overview');
1191
+ expect(client.description).toBe('Package client calls the Acme API.');
1192
+ expect(client.intro).toBe('Use \\[New] to start.\n\nMore.');
1193
+ expect(page(reference, 'breaks/package-overview').intro).toBe('Use \\[New] to start.');
1194
+ expect(page(reference, 'kept/package-overview').intro).toBe(
1195
+ 'Package kept calls the *API. Really* well. Use it.'
1196
+ );
1197
+ });
1198
+
1199
+ it('renders pathological Markdown as text instead of parsing it', async () => {
1200
+ const hostile = '[a]('.repeat(80000);
1201
+ const start = Date.now();
1202
+ const reference = await convertGo(
1203
+ await artifact('pathological', [
1204
+ {
1205
+ importPath: 'example.com/acme',
1206
+ name: 'acme',
1207
+ dir: '.',
1208
+ doc: hostile,
1209
+ readme: hostile,
1210
+ types: [
1211
+ {
1212
+ name: 'Client',
1213
+ kind: 'struct',
1214
+ decl: [{ t: 'type Client struct{}' }],
1215
+ doc: hostile,
1216
+ },
1217
+ ],
1218
+ },
1219
+ ])
1220
+ );
1221
+ expect(Date.now() - start).toBeLessThan(5000);
1222
+ const overview = page(reference, 'package-overview');
1223
+ expect(overview.intro).toMatch(/^```text\n\[a\]\(/);
1224
+ expect(page(reference, 'Client').content).toContain('```text\n[a](');
1225
+ });
1226
+
1227
+ it('widens code fences past any backtick run in tags, types, and code', async () => {
1228
+ const fenced = 'x\n```\n{alert(1)}\nimport x from "y"\n````\n{alert(2)}';
1229
+ const reference = await convertGo(
1230
+ await artifact('fences', [
1231
+ {
1232
+ importPath: 'example.com/acme',
1233
+ name: 'acme',
1234
+ dir: '.',
1235
+ doc: 'Package acme is the client.',
1236
+ examples: [{ code: fenced, output: fenced }],
1237
+ types: [
1238
+ {
1239
+ name: 'Record',
1240
+ kind: 'struct',
1241
+ decl: [{ t: `type Record struct{} // ${fenced}` }],
1242
+ fields: [
1243
+ { name: 'A', type: [{ t: 'string' }], tag: 'a`{alert(1)}`b' },
1244
+ { name: 'B', type: [{ t: 'string' }], tag: '`lead' },
1245
+ { name: 'C', type: [{ t: 'string' }], tag: 'trail`' },
1246
+ ],
1247
+ methods: [
1248
+ {
1249
+ name: 'Do',
1250
+ decl: [{ t: 'func (r Record) Do() string' }],
1251
+ results: [{ type: [{ t: 'a``{alert(3)}``b' }] }],
1252
+ },
1253
+ ],
1254
+ },
1255
+ ],
1256
+ },
1257
+ ])
1258
+ );
1259
+ expectNoLiveMdx(reference);
1260
+ const record = page(reference, 'Record').content;
1261
+ expect(record).toContain('**Tag:** ``a`{alert(1)}`b``');
1262
+ expect(record).toContain('**Tag:** `` `lead ``');
1263
+ expect(record).toContain('**Tag:** `` trail` ``');
1264
+ expect(record).toContain('```a``{alert(3)}``b```');
1265
+ expect(record).toContain('`````go\ntype Record struct{}');
1266
+ expect(page(reference, 'package-overview').content).toContain('`````go\nx\n```');
1267
+ });
1268
+
1269
+ it('escapes braces, angle brackets, and brackets in linked type text', async () => {
1270
+ const reference = await convertGo(
1271
+ await artifact('linked-text', [
1272
+ {
1273
+ importPath: 'example.com/acme',
1274
+ name: 'acme',
1275
+ dir: '.',
1276
+ doc: 'Package acme is the client.',
1277
+ types: [
1278
+ { name: 'Widget', kind: 'struct', decl: [{ t: 'type Widget struct{}' }] },
1279
+ {
1280
+ name: 'Record',
1281
+ kind: 'struct',
1282
+ decl: [{ t: 'type Record struct{}' }],
1283
+ fields: [
1284
+ {
1285
+ name: 'W',
1286
+ type: [
1287
+ { t: 'map[string]' },
1288
+ { t: '{alert(1)}<b>[x]', ref: { name: 'Widget' } },
1289
+ ],
1290
+ },
1291
+ ],
1292
+ },
1293
+ ],
1294
+ },
1295
+ ])
1296
+ );
1297
+ expectNoLiveMdx(reference);
1298
+ expect(page(reference, 'Record').content).toContain(
1299
+ '[\\{alert(1)\\}\\<b\\>\\[x\\]](/Widget)'
1300
+ );
1301
+ });
1302
+
1303
+ it('escapes ESM in HTML blocks with bare CR line endings', async () => {
1304
+ const readme = [
1305
+ '<pre>',
1306
+ 'import a from "a"',
1307
+ '',
1308
+ 'export const b = 1',
1309
+ '</pre>',
1310
+ '',
1311
+ '<details>',
1312
+ 'export FOO=bar',
1313
+ '</details>',
1314
+ ].join('\r');
1315
+ const reference = await convertGo(
1316
+ await artifact('bare-cr', [
1317
+ { importPath: 'example.com/acme', name: 'acme', dir: '.', doc: 'Package acme.', readme },
1318
+ ])
1319
+ );
1320
+ expectNoLiveMdx(reference);
1321
+ const intro = page(reference, 'package-overview').intro ?? '';
1322
+ expect(intro).toContain('&#105;mport a from "a"');
1323
+ expect(intro).toContain('&#101;xport const b = 1');
1324
+ expect(intro).toContain('&#101;xport FOO=bar');
1325
+ });
1326
+
1327
+ it('keeps hostile inputs at the size caps fast', async () => {
1328
+ within(1000, () => {
1329
+ expect(safeHref(`a${' '.repeat(1 << 20)}b`)).toBe(`a${' '.repeat(1 << 20)}b`);
1330
+ });
1331
+ const start = performance.now();
1332
+ const reference = await convertGo(
1333
+ await artifact('size-caps', [
1334
+ {
1335
+ importPath: 'example.com/acme',
1336
+ name: 'acme',
1337
+ dir: '.',
1338
+ doc: `<div>\n${'<'.repeat((1 << 20) - 6)}`,
1339
+ readme: ')'.repeat(2000),
1340
+ },
1341
+ ])
1342
+ );
1343
+ expect(performance.now() - start).toBeLessThan(5000);
1344
+ expect(page(reference, 'package-overview').intro).toBe(
1345
+ `\`\`\`text\n${')'.repeat(2000)}\n\`\`\``
1346
+ );
1347
+ const tooBig = await artifact('too-big', [
1348
+ { importPath: 'example.com/acme', name: 'acme', dir: '.', doc: 'x'.repeat((1 << 20) + 1) },
1349
+ ]);
1350
+ await expect(convertGo(tooBig)).rejects.toThrow('packages.0.doc');
1351
+ });
1352
+
1353
+ it('renders balanced but heavy markup as text instead of parsing it', async () => {
1354
+ const start = performance.now();
1355
+ const reference = await convertGo(
1356
+ await artifact('heavy-markup', [
1357
+ {
1358
+ importPath: 'example.com/acme',
1359
+ name: 'acme',
1360
+ dir: '.',
1361
+ doc: 'Package acme.',
1362
+ readme: `${'['.repeat(20000)}${']'.repeat(20000)}`,
1363
+ types: [
1364
+ {
1365
+ name: 'A',
1366
+ kind: 'struct',
1367
+ decl: [{ t: 'type A struct{}' }],
1368
+ doc: '*a'.repeat(20000),
1369
+ },
1370
+ ],
1371
+ },
1372
+ ])
1373
+ );
1374
+ expect(performance.now() - start).toBeLessThan(5000);
1375
+ expect(page(reference, 'package-overview').intro).toMatch(/^```text\n\[\[/);
1376
+ expect(page(reference, 'A').content).toContain('```text\n*a*a');
1377
+ });
1378
+
1379
+ it('resolves package-only and unknown-member doc links and encodes pkg.go.dev paths', async () => {
1380
+ const reference = await convertGo(
1381
+ await artifact(
1382
+ 'doc-links',
1383
+ [
1384
+ {
1385
+ importPath: 'github.com/acme/acme-go',
1386
+ name: 'acme',
1387
+ dir: '.',
1388
+ doc: '**Package acme** is _the_ client.',
1389
+ readme: 'Up [two](../../x.md) and [one](../y.md).',
1390
+ types: [
1391
+ {
1392
+ name: 'Client',
1393
+ kind: 'struct',
1394
+ decl: [{ t: 'type Client struct{}' }],
1395
+ doc: 'See [acme](go:github.com/acme/acme-go), [Client.Missing](go:github.com/acme/acme-go#Client.Missing), and [T](go:example.org/a(b)#T).',
1396
+ },
1397
+ ],
1398
+ },
1399
+ ],
1400
+ 'github.com/acme/acme-go'
1401
+ )
1402
+ );
1403
+ const overview = page(reference, 'package-overview');
1404
+ expect(overview.description).toBe('Package acme is the client.');
1405
+ expect(overview.intro).toBe('Up [two](../../x.md) and [one](../y.md).');
1406
+ expect(page(reference, 'Client').content).toContain(
1407
+ 'See [acme](/package-overview), [Client.Missing](/Client), and [T](https://pkg.go.dev/example.org/a%28b%29#T).'
1408
+ );
1409
+ });
1410
+
1411
+ it('reports every candidate for an ambiguous method and lets an exact one win', async () => {
1412
+ const client = {
1413
+ name: 'Client',
1414
+ kind: 'struct',
1415
+ decl: [{ t: 'type Client struct{}' }],
1416
+ methods: [{ name: 'Do', decl: [{ t: 'func (c *Client) Do()' }] }],
1417
+ };
1418
+ const reference = await convertGo(
1419
+ await artifact('ambiguous-methods', [
1420
+ { importPath: 'example.com/acme', name: 'acme', dir: '.', types: [client] },
1421
+ { importPath: 'example.com/acme/v2', name: 'acme', dir: 'v2', types: [client] },
1422
+ ])
1423
+ );
1424
+ expect(() =>
1425
+ findSdkPage(reference, { kind: 'method', name: 'Do', parent: 'Client' })
1426
+ ).toThrow('method Client.Do matches acme.Client.Do, acme.v2.Client.Do');
1427
+ expect(
1428
+ findSdkPage(reference, { kind: 'method', name: 'Do', parent: 'acme.Client' })?.slug
1429
+ ).toBe('Client/Do');
1430
+ expect(
1431
+ findSdkPage(reference, {
1432
+ kind: 'method',
1433
+ name: 'Do',
1434
+ parent: 'Client',
1435
+ namespace: 'acme.v2',
1436
+ })?.slug
1437
+ ).toBe('v2/Client/Do');
1438
+ });
1439
+
1440
+ it('drops protocol-relative URLs spelled with character references after a slash', async () => {
1441
+ expect(safeHref('/&sol;evil.com')).toBeUndefined();
1442
+ expect(safeHref('\\&#47;evil.com')).toBeUndefined();
1443
+ expect(safeHref('/docs/a&amp;b.md')).toBe('/docs/a&amp;b.md');
1444
+ });
1445
+
1446
+ it('keeps spaces inside relative README links', async () => {
1447
+ const reference = await convertGo(
1448
+ await artifact(
1449
+ 'spaced-links',
1450
+ [
1451
+ {
1452
+ importPath: 'github.com/acme/acme-go',
1453
+ name: 'acme',
1454
+ dir: '.',
1455
+ doc: 'Package acme is the client.',
1456
+ readme: '[guide](<docs/Getting Started.md>) and ![shot](<img/a b.png>)',
1457
+ },
1458
+ ],
1459
+ 'github.com/acme/acme-go'
1460
+ )
1461
+ );
1462
+ expect(page(reference, 'package-overview').intro).toBe(
1463
+ '[guide](<https://github.com/acme/acme-go/blob/HEAD/docs/Getting Started.md>) and ![shot](<https://github.com/acme/acme-go/raw/HEAD/img/a b.png>)'
1464
+ );
1465
+ });
1466
+
1467
+ it('keeps names apart that differ only in Unicode normalisation', async () => {
1468
+ const composed = 'A\uAC00';
1469
+ const decomposed = 'A\u1100\u1161';
1470
+ const reference = await convertGo(
1471
+ await artifact('unicode-forms', [
1472
+ {
1473
+ importPath: 'example.com/acme',
1474
+ name: 'acme',
1475
+ dir: '.',
1476
+ types: [composed, decomposed].map((name) => ({
1477
+ name,
1478
+ kind: 'struct',
1479
+ decl: [{ t: `type ${name} struct{}` }],
1480
+ })),
1481
+ },
1482
+ ])
1483
+ );
1484
+ const slugs = reference.pages
1485
+ .filter((entry) => entry.tag === 'STRUCT')
1486
+ .map((entry) => entry.slug.normalize('NFC').toLowerCase());
1487
+ expect(new Set(slugs).size).toBe(2);
1488
+ });
1489
+
1490
+ it('drops URLs that only look relative until a browser normalises them', async () => {
1491
+ const readme = [
1492
+ '[tab](<java\tscript:alert(1)>)',
1493
+ '[ctl](<\u0001javascript:alert(1)>)',
1494
+ '[spaced](< javascript:alert(1)>)',
1495
+ '[bs](\\\\evil.com)',
1496
+ '[sbs](/\\evil.com)',
1497
+ '[rel](docs/a.md)',
1498
+ ].join(' ');
1499
+ const reference = await convertGo(
1500
+ await artifact(
1501
+ 'normalised-schemes',
1502
+ [
1503
+ {
1504
+ importPath: 'gitlab.com/acme/acme-go',
1505
+ name: 'acme',
1506
+ dir: '.',
1507
+ doc: 'Package acme is the client.',
1508
+ readme,
1509
+ },
1510
+ ],
1511
+ 'gitlab.com/acme/acme-go'
1512
+ )
1513
+ );
1514
+ expect(page(reference, 'package-overview').intro).toBe(
1515
+ 'tab ctl spaced bs sbs [rel](docs/a.md)'
1516
+ );
1517
+ });
1518
+
1519
+ it('resolves root-relative README links against the repository root', async () => {
1520
+ const reference = await convertGo(
1521
+ await artifact(
1522
+ 'root-links',
1523
+ [
1524
+ {
1525
+ importPath: 'github.com/acme/acme-go/option',
1526
+ name: 'option',
1527
+ dir: 'option',
1528
+ readme: '[abs](/docs/a.md) [rel](b.md) [both][d] ![both][d]\n\n[d]: img.png',
1529
+ },
1530
+ ],
1531
+ 'github.com/acme/acme-go'
1532
+ )
1533
+ );
1534
+ expect(page(reference, 'option/package-overview').intro).toBe(
1535
+ '[abs](https://github.com/acme/acme-go/blob/HEAD/docs/a.md) [rel](https://github.com/acme/acme-go/blob/HEAD/option/b.md) [both][d] ![both][d]\n\n[d]: https://github.com/acme/acme-go/blob/HEAD/option/img.png'
1536
+ );
1537
+ });
1538
+
1539
+ it('rejects traversal in package directories and duplicate import paths', async () => {
1540
+ await expect(
1541
+ convertGo(
1542
+ await artifact('bad-dir', [
1543
+ { importPath: 'example.com/acme/x', name: 'x', dir: '../../etc' },
1544
+ ])
1545
+ )
1546
+ ).rejects.toThrow('packages.0.dir: expected "." or a relative directory');
1547
+ await expect(
1548
+ convertGo(
1549
+ await artifact('dup', [
1550
+ { importPath: 'example.com/acme', name: 'acme', dir: '.' },
1551
+ { importPath: 'example.com/acme', name: 'acme', dir: 'other' },
1552
+ ])
1553
+ )
1554
+ ).rejects.toThrow('packages.1.importPath: duplicate package example.com/acme');
1555
+ });
1556
+
1557
+ it('leaves doc links inside code spans untouched', async () => {
1558
+ const doc =
1559
+ 'Call `[y](go:q`) then ` alert(document.domain)} />` done. See [Client](go:example.com/acme#Client).';
1560
+ const reference = await convertGo(
1561
+ await artifact('code-span-links', [
1562
+ {
1563
+ importPath: 'example.com/acme',
1564
+ name: 'acme',
1565
+ dir: '.',
1566
+ types: [{ name: 'Client', kind: 'struct', decl: [{ t: 'type Client struct{}' }], doc }],
1567
+ },
1568
+ ])
1569
+ );
1570
+ const content = page(reference, 'Client').content;
1571
+ expect(content).toContain(
1572
+ 'Call `[y](go:q`) then ` alert(document.domain)} />` done. See [Client](/Client).'
1573
+ );
1574
+ expect(content).not.toContain('pkg.go.dev');
1575
+ });
1576
+
1577
+ it('escapes linked code text and external hrefs', async () => {
1578
+ const reference = await convertGo(
1579
+ await artifact('escape', [
1580
+ {
1581
+ importPath: 'example.com/acme',
1582
+ name: 'acme',
1583
+ dir: '.',
1584
+ types: [
1585
+ {
1586
+ name: 'Client',
1587
+ kind: 'struct',
1588
+ decl: [{ t: 'type Client struct{}' }],
1589
+ fields: [
1590
+ {
1591
+ name: 'Ext',
1592
+ type: [
1593
+ { t: 'map[' },
1594
+ { t: 'x]*', ref: { pkg: 'gopkg.in/yaml.v3', name: 'T' } },
1595
+ { t: ']' },
1596
+ ],
1597
+ },
1598
+ ],
1599
+ },
1600
+ ],
1601
+ },
1602
+ ])
1603
+ );
1604
+ expect(page(reference, 'Client').content).toContain(
1605
+ '<code>{"map["}[x\\]\\*](https://pkg.go.dev/gopkg.in/yaml.v3#T){"]"}</code>'
1606
+ );
1607
+ });
1608
+
1609
+ it('demotes underlined comment headings', async () => {
1610
+ const reference = await convertGo(
1611
+ await artifact('setext', [
1612
+ {
1613
+ importPath: 'example.com/acme',
1614
+ name: 'acme',
1615
+ dir: '.',
1616
+ types: [
1617
+ {
1618
+ name: 'Client',
1619
+ kind: 'struct',
1620
+ decl: [{ t: 'type Client struct{}' }],
1621
+ doc: 'Client is a client.\n\nFormat\n------\n\nDetails.',
1622
+ },
1623
+ ],
1624
+ },
1625
+ ])
1626
+ );
1627
+ expect(page(reference, 'Client').content).toContain(
1628
+ 'Client is a client.\n\n#### Format\n\nDetails.'
1629
+ );
1630
+ });
1631
+ });
1632
+
1633
+ describe('artifact validation', () => {
1634
+ let dir: string;
1635
+
1636
+ beforeAll(async () => {
1637
+ dir = await mkdtemp(path.join(os.tmpdir(), 'go-artifact-'));
1638
+ });
1639
+
1640
+ afterAll(async () => {
1641
+ await rm(dir, { recursive: true, force: true });
1642
+ });
1643
+
1644
+ it('rejects unsupported schema versions', async () => {
1645
+ const file = path.join(dir, 'future.json');
1646
+ await writeFile(file, JSON.stringify({ schemaVersion: 2, packages: [] }));
1647
+ await expect(convertGo(file)).rejects.toThrow(
1648
+ 'uses schema version 2, but Mintlify reads schema version 1'
1649
+ );
1650
+ });
1651
+
1652
+ it('rejects malformed artifacts', async () => {
1653
+ const file = path.join(dir, 'invalid.json');
1654
+ await writeFile(
1655
+ file,
1656
+ JSON.stringify({
1657
+ schemaVersion: 1,
1658
+ generator: { name: 'mintlify-go-extractor', version: 'test', go: 'go1.22' },
1659
+ module: 'example.com/acme',
1660
+ goos: 'linux',
1661
+ goarch: 'amd64',
1662
+ commentSyntax: 'markdown',
1663
+ packages: [{ importPath: 'example.com/acme', name: 'acme' }],
1664
+ })
1665
+ );
1666
+ await expect(convertGo(file)).rejects.toThrow(
1667
+ 'Invalid Go SDK artifact ' + file + ': packages.0.dir'
1668
+ );
1669
+ });
1670
+ });
1671
+ });