diff --git a/packages/react/src/html/ui/index.css b/packages/react/src/html/ui/index.css index e385a3eb..c563527f 100644 --- a/packages/react/src/html/ui/index.css +++ b/packages/react/src/html/ui/index.css @@ -78,6 +78,33 @@ main { } } + .overload-panel { + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 4); + background-color: var(--color-neutral-100); + border: 1px solid var(--color-neutral-200); + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + padding: calc(var(--spacing) * 4); + padding-bottom: calc(var(--spacing) * 2); + } + + @media (min-width: 48rem) { + .overload-panel { + gap: calc(var(--spacing) * 6); + padding: calc(var(--spacing) * 6); + padding-bottom: calc(var(--spacing) * 3); + } + } + + :where([data-theme='dark'], [data-theme='dark'] *) .overload-panel { + background-color: var(--color-neutral-950); + border-color: var(--color-neutral-900); + } + table { td { word-break: break-all; diff --git a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs index 1be67964..312eea9d 100644 --- a/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs +++ b/packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs @@ -3,7 +3,11 @@ import { describe, it } from 'node:test'; import { setConfig } from '@doc-kit/core/utils/configuration/index.mjs'; -import { transformHeadingNode, gatherChangeEntries } from '../buildContent.mjs'; +import { + transformHeadingNode, + gatherChangeEntries, + groupOverloadsIntoTabs, +} from '../buildContent.mjs'; const heading = { type: 'heading', @@ -190,3 +194,101 @@ describe('gatherChangeEntries', () => { assert.equal(result[1].label, 'Added new feature.'); }); }); + +describe('groupOverloadsIntoTabs', () => { + it('groups consecutive overloads into a single OverloadTabs component', () => { + const originalEntries = [ + { heading: { data: { name: 'funcA', isOverload: false } } }, + { heading: { depth: 3, data: { name: 'funcB', isOverload: false } } }, + { heading: { depth: 3, data: { name: 'funcB', isOverload: true } } }, + { heading: { depth: 3, data: { name: 'funcB', isOverload: true } } }, + { heading: { data: { name: 'funcC', isOverload: false } } }, + ]; + + const getText = node => { + if (node.type === 'text') { + return node.value; + } + return (node.children || []).map(getText).join(''); + }; + + const makeNode = (className, bodyText, sigText = null) => { + const children = [ + { type: 'element', tagName: 'h3', depth: 3 }, // The heading to be stripped + { type: 'text', value: bodyText }, + ]; + + if (sigText) { + children.push({ + type: 'element', + tagName: 'div', + properties: { class: 'signature', dataSignatureRaw: sigText }, + }); + } + + return { + type: 'element', + tagName: 'div', + properties: { className }, + children, + }; + }; + + const processedChildren = [ + makeNode('entry-a', 'body a'), + makeNode('entry-b1', 'body b1', 'function funcB(arg1);'), + makeNode('entry-b2', 'body b2', 'function funcB(arg1, arg2);'), + makeNode('entry-b3', 'body b3', 'function funcB(arg1, arg2, arg3);'), + makeNode('entry-c', 'body c'), + ]; + + const result = groupOverloadsIntoTabs(processedChildren, originalEntries); + + // 0: funcA, 1: funcB-heading, 2: CombinedSignatures, 3: CodeTabs(funcB), 4: funcC + assert.equal(result.length, 5); + + // First element is untouched + assert.equal(result[0].properties.className, 'entry-a'); + + // Second element is the extracted heading + assert.equal(result[1].tagName, 'h3'); + + // Third element is the combined signatures block + const combinedSigBlock = result[2]; + assert.deepEqual(combinedSigBlock.properties.className, ['signature']); + + // Assert that the combined signatures contain the raw signatures without comments + const combinedText = getText(combinedSigBlock); + assert.match(combinedText, /function funcB\(arg1\);/); + assert.match(combinedText, /function funcB\(arg1, arg2\);/); + assert.match(combinedText, /function funcB\(arg1, arg2, arg3\);/); + + // Fourth element is the CodeTabs component + const tabsComponent = result[3]; + assert.equal(tabsComponent.name, 'CodeTabs'); + const languagesAttr = tabsComponent.attributes.find( + a => a.name === 'languages' + ); + const displayNamesAttr = tabsComponent.attributes.find( + a => a.name === 'displayNames' + ); + assert.equal(languagesAttr.value, 'overload|overload|overload'); + assert.equal(displayNamesAttr.value, 'Overload #1|Overload #2|Overload #3'); + assert.equal(tabsComponent.children.length, 3); // 3 tab panels + + // Check that the h3 was removed from the overloads and they are wrapped in overload-panel + const panel1 = tabsComponent.children[0]; + const classAttr1 = panel1.attributes.find(a => a.name === 'className'); + assert.equal(classAttr1.value, 'overload-panel'); + + // Second panel child should be the text we inserted + assert.equal(panel1.children[0].value, 'body b1'); + assert.equal(result[4].properties.className, 'entry-c'); + + const panel2 = tabsComponent.children[1]; + const classAttr2 = panel2.attributes.find(a => a.name === 'className'); + assert.equal(classAttr2.value, 'overload-panel'); + assert.equal(panel2.children[0].type, 'text'); + assert.equal(panel2.children[0].value, 'body b2'); + }); +}); diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index 5b29a2f9..632e4d7e 100644 --- a/packages/react/src/jsx-ast/utils/buildContent.mjs +++ b/packages/react/src/jsx-ast/utils/buildContent.mjs @@ -6,6 +6,7 @@ import { GITHUB_BLOB_URL, populate, } from '@doc-kit/core/utils/configuration/templates.mjs'; +import { highlighter } from '@doc-kit/core/utils/highlighter.mjs'; import { parseInline } from '@doc-kit/core/utils/inline.mjs'; import { omitKeys } from '@doc-kit/core/utils/misc.mjs'; import { UNIST } from '@doc-kit/core/utils/queries/index.mjs'; @@ -15,7 +16,7 @@ import { slice } from 'mdast-util-slice-markdown'; import { u as createTree } from 'unist-builder'; import { SKIP, visit } from 'unist-util-visit'; -import { createJSXElement } from './ast.mjs'; +import { createJSXElement, createAttributeNode } from './ast.mjs'; import { extractHeadings, extractTextContent } from './buildBarProps.mjs'; import { annotateOverloads } from './overloads.mjs'; import { getRemarkRecma as remark } from './remark.mjs'; @@ -317,6 +318,140 @@ export const processEntry = entry => { return entry.content; }; +/** + * Groups consecutive overloaded function API entries into a single OverloadTabs component. + * @param {Array} processedChildren - The processed JSX AST nodes for the API entries + * @param {Array} originalEntries - The original API metadata entries containing the overload flags + * @returns {Array} The final array of layout children with overloads grouped + */ +export const groupOverloadsIntoTabs = (processedChildren, originalEntries) => { + const finalChildren = []; + let activeOverloadGroup = null; + + /** + * Wraps an AST node's children in a styled panel `div` for tab rendering. + * @param {import('estree').Node} rootNode - The root node whose children will be wrapped. + * @returns {import('estree').Node} The new `div` AST node containing the children. + */ + const wrapInDiv = rootNode => { + return createJSXElement('div', { + inline: false, + className: 'overload-panel', + children: rootNode.children || [], + }); + }; + + /** + * Extracts the raw signature string from an API entry node and removes the signature node from its children. + * @param {import('estree').Node} node - The AST node representing the API entry. + * @returns {string|null} The raw TypeScript signature string, or null if not found. + */ + const extractSignature = node => { + const sigIdx = (node.children || []).findIndex( + c => + c.properties?.className?.includes('signature') || + c.properties?.class === 'signature' + ); + if (sigIdx !== -1) { + const sigNode = node.children.splice(sigIdx, 1)[0]; + return sigNode.properties?.dataSignatureRaw; + } + return null; + }; + + /** + * Finalizes the active overload group by generating a combined signatures block + */ + const pushOverloadGroup = () => { + if (!activeOverloadGroup) { + return; + } + + // Deduplicate signatures and join with a single newline + const uniqueSignatures = [...new Set(activeOverloadGroup.signatures)]; + const combinedSigRaw = uniqueSignatures.join('\n'); + + const highlighted = highlighter.highlightToHast( + combinedSigRaw, + 'typescript' + ); + const combinedSigNode = createElement('div', { class: 'signature' }, [ + highlighted, + ]); + + // Push combined signatures + finalChildren.push(combinedSigNode); + + // Inject properties needed by CodeTabs component + const count = activeOverloadGroup.signatures.length; + + const languagesArr = []; + const displayNamesArr = []; + + for (let i = 0; i < count; i++) { + languagesArr.push('overload'); + displayNamesArr.push(`Overload #${i + 1}`); + } + + activeOverloadGroup.tabsNode.attributes.push( + createAttributeNode('languages', languagesArr.join('|')), + createAttributeNode('displayNames', displayNamesArr.join('|')) + ); + + // Push the tabs + finalChildren.push(activeOverloadGroup.tabsNode); + + activeOverloadGroup = null; + }; + + /** + * Processes a single API entry node belonging to an overload group. + * It extracts its signature and pushes its remaining content into a new tab panel. + * @param {import('estree').Node} node - The AST node to process and add to the active group. + */ + const processOverloadNode = node => { + const sigRaw = extractSignature(node); + if (sigRaw) { + activeOverloadGroup.signatures.push(sigRaw); + } + activeOverloadGroup.tabsNode.children.push(wrapInDiv(node)); + }; + + for (const [i, current] of processedChildren.entries()) { + if (originalEntries[i].heading?.data?.isOverload) { + if (activeOverloadGroup) { + current.children.shift(); + processOverloadNode(current); + } else { + const last = finalChildren.pop(); + activeOverloadGroup = { + firstHeading: last?.children?.shift?.(), + signatures: [], + tabsNode: createJSXElement(JSX_IMPORTS.CodeTabs.name, { + inline: false, + children: [], + }), + }; + current.children.shift(); + + processOverloadNode(last); + processOverloadNode(current); + + if (activeOverloadGroup.firstHeading) { + finalChildren.push(activeOverloadGroup.firstHeading); + } + } + } else { + pushOverloadGroup(); + finalChildren.push(current); + } + } + + pushOverloadGroup(); + + return finalChildren; +}; + /** * Builds the overall document layout tree * @param {Array} entries - API documentation metadata entries @@ -336,7 +471,7 @@ export const createDocumentLayout = async (entries, metadata) => { readingTime: showReadingTime ? await readingTime(extractTextContent(entries)) : undefined, - children: entries.map(processEntry), + children: groupOverloadsIntoTabs(entries.map(processEntry), entries), }), ]); }; diff --git a/packages/react/src/jsx-ast/utils/signature.mjs b/packages/react/src/jsx-ast/utils/signature.mjs index d7032f32..47d5d580 100644 --- a/packages/react/src/jsx-ast/utils/signature.mjs +++ b/packages/react/src/jsx-ast/utils/signature.mjs @@ -67,7 +67,9 @@ export const createSignatureCodeBlock = (functionName, signature, heading) => { const sig = generateSignature(functionName, signature, heading); const highlighted = highlighter.highlightToHast(sig, 'typescript'); - return createElement('div', { class: 'signature' }, [highlighted]); + return createElement('div', { class: 'signature', dataSignatureRaw: sig }, [ + highlighted, + ]); }; /**