Skip to content
Open
27 changes: 27 additions & 0 deletions packages/react/src/html/ui/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Comment thread
ovflowd marked this conversation as resolved.
.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;
Expand Down
104 changes: 103 additions & 1 deletion packages/react/src/jsx-ast/utils/__tests__/buildContent.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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');
});
});
144 changes: 142 additions & 2 deletions packages/react/src/jsx-ast/utils/buildContent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -317,6 +318,145 @@ export const processEntry = entry => {
return entry.content;
};

/**
* Groups consecutive overloaded function API entries into a single OverloadTabs component.
* @param {Array<import('estree').Node>} processedChildren - The processed JSX AST nodes for the API entries
* @param {Array<import('@doc-kit/core/generators/metadata/types').MetadataEntry>} originalEntries - The original API metadata entries containing the overload flags
* @returns {Array<import('estree').Node>} The final array of layout children with overloads grouped
*/
export const groupOverloadsIntoTabs = (processedChildren, originalEntries) => {
const finalChildren = [];
let activeOverloadGroup;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how this handles in asynchronous work? no issues here, right?


/**
* 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|undefined} The raw TypeScript signature string, or undefined if not found.
*/
const extractSignature = node => {

@ovflowd ovflowd Aug 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: easier to read, no need of (node.children || [])

Suggested change
const extractSignature = node => {
const extractSignature = ({ children = [] }) => {

const signatureIndex = (node.children || []).findIndex(
c =>
c.properties?.className?.includes('signature') ||
c.properties?.class === 'signature'
);

if (signatureIndex !== -1) {
const signatureNode = node.children.splice(signatureIndex, 1)[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: cleaner and easie to read

Suggested change
const signatureNode = node.children.splice(signatureIndex, 1)[0];
const [signatureNode] = node.children.splice(signatureIndex, 1) ?? [];
return signatureNode.properties?.dataSignatureRaw;

return signatureNode.properties?.dataSignatureRaw;
}
return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@moshams272 tiny nit, no need for this dangling return operator. the fn will reutnr undefined by default

};

/**
* 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 combinedSignatureRaw = uniqueSignatures.join('\n');

const highlighted = highlighter.highlightToHast(
combinedSignatureRaw,
'typescript'
);
const combinedSignatureNode = createElement('div', { class: 'signature' }, [
highlighted,
]);

// Push combined signatures
finalChildren.push(combinedSignatureNode);

// 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 = undefined;
};

/**
* 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 => {

@ovflowd ovflowd Aug 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

const processOverloadNode = node => {
  const signatureRaw = extractSignature(node);
  
  signatureRaw && activeOverloadGroup.signatures.push(signatureRaw);
  activeOverloadGroup.tabsNode.children.push(wrapInDiv(node));
};

const signatureRaw = extractSignature(node);
if (signatureRaw) {
activeOverloadGroup.signatures.push(signatureRaw);
}
activeOverloadGroup.tabsNode.children.push(wrapInDiv(node));
};

for (const [i, current] of processedChildren.entries()) {
const isOverload = originalEntries[i].heading?.data?.isOverload;

if (!isOverload) {
pushOverloadGroup();
finalChildren.push(current);
continue;
}

current.children.shift();

if (activeOverloadGroup) {
processOverloadNode(current);
continue;
}

const last = finalChildren.pop();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add inline comments on why we pop and shift.

activeOverloadGroup = {
firstHeading: last?.children?.shift?.(),
signatures: [],
tabsNode: createJSXElement(JSX_IMPORTS.CodeTabs.name, {
inline: false,
children: [],
}),
};

processOverloadNode(last);
processOverloadNode(current);

if (activeOverloadGroup.firstHeading) {
finalChildren.push(activeOverloadGroup.firstHeading);
}
}

pushOverloadGroup();

return finalChildren;
};

/**
* Builds the overall document layout tree
* @param {Array<import('@doc-kit/core/generators/metadata/types').MetadataEntry>} entries - API documentation metadata entries
Expand All @@ -336,7 +476,7 @@ export const createDocumentLayout = async (entries, metadata) => {
readingTime: showReadingTime
? await readingTime(extractTextContent(entries))
: undefined,
children: entries.map(processEntry),
children: groupOverloadsIntoTabs(entries.map(processEntry), entries),
}),
]);
};
Expand Down
4 changes: 3 additions & 1 deletion packages/react/src/jsx-ast/utils/signature.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
]);
};

/**
Expand Down
Loading