Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/generators/jsx-ast/utils/__tests__/buildBarProps.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,71 @@ describe('extractHeadings', () => {
const result = extractHeadings(entries);
assert.equal(result.length, 0);
});

it('labels callables with the bare name instead of the full signature', () => {
const entries = [
{
heading: {
depth: 2,
data: {
text: 'fs.read(fd, buffer, offset, length, position, callback)',
name: 'fs.read',
slug: 'fsreadfd',
type: 'method',
},
},
},
{
heading: {
depth: 2,
data: {
text: 'new Buffer(size)',
name: 'Buffer',
slug: 'new-buffersize',
type: 'ctor',
},
},
},
];

const result = extractHeadings(entries);

assert.equal(result[0].value, 'fs.read');
assert.equal(result[1].value, 'new Buffer');
});

it('drops overload headings and links to the first signature', () => {
const entries = [
{
heading: {
depth: 2,
data: {
text: 'fs.read(fd)',
name: 'fs.read',
slug: 'fsreadfd',
type: 'method',
},
},
},
{
heading: {
depth: 2,
data: {
text: 'fs.read(fd, options)',
name: 'fs.read',
slug: 'fsreadoptions',
type: 'method',
isOverload: true,
},
},
},
];

const result = extractHeadings(entries);

assert.equal(result.length, 1);
assert.equal(result[0].slug, 'fsreadfd');
assert.equal(result[0].data.id, 'fsreadfd');
assert.equal(result[0].value, 'fs.read');
});
});
75 changes: 75 additions & 0 deletions src/generators/jsx-ast/utils/__tests__/overloads.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { annotateOverloads } from '../overloads.mjs';

const entry = (name, type, slug, depth = 2, text = `${name}(...)`) => ({
heading: { depth, data: { name, type, slug, text } },
});

describe('annotateOverloads', () => {
it('flags every overload after the first heading of a run', () => {
const entries = [
entry('fs.read', 'method', 'fsreadfd'),
entry('fs.read', 'method', 'fsreadbuffer'),
entry('fs.read', 'method', 'fsreadoptions'),
];

annotateOverloads(entries);

// The first (most stable) heading is left as-is...
assert.ok(!entries[0].heading.data.isOverload);
// ...and the rest are flagged so the ToC can drop them.
assert.ok(entries[1].heading.data.isOverload);
assert.ok(entries[2].heading.data.isOverload);
});

it('leaves a single-signature function untouched', () => {
const entries = [entry('fs.access', 'method', 'fsaccess')];

annotateOverloads(entries);

assert.ok(!entries[0].heading.data.isOverload);
});

it('groups constructors by name', () => {
const entries = [
entry('Buffer', 'ctor', 'new-bufferarray'),
entry('Buffer', 'ctor', 'new-buffersize'),
];

annotateOverloads(entries);

assert.ok(!entries[0].heading.data.isOverload);
assert.ok(entries[1].heading.data.isOverload);
});

it('does not group headings of different types or depths', () => {
const entries = [
entry('Buffer', 'class', 'class-buffer'),
entry('Buffer', 'ctor', 'new-bufferarray'),
entry('Buffer', 'ctor', 'new-buffersize', 3),
];

annotateOverloads(entries);

// class is not overloadable; the two ctors differ in depth, so none group.
assert.ok(entries.every(e => !e.heading.data.isOverload));
});

it('starts a fresh group when a different function interrupts a run', () => {
const entries = [
entry('fs.read', 'method', 'fsreadfd'),
entry('fs.read', 'method', 'fsreadbuffer'),
entry('fs.write', 'method', 'fswritefd'),
entry('fs.write', 'method', 'fswritebuffer'),
];

annotateOverloads(entries);

assert.deepEqual(
entries.map(e => !!e.heading.data.isOverload),
[false, true, false, true]
);
});
});
62 changes: 46 additions & 16 deletions src/generators/jsx-ast/utils/buildBarProps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

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

import { getFullName } from './signature.mjs';
import { TOC_MAX_HEADING_DEPTH } from '../constants.mjs';

// Callable heading types whose ToC label should be the bare function name
// rather than the full signature.
const FUNCTION_HEADING_TYPES = new Set(['method', 'ctor', 'classMethod']);

/**
* Generate a combined plain text string from all MDAST entries for estimating reading time.
*
Expand All @@ -28,39 +33,64 @@ const shouldIncludeEntryInToC = ({ heading }) =>
// and whose depth <= the maximum allowed.
heading?.depth <= TOC_MAX_HEADING_DEPTH;

/**
* Builds the display label for a heading in the Table of Contents.
*
* Callables collapse to their bare name (e.g. `fs.read` rather than the full
* `fs.read(fd, buffer, offset, length, position, callback)` signature). All
* other headings keep their plain text, with CLI flags / env vars and leading
* prefixes (i.e. `Class:`) stripped.
*
* @param {import('../../metadata/types').HeadingData} data
*/
const headingLabel = data => {
if (FUNCTION_HEADING_TYPES.has(data.type)) {
const name = getFullName(data, data.name);

return data.type === 'ctor' ? `new ${name}` : name;
}

const cliFlagOrEnv = [...data.text.matchAll(/`(-[\w-]+|[A-Z0-9_]+=)/g)];

if (cliFlagOrEnv.length > 0) {
return cliFlagOrEnv.at(-1)[1];
}

return (
data.text
// Remove any containing code blocks
.replace(/`/g, '')
// Remove any prefixes (i.e. 'Class:')
.replace(/^[^:]+:/, '')
// Trim the remaining whitespace
.trim()
);
};

/**
* Extracts and formats heading information from an API documentation entry.
* @param {import('../../metadata/types').MetadataEntry} entry
*/
const extractHeading = entry => {
const data = entry.heading.data;

const cliFlagOrEnv = [...data.text.matchAll(/`(-[\w-]+|[A-Z0-9_]+=)/g)];

const heading =
cliFlagOrEnv.length > 0
? cliFlagOrEnv.at(-1)[1]
: data.text
// Remove any containing code blocks
.replace(/`/g, '')
// Remove any prefixes (i.e. 'Class:')
.replace(/^[^:]+:/, '')
// Trim the remaining whitespace
.trim();

return {
depth: entry.heading.depth,
value: heading,
value: headingLabel(data),
stability: parseInt(entry.stability?.data.index ?? 2),
slug: data.slug,
data: { id: data.slug, type: data.type },
};
};

/**
* Build the list of heading metadata for sidebar navigation.
* Build the list of heading metadata for sidebar navigation. Overload headings
* are dropped so each function contributes a single entry.
*
* @param {Array<import('../../metadata/types').MetadataEntry>} entries - All API metadata entries
*/
export const extractHeadings = entries =>
entries.filter(shouldIncludeEntryInToC).map(extractHeading);
entries
.filter(shouldIncludeEntryInToC)
.filter(({ heading }) => !heading.data.isOverload)
.map(extractHeading);
10 changes: 8 additions & 2 deletions src/generators/jsx-ast/utils/buildContent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SKIP, visit } from 'unist-util-visit';

import { createJSXElement } from './ast.mjs';
import { extractHeadings, extractTextContent } from './buildBarProps.mjs';
import { annotateOverloads } from './overloads.mjs';
import { enforceArray } from '../../../utils/array.mjs';
import { omitKeys } from '../../../utils/misc.mjs';
import { JSX_IMPORTS } from '../../web/constants.mjs';
Expand Down Expand Up @@ -278,15 +279,20 @@ export const processEntry = entry => {
* @param {Array<import('../../metadata/types').MetadataEntry>} entries - API documentation metadata entries
* @param {Object} metadata - Raw page metadata from the head entry
*/
export const createDocumentLayout = (entries, metadata) =>
createTree('root', [
export const createDocumentLayout = (entries, metadata) => {
// Collapse overloaded function headings into one stable ToC entry, tagging the
// underlying headings with compact anchors / overload flags read just below.
annotateOverloads(entries);

return createTree('root', [
createJSXElement(JSX_IMPORTS.Layout.name, {
metadata,
headings: extractHeadings(entries),
readingTime: readingTime(extractTextContent(entries)).text,
children: entries.map(processEntry),
}),
]);
};

/**
* @typedef {import('estree').Node & { data: import('../../metadata/types').MetadataEntry }} JSXContent
Expand Down
52 changes: 52 additions & 0 deletions src/generators/jsx-ast/utils/overloads.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use strict';

// Heading types that document a callable and therefore may appear several times
// in a row as overloaded signatures of the same function.
const OVERLOADABLE_TYPES = new Set(['method', 'ctor', 'classMethod']);

/**
* Two headings document the same function (i.e. are overloads of one another)
* when they sit at the same depth and share the same resolved name and type.
*
* @param {import('../../metadata/types').HeadingNode} a
* @param {import('../../metadata/types').HeadingNode} b
*/
const isSameFunction = (a, b) =>
a.depth === b.depth &&
a.data.type === b.data.type &&
a.data.name === b.data.name;

/**
* Flags overloaded function headings so the Table of Contents shows a single
* entry per function.
*
* Node.js documents each overload of a function as its own heading (e.g. the
* five `new Buffer(...)` signatures). This marks the 2nd..nth heading of each
* such run with `isOverload` so they can be dropped from the ToC while still
* rendering in full on the page. The first (most stable) heading is left as-is,
* and the ToC links to its existing anchor.
*
* @param {Array<import('../../metadata/types').MetadataEntry>} entries - Page entries, in render order.
* @returns {Array<import('../../metadata/types').MetadataEntry>} The same entries (mutated).
*/
export const annotateOverloads = entries => {
for (let i = 0; i < entries.length; i++) {
if (!OVERLOADABLE_TYPES.has(entries[i].heading.data.type)) {
continue;
}

// Flag each following heading that overloads the same function.
let end = i + 1;
while (
end < entries.length &&
isSameFunction(entries[end].heading, entries[i].heading)
) {
entries[end].heading.data.isOverload = true;
end++;
}

i = end - 1;
}

return entries;
};
5 changes: 5 additions & 0 deletions src/generators/metadata/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ export interface HeadingData extends Data {
slug: string;
/** Optional type classification */
type?: HeadingType;
/**
* Marks the 2nd..nth heading of an overloaded function so it can be dropped
* from the ToC while still rendering on the page.
*/
isOverload?: boolean;
}

/**
Expand Down
Loading