diff --git a/src/generators/jsx-ast/utils/__tests__/buildBarProps.test.mjs b/src/generators/jsx-ast/utils/__tests__/buildBarProps.test.mjs index 82484d18..38ea8d55 100644 --- a/src/generators/jsx-ast/utils/__tests__/buildBarProps.test.mjs +++ b/src/generators/jsx-ast/utils/__tests__/buildBarProps.test.mjs @@ -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'); + }); }); diff --git a/src/generators/jsx-ast/utils/__tests__/overloads.test.mjs b/src/generators/jsx-ast/utils/__tests__/overloads.test.mjs new file mode 100644 index 00000000..44436f01 --- /dev/null +++ b/src/generators/jsx-ast/utils/__tests__/overloads.test.mjs @@ -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] + ); + }); +}); diff --git a/src/generators/jsx-ast/utils/buildBarProps.mjs b/src/generators/jsx-ast/utils/buildBarProps.mjs index 6f21b183..cdad9536 100644 --- a/src/generators/jsx-ast/utils/buildBarProps.mjs +++ b/src/generators/jsx-ast/utils/buildBarProps.mjs @@ -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. * @@ -28,6 +33,40 @@ 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 @@ -35,22 +74,9 @@ const shouldIncludeEntryInToC = ({ heading }) => 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 }, @@ -58,9 +84,13 @@ const extractHeading = entry => { }; /** - * 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} entries - All API metadata entries */ export const extractHeadings = entries => - entries.filter(shouldIncludeEntryInToC).map(extractHeading); + entries + .filter(shouldIncludeEntryInToC) + .filter(({ heading }) => !heading.data.isOverload) + .map(extractHeading); diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index 363d1eea..dd621eec 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -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'; @@ -278,8 +279,12 @@ export const processEntry = entry => { * @param {Array} 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), @@ -287,6 +292,7 @@ export const createDocumentLayout = (entries, metadata) => children: entries.map(processEntry), }), ]); +}; /** * @typedef {import('estree').Node & { data: import('../../metadata/types').MetadataEntry }} JSXContent diff --git a/src/generators/jsx-ast/utils/overloads.mjs b/src/generators/jsx-ast/utils/overloads.mjs new file mode 100644 index 00000000..b35ad093 --- /dev/null +++ b/src/generators/jsx-ast/utils/overloads.mjs @@ -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} entries - Page entries, in render order. + * @returns {Array} 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; +}; diff --git a/src/generators/metadata/types.d.ts b/src/generators/metadata/types.d.ts index 7b19b23f..04b098db 100644 --- a/src/generators/metadata/types.d.ts +++ b/src/generators/metadata/types.d.ts @@ -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; } /**