Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@fontsource-variable/open-sans": "^5.3.0",
"@fontsource/ibm-plex-mono": "^5.3.0",
"@heroicons/react": "^2.2.0",
"@radix-ui/react-tabs": "^1.1.0",
"@doc-kit/core": "workspace:*",
"@node-core/rehype-shiki": "^1.4.3",
"@node-core/ui-components": "^1.7.4",
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/html/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export const JSX_IMPORTS = {
name: 'CodeTabs',
source: resolve(ROOT, './ui/components/CodeTabs'),
},
OverloadTabs: {
name: 'OverloadTabs',
source: resolve(ROOT, './ui/components/OverloadTabs'),
},
DocumentationIndex: {
name: 'DocumentationIndex',
source: resolve(ROOT, './ui/components/DocumentationIndex'),
Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/html/ui/components/OverloadTabs/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* eslint-disable react-x/no-array-index-key */
import Tabs from '@node-core/ui-components/Common/Tabs';
import * as TabsPrimitive from '@radix-ui/react-tabs';

import styles from './index.module.css';
import withIsland from '../../islands/withIsland.jsx';

const OverloadTabs = ({ children }) => {
const tabs = children.map((_, index) => ({
key: `${index + 1}`,
label: `Overload #${index + 1}`,
}));

return (
<Tabs tabs={tabs} defaultValue="1">
<div className={styles.panelContainer}>
{children.map((child, index) => (
<TabsPrimitive.Content
key={`overload-panel-${index}`}
value={`${index + 1}`}
forceMount={true}
className={styles.panel}
>
{child}
</TabsPrimitive.Content>
))}
</div>
</Tabs>
);
};

export default withIsland(OverloadTabs, {
name: 'OverloadTabs',
on: { interaction: 'pointerover,focusin,touchstart' },
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.panelContainer {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}

.panel {
grid-column: 1;
grid-row: 1;
opacity: 1;
visibility: visible;
pointer-events: auto;
transition: opacity 0.2s ease;
margin-top: calc(var(--spacing, 0.25rem) * 2);
}

.panel[data-state='inactive'] {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
6 changes: 6 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,12 @@ main {
}
}

.overload-panel {
display: flex;
flex-direction: column;
gap: calc(var(--spacing) * 6);
}

table {
td {
word-break: break-all;
Expand Down
102 changes: 101 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 @@ -142,3 +146,99 @@ 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: Overloads-heading, 3: CombinedSignatures, 4: OverloadTabs(funcB), 5: funcC
assert.equal(result.length, 6);

// 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 "Overloads" heading
assert.equal(result[2].children[0].value, 'Overloads');

// Fourth element is the combined signatures block
const combinedSigBlock = result[3];
assert.deepEqual(combinedSigBlock.properties.className, ['signature']);

// Assert that the combined signatures contain the formatted 'Overload #X' text
const combinedText = getText(combinedSigBlock);
assert.match(combinedText, /Overload #1/);
assert.match(combinedText, /function funcB\(arg1\);/);
assert.match(combinedText, /Overload #2/);
assert.match(combinedText, /function funcB\(arg1, arg2\);/);
assert.match(combinedText, /Overload #3/);
assert.match(combinedText, /function funcB\(arg1, arg2, arg3\);/);

// Fifth element is the OverloadTabs component
const tabsComponent = result[4];
assert.equal(tabsComponent.name, 'OverloadTabs');
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[5].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');
});
});
122 changes: 122 additions & 0 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 { omitKeys } from '@doc-kit/core/utils/misc.mjs';
import { UNIST } from '@doc-kit/core/utils/queries/index.mjs';
import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs';
Expand Down Expand Up @@ -320,6 +321,127 @@ 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 = 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;
}

// Build the combined signature raw string
const combinedSigRaw = activeOverloadGroup.signatures
.map((sig, idx) => `// Overload #${idx + 1}\n${sig}`)
.join('\n\n');

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

// Push combined signatures
finalChildren.push(combinedSigNode);
// 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.OverloadTabs.name, {
inline: false,
children: [],
}),
};
current.children.shift();

processOverloadNode(last);
processOverloadNode(current);

finalChildren.push(activeOverloadGroup.firstHeading);
finalChildren.push({
type: 'heading',
depth: (activeOverloadGroup.firstHeading.depth || 2) + 1,
children: [{ type: 'text', value: 'Overloads' }],
});
}
} else {
pushOverloadGroup();
finalChildren.push(current);
}
}

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 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
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading