Files
v10/site/scripts/api-docs-builder/src/formatter.ts
T
2026-04-22 10:18:05 -05:00

352 lines
11 KiB
TypeScript

import { uniq } from 'es-toolkit/array';
import * as tae from 'typescript-api-extractor';
import type { PropDef } from './types.js';
/**
* Detect if a type string is a single function type (vs a top-level union).
*
* Tracks bracket depth to find the matching `)` for the opening `(` of the parameter list,
* then checks if `=>` follows. Returns `false` for top-level unions that happen to contain
* a function member (e.g., `((state: object) => string) | undefined`).
*/
function isFunctionType(type: string): boolean {
if (!type.startsWith('(')) return false;
let depth = 0;
for (let i = 0; i < type.length; i++) {
if (type[i] === '(' || type[i] === '{' || type[i] === '[') depth++;
else if (type[i] === ')' || type[i] === '}' || type[i] === ']') depth--;
if (depth === 0) {
return type
.slice(i + 1)
.trimStart()
.startsWith('=>');
}
}
return false;
}
/**
* Get abbreviated type for display in collapsed rows.
*
* Returns an abbreviated string when abbreviation adds value, `undefined` otherwise.
*/
export function abbreviateType(name: string, type: string): string | undefined {
// Pure function types (no union) → "function"
// Also matches function types whose return is a union (e.g., `(state: object) => X | undefined`)
if (type.includes('=>') && (!type.includes(' | ') || isFunctionType(type))) {
return 'function';
}
// Callbacks → "function"
if (/^(on|get)[A-Z]/.test(name) && type.includes('=>')) {
return 'function';
}
// className/style/render → simplified
if (name === 'className' && type.includes('=>')) {
return 'string | function';
}
if (name === 'style' && type.includes('=>')) {
return 'CSSProperties | function';
}
if (name === 'render' && type.includes('=>')) {
return 'ReactElement | function';
}
// Simple types → no abbreviation needed
if (['boolean', 'string', 'number'].includes(type)) {
return undefined;
}
// Object literal > 40 chars → "object"
if (type.startsWith('{ ') && type.length > 40) {
return 'object';
}
// Short unions (less than 3 members and under 40 chars) → no abbreviation
if (!type.includes(' | ') || (type.split(' | ').length < 3 && type.length < 40 && !type.includes('=>'))) {
return undefined;
}
// Function in union → "type | function"
if (type.includes('=>')) {
const parts = type.split(' | ');
const nonFunctionParts = parts.filter((p) => !p.includes('=>'));
if (nonFunctionParts.length > 0) {
return `${nonFunctionParts.join(' | ')} | function`;
}
return 'function';
}
// Any other type > 40 chars → truncated for display, full in detailedType
if (type.length > 40) {
return `${type.slice(0, 37)}...`;
}
// Complex unions → no abbreviation needed (show full type)
return undefined;
}
/**
* Format a list of properties into API reference format.
*/
export function formatProperties(props: tae.PropertyNode[], allExports?: tae.ExportNode[]): Record<string, PropDef> {
const result: Record<string, PropDef> = {};
for (const prop of props) {
// Skip ref for components
if (prop.name === 'ref') continue;
// Skip props marked with @ignore
if (prop.documentation?.hasTag('ignore')) continue;
const expandedType = allExports
? formatDetailedType(prop.type, allExports, prop.optional)
: formatType(prop.type, prop.optional);
const abbreviated = abbreviateType(prop.name, expandedType);
const entry: PropDef = { type: abbreviated ?? expandedType };
if (abbreviated && expandedType !== abbreviated) entry.detailedType = expandedType;
if (prop.documentation?.defaultValue !== undefined) entry.default = String(prop.documentation.defaultValue);
if (!prop.optional) entry.required = true;
if (prop.documentation?.description !== undefined) entry.description = prop.documentation.description;
result[prop.name] = entry;
}
return result;
}
/**
* Format a type into a human-readable string, expanding type aliases when possible.
*
* Resolves `ExternalTypeNode` references against `allExports` so that type aliases
* like `TimeType` are expanded to their underlying union (`'current' | 'duration' | 'remaining'`).
*/
export function formatDetailedType(
type: tae.AnyType,
allExports: tae.ExportNode[],
removeUndefined: boolean,
visited: Set<string> = new Set()
): string {
if (type instanceof tae.ExternalTypeNode) {
const name = type.typeName.name;
if (!visited.has(name)) {
const resolved = allExports.find((exp) => exp.name === name && exp.reexportedFrom === undefined);
if (resolved) {
visited.add(name);
return formatDetailedType(resolved.type, allExports, removeUndefined, visited);
}
}
return formatType(type, removeUndefined);
}
if (type instanceof tae.UnionNode) {
let memberTypes = type.types;
if (removeUndefined) {
memberTypes = memberTypes.filter((t) => !(t instanceof tae.IntrinsicNode && t.intrinsic === 'undefined'));
}
const flattenedMemberTypes = memberTypes.flatMap((t) => {
if (t instanceof tae.UnionNode) {
return t.typeName ? t : t.types;
}
if (
t instanceof tae.TypeParameterNode &&
t.constraint instanceof tae.UnionNode &&
t.constraint.types.length <= 5
) {
return t.constraint.types;
}
return t;
});
const formattedMemberTypes = uniq(
orderMembers(flattenedMemberTypes).map((t) => formatDetailedType(t, allExports, removeUndefined, visited))
);
return formattedMemberTypes.join(' | ');
}
if (type instanceof tae.IntersectionNode) {
return orderMembers(type.types)
.map((t) => formatDetailedType(t, allExports, false, visited))
.join(' & ');
}
return formatType(type, removeUndefined);
}
/**
* Format a type into a human-readable string.
*/
export function formatType(type: tae.AnyType, removeUndefined: boolean): string {
if (type instanceof tae.ExternalTypeNode) {
if (/^ReactElement(<.*>)?/.test(type.typeName.name || '')) {
return 'ReactElement';
}
if (type.typeName.namespaces?.length === 1 && type.typeName.namespaces[0] === 'React') {
return createNameWithTypeArguments(type.typeName);
}
return getFullyQualifiedName(type.typeName);
}
if (type instanceof tae.IntrinsicNode) {
return type.typeName ? getFullyQualifiedName(type.typeName) : type.intrinsic;
}
if (type instanceof tae.UnionNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
let memberTypes = type.types;
if (removeUndefined) {
memberTypes = memberTypes.filter((t) => !(t instanceof tae.IntrinsicNode && t.intrinsic === 'undefined'));
}
const flattenedMemberTypes = memberTypes.flatMap((t) => {
if (t instanceof tae.UnionNode) {
return t.typeName ? t : t.types;
}
if (
t instanceof tae.TypeParameterNode &&
t.constraint instanceof tae.UnionNode &&
t.constraint.types.length <= 5
) {
return t.constraint.types;
}
return t;
});
const formattedMemberTypes = uniq(orderMembers(flattenedMemberTypes).map((t) => formatType(t, removeUndefined)));
return formattedMemberTypes.join(' | ');
}
if (type instanceof tae.IntersectionNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
return orderMembers(type.types)
.map((t) => formatType(t, false))
.join(' & ');
}
if (type instanceof tae.ObjectNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
if (type.properties.length === 0) {
return 'object';
}
return `{ ${type.properties.map((m) => `${m.name}${m.optional ? '?' : ''}: ${formatType(m.type, m.optional)}`).join('; ')} }`;
}
if (type instanceof tae.LiteralNode) {
return normalizeQuotes(type.value as string);
}
if (type instanceof tae.ArrayNode) {
const formattedMemberType = formatType(type.elementType, false);
if (formattedMemberType.includes(' ')) {
return `(${formattedMemberType})[]`;
}
return `${formattedMemberType}[]`;
}
if (type instanceof tae.FunctionNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
const functionSignature = type.callSignatures
.map((s) => {
const params = s.parameters.map((p) => `${p.name}: ${formatType(p.type, false)}`).join(', ');
const returnType = formatType(s.returnValueType, false);
return `(${params}) => ${returnType}`;
})
.join(' | ');
return `(${functionSignature})`;
}
if (type instanceof tae.TupleNode) {
if (type.typeName) {
return getFullyQualifiedName(type.typeName);
}
return `[${type.types.map((member: tae.AnyType) => formatType(member, false)).join(', ')}]`;
}
if (type instanceof tae.TypeParameterNode) {
if (type.constraint === undefined) return type.name;
// Large union constraints (e.g., keyof JSX.IntrinsicElements) — show the parameter name
if (type.constraint instanceof tae.UnionNode && type.constraint.types.length > 5) return type.name;
return formatType(type.constraint, removeUndefined);
}
return 'unknown';
}
function getFullyQualifiedName(typeName: tae.TypeName): string {
const nameWithTypeArgs = createNameWithTypeArguments(typeName);
if (!typeName.namespaces || typeName.namespaces.length === 0) {
return nameWithTypeArgs;
}
return `${typeName.namespaces.join('.')}.${nameWithTypeArgs}`;
}
function createNameWithTypeArguments(typeName: tae.TypeName): string {
if (
typeName.typeArguments &&
typeName.typeArguments.length > 0 &&
typeName.typeArguments.some((ta) => ta.equalToDefault === false)
) {
return `${typeName.name}<${typeName.typeArguments.map((ta) => formatType(ta.type, false)).join(', ')}>`;
}
return typeName.name;
}
/**
* Order members so null, undefined, and any come last.
*/
function orderMembers(members: readonly tae.AnyType[]): readonly tae.AnyType[] {
let ordered = pushToEnd(members, 'any');
ordered = pushToEnd(ordered, 'null');
ordered = pushToEnd(ordered, 'undefined');
return ordered;
}
function pushToEnd(members: readonly tae.AnyType[], name: string): readonly tae.AnyType[] {
const index = members.findIndex(
(member: tae.AnyType) => member instanceof tae.IntrinsicNode && member.intrinsic === name
);
if (index !== -1) {
const member = members[index];
return [...members.slice(0, index), ...members.slice(index + 1), member!];
}
return members;
}
function normalizeQuotes(str: string): string {
if (str.startsWith('"') && str.endsWith('"')) {
return str
.replaceAll("'", "\\'")
.replaceAll('\\"', '"')
.replace(/^"(.*)"$/, "'$1'");
}
return str;
}