Skip to content

Commit 8851110

Browse files
panvaavivkeller
andauthored
fix(web): shorten method labels in sidebar (#798)
Co-authored-by: avivkeller <me@aviv.sh>
1 parent 14a7014 commit 8851110

6 files changed

Lines changed: 253 additions & 18 deletions

File tree

src/generators/jsx-ast/utils/__tests__/buildBarProps.test.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,71 @@ describe('extractHeadings', () => {
8383
const result = extractHeadings(entries);
8484
assert.equal(result.length, 0);
8585
});
86+
87+
it('labels callables with the bare name instead of the full signature', () => {
88+
const entries = [
89+
{
90+
heading: {
91+
depth: 2,
92+
data: {
93+
text: 'fs.read(fd, buffer, offset, length, position, callback)',
94+
name: 'fs.read',
95+
slug: 'fsreadfd',
96+
type: 'method',
97+
},
98+
},
99+
},
100+
{
101+
heading: {
102+
depth: 2,
103+
data: {
104+
text: 'new Buffer(size)',
105+
name: 'Buffer',
106+
slug: 'new-buffersize',
107+
type: 'ctor',
108+
},
109+
},
110+
},
111+
];
112+
113+
const result = extractHeadings(entries);
114+
115+
assert.equal(result[0].value, 'fs.read');
116+
assert.equal(result[1].value, 'new Buffer');
117+
});
118+
119+
it('drops overload headings and links to the first signature', () => {
120+
const entries = [
121+
{
122+
heading: {
123+
depth: 2,
124+
data: {
125+
text: 'fs.read(fd)',
126+
name: 'fs.read',
127+
slug: 'fsreadfd',
128+
type: 'method',
129+
},
130+
},
131+
},
132+
{
133+
heading: {
134+
depth: 2,
135+
data: {
136+
text: 'fs.read(fd, options)',
137+
name: 'fs.read',
138+
slug: 'fsreadoptions',
139+
type: 'method',
140+
isOverload: true,
141+
},
142+
},
143+
},
144+
];
145+
146+
const result = extractHeadings(entries);
147+
148+
assert.equal(result.length, 1);
149+
assert.equal(result[0].slug, 'fsreadfd');
150+
assert.equal(result[0].data.id, 'fsreadfd');
151+
assert.equal(result[0].value, 'fs.read');
152+
});
86153
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { annotateOverloads } from '../overloads.mjs';
5+
6+
const entry = (name, type, slug, depth = 2, text = `${name}(...)`) => ({
7+
heading: { depth, data: { name, type, slug, text } },
8+
});
9+
10+
describe('annotateOverloads', () => {
11+
it('flags every overload after the first heading of a run', () => {
12+
const entries = [
13+
entry('fs.read', 'method', 'fsreadfd'),
14+
entry('fs.read', 'method', 'fsreadbuffer'),
15+
entry('fs.read', 'method', 'fsreadoptions'),
16+
];
17+
18+
annotateOverloads(entries);
19+
20+
// The first (most stable) heading is left as-is...
21+
assert.ok(!entries[0].heading.data.isOverload);
22+
// ...and the rest are flagged so the ToC can drop them.
23+
assert.ok(entries[1].heading.data.isOverload);
24+
assert.ok(entries[2].heading.data.isOverload);
25+
});
26+
27+
it('leaves a single-signature function untouched', () => {
28+
const entries = [entry('fs.access', 'method', 'fsaccess')];
29+
30+
annotateOverloads(entries);
31+
32+
assert.ok(!entries[0].heading.data.isOverload);
33+
});
34+
35+
it('groups constructors by name', () => {
36+
const entries = [
37+
entry('Buffer', 'ctor', 'new-bufferarray'),
38+
entry('Buffer', 'ctor', 'new-buffersize'),
39+
];
40+
41+
annotateOverloads(entries);
42+
43+
assert.ok(!entries[0].heading.data.isOverload);
44+
assert.ok(entries[1].heading.data.isOverload);
45+
});
46+
47+
it('does not group headings of different types or depths', () => {
48+
const entries = [
49+
entry('Buffer', 'class', 'class-buffer'),
50+
entry('Buffer', 'ctor', 'new-bufferarray'),
51+
entry('Buffer', 'ctor', 'new-buffersize', 3),
52+
];
53+
54+
annotateOverloads(entries);
55+
56+
// class is not overloadable; the two ctors differ in depth, so none group.
57+
assert.ok(entries.every(e => !e.heading.data.isOverload));
58+
});
59+
60+
it('starts a fresh group when a different function interrupts a run', () => {
61+
const entries = [
62+
entry('fs.read', 'method', 'fsreadfd'),
63+
entry('fs.read', 'method', 'fsreadbuffer'),
64+
entry('fs.write', 'method', 'fswritefd'),
65+
entry('fs.write', 'method', 'fswritebuffer'),
66+
];
67+
68+
annotateOverloads(entries);
69+
70+
assert.deepEqual(
71+
entries.map(e => !!e.heading.data.isOverload),
72+
[false, true, false, true]
73+
);
74+
});
75+
});

src/generators/jsx-ast/utils/buildBarProps.mjs

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22

33
import { visit } from 'unist-util-visit';
44

5+
import { getFullName } from './signature.mjs';
56
import { TOC_MAX_HEADING_DEPTH } from '../constants.mjs';
67

8+
// Callable heading types whose ToC label should be the bare function name
9+
// rather than the full signature.
10+
const FUNCTION_HEADING_TYPES = new Set(['method', 'ctor', 'classMethod']);
11+
712
/**
813
* Generate a combined plain text string from all MDAST entries for estimating reading time.
914
*
@@ -28,39 +33,64 @@ const shouldIncludeEntryInToC = ({ heading }) =>
2833
// and whose depth <= the maximum allowed.
2934
heading?.depth <= TOC_MAX_HEADING_DEPTH;
3035

36+
/**
37+
* Builds the display label for a heading in the Table of Contents.
38+
*
39+
* Callables collapse to their bare name (e.g. `fs.read` rather than the full
40+
* `fs.read(fd, buffer, offset, length, position, callback)` signature). All
41+
* other headings keep their plain text, with CLI flags / env vars and leading
42+
* prefixes (i.e. `Class:`) stripped.
43+
*
44+
* @param {import('../../metadata/types').HeadingData} data
45+
*/
46+
const headingLabel = data => {
47+
if (FUNCTION_HEADING_TYPES.has(data.type)) {
48+
const name = getFullName(data, data.name);
49+
50+
return data.type === 'ctor' ? `new ${name}` : name;
51+
}
52+
53+
const cliFlagOrEnv = [...data.text.matchAll(/`(-[\w-]+|[A-Z0-9_]+=)/g)];
54+
55+
if (cliFlagOrEnv.length > 0) {
56+
return cliFlagOrEnv.at(-1)[1];
57+
}
58+
59+
return (
60+
data.text
61+
// Remove any containing code blocks
62+
.replace(/`/g, '')
63+
// Remove any prefixes (i.e. 'Class:')
64+
.replace(/^[^:]+:/, '')
65+
// Trim the remaining whitespace
66+
.trim()
67+
);
68+
};
69+
3170
/**
3271
* Extracts and formats heading information from an API documentation entry.
3372
* @param {import('../../metadata/types').MetadataEntry} entry
3473
*/
3574
const extractHeading = entry => {
3675
const data = entry.heading.data;
3776

38-
const cliFlagOrEnv = [...data.text.matchAll(/`(-[\w-]+|[A-Z0-9_]+=)/g)];
39-
40-
const heading =
41-
cliFlagOrEnv.length > 0
42-
? cliFlagOrEnv.at(-1)[1]
43-
: data.text
44-
// Remove any containing code blocks
45-
.replace(/`/g, '')
46-
// Remove any prefixes (i.e. 'Class:')
47-
.replace(/^[^:]+:/, '')
48-
// Trim the remaining whitespace
49-
.trim();
50-
5177
return {
5278
depth: entry.heading.depth,
53-
value: heading,
79+
value: headingLabel(data),
5480
stability: parseInt(entry.stability?.data.index ?? 2),
5581
slug: data.slug,
5682
data: { id: data.slug, type: data.type },
5783
};
5884
};
5985

6086
/**
61-
* Build the list of heading metadata for sidebar navigation.
87+
* Build the list of heading metadata for sidebar navigation. Overload headings
88+
* are dropped so each function contributes a single entry.
6289
*
6390
* @param {Array<import('../../metadata/types').MetadataEntry>} entries - All API metadata entries
6491
*/
6592
export const extractHeadings = entries =>
66-
entries.filter(shouldIncludeEntryInToC).map(extractHeading);
93+
entries
94+
.filter(shouldIncludeEntryInToC)
95+
.filter(({ heading }) => !heading.data.isOverload)
96+
.map(extractHeading);

src/generators/jsx-ast/utils/buildContent.mjs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SKIP, visit } from 'unist-util-visit';
88

99
import { createJSXElement } from './ast.mjs';
1010
import { extractHeadings, extractTextContent } from './buildBarProps.mjs';
11+
import { annotateOverloads } from './overloads.mjs';
1112
import { enforceArray } from '../../../utils/array.mjs';
1213
import { omitKeys } from '../../../utils/misc.mjs';
1314
import { JSX_IMPORTS } from '../../web/constants.mjs';
@@ -278,15 +279,20 @@ export const processEntry = entry => {
278279
* @param {Array<import('../../metadata/types').MetadataEntry>} entries - API documentation metadata entries
279280
* @param {Object} metadata - Raw page metadata from the head entry
280281
*/
281-
export const createDocumentLayout = (entries, metadata) =>
282-
createTree('root', [
282+
export const createDocumentLayout = (entries, metadata) => {
283+
// Collapse overloaded function headings into one stable ToC entry, tagging the
284+
// underlying headings with compact anchors / overload flags read just below.
285+
annotateOverloads(entries);
286+
287+
return createTree('root', [
283288
createJSXElement(JSX_IMPORTS.Layout.name, {
284289
metadata,
285290
headings: extractHeadings(entries),
286291
readingTime: readingTime(extractTextContent(entries)).text,
287292
children: entries.map(processEntry),
288293
}),
289294
]);
295+
};
290296

291297
/**
292298
* @typedef {import('estree').Node & { data: import('../../metadata/types').MetadataEntry }} JSXContent
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use strict';
2+
3+
// Heading types that document a callable and therefore may appear several times
4+
// in a row as overloaded signatures of the same function.
5+
const OVERLOADABLE_TYPES = new Set(['method', 'ctor', 'classMethod']);
6+
7+
/**
8+
* Two headings document the same function (i.e. are overloads of one another)
9+
* when they sit at the same depth and share the same resolved name and type.
10+
*
11+
* @param {import('../../metadata/types').HeadingNode} a
12+
* @param {import('../../metadata/types').HeadingNode} b
13+
*/
14+
const isSameFunction = (a, b) =>
15+
a.depth === b.depth &&
16+
a.data.type === b.data.type &&
17+
a.data.name === b.data.name;
18+
19+
/**
20+
* Flags overloaded function headings so the Table of Contents shows a single
21+
* entry per function.
22+
*
23+
* Node.js documents each overload of a function as its own heading (e.g. the
24+
* five `new Buffer(...)` signatures). This marks the 2nd..nth heading of each
25+
* such run with `isOverload` so they can be dropped from the ToC while still
26+
* rendering in full on the page. The first (most stable) heading is left as-is,
27+
* and the ToC links to its existing anchor.
28+
*
29+
* @param {Array<import('../../metadata/types').MetadataEntry>} entries - Page entries, in render order.
30+
* @returns {Array<import('../../metadata/types').MetadataEntry>} The same entries (mutated).
31+
*/
32+
export const annotateOverloads = entries => {
33+
for (let i = 0; i < entries.length; i++) {
34+
if (!OVERLOADABLE_TYPES.has(entries[i].heading.data.type)) {
35+
continue;
36+
}
37+
38+
// Flag each following heading that overloads the same function.
39+
let end = i + 1;
40+
while (
41+
end < entries.length &&
42+
isSameFunction(entries[end].heading, entries[i].heading)
43+
) {
44+
entries[end].heading.data.isOverload = true;
45+
end++;
46+
}
47+
48+
i = end - 1;
49+
}
50+
51+
return entries;
52+
};

src/generators/metadata/types.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export interface HeadingData extends Data {
9191
slug: string;
9292
/** Optional type classification */
9393
type?: HeadingType;
94+
/**
95+
* Marks the 2nd..nth heading of an overloaded function so it can be dropped
96+
* from the ToC while still rendering on the page.
97+
*/
98+
isOverload?: boolean;
9499
}
95100

96101
/**

0 commit comments

Comments
 (0)