import { Node, Project, Scope, type InterfaceDeclaration, type JSDoc, type JSDocableNode, type ModuleDeclaration, type Node as TsNode, type SourceFile, type Symbol as TsMorphSymbol, type Type, type TypeAliasDeclaration, } from "ts-morph"; import * as fs from "node:fs"; import * as path from "node:path"; import { CORE_PKG, DOCS_ROOT, INTEGRATION_PACKAGES, REACT_GENERATIVE_UI_PKG, REACT_PKG, REPO_ROOT, } from "./paths.mts"; import type { ExportInfo } from "./discover.mts"; // ── Project (per-process shared instance) ────────────────────────────────── // Source trees the api-reference + primitive generators read from. Derived // from paths.mts so adding/removing a package is a single edit there. Limited // to the packages whose public API we render — anything else can be lazy // loaded by ts-morph via addSourceFileAtPath if a type reference reaches it. const PACKAGE_SOURCE_ROOTS = [ CORE_PKG, REACT_PKG, REACT_GENERATIVE_UI_PKG, ...INTEGRATION_PACKAGES.map((p) => path.dirname(p.entry)), ]; const GENERATOR_SOURCE_GLOBS = PACKAGE_SOURCE_ROOTS.flatMap((dir) => [ path.join(dir, "**/*.ts"), path.join(dir, "**/*.tsx"), ]); let _project: Project | undefined; let _allExportedNames: Set | undefined; /** Every symbol name exported from the generator's package source trees — * including internal exports that never reach the public api-reference (e.g. * `GROUPBY_MEMO_KEY`) and members nested inside an exported namespace (e.g. * `IndicatorPart` under `MessagePrimitive.GroupedParts`). Lets the JSDoc * renderer treat a `{@link}` to a real but unanchored symbol as a graceful * no-op instead of a broken-link warning, reserving the warning for targets * that name nothing that actually exists. */ export function getAllExportedNames(): Set { if (_allExportedNames) return _allExportedNames; const project = getProject(); const names = new Set(); for (const sourceFile of project.getSourceFiles()) { const filePath = sourceFile.getFilePath(); if (!PACKAGE_SOURCE_ROOTS.some((root) => filePath.startsWith(root))) { continue; } collectExportedNames(sourceFile, names); } _allExportedNames = names; return names; } /** Collects export names from a source file or namespace, recursing through * nested namespace declarations. Namespace-merged primitives (e.g. * `MessagePrimitive.GroupedParts`) keep their public types one level down, so * a `{@link IndicatorPart}` references the unqualified member name; without * the recursion those reads as broken links. */ function collectExportedNames( container: SourceFile | ModuleDeclaration, names: Set, ): void { for (const symbol of container.getExportSymbols()) { names.add(symbol.getName()); for (const decl of symbol.getDeclarations()) { if (Node.isModuleDeclaration(decl)) { collectExportedNames(decl, names); } } } } export function getProject(): Project { if (_project) return _project; _project = new Project({ tsConfigFilePath: path.join(DOCS_ROOT, "tsconfig.json"), // The docs tsconfig `include`s the entire Next.js app + `.next/types/**` // + every workspace package reachable through path mappings, which costs // ~3.4 s of project bootstrap and loads ~1,100 source files we never look // at. Skip the tsconfig auto-add and explicitly preload the package // source trees this generator analyzes (see GENERATOR_SOURCE_GLOBS). skipAddingFilesFromTsConfig: true, }); for (const glob of GENERATOR_SOURCE_GLOBS) { _project.addSourceFilesAtPaths(glob); } // Note: do NOT eagerly add primitive source globs here. Doing so changes // ts-morph's intersection property iteration order (legacy api-surface // loaded files lazily via addSourceFileAtPath). Primitive sources get // pulled in on demand by the primitive extraction module. return _project; } // ── Semantic model ───────────────────────────────────────────────────────── export type InheritedFrom = | "react" | "radix" | "csstype" | "react-textarea-autosize" | "tw" | "other"; export type PropModel = { name: string; /** Resolved ts-morph type text (only `import()` prefixes stripped). * Preserves `| undefined`. Used by primitive-docs projection. */ rawType?: string; /** The explicit type-node text the author wrote where available, else the * resolved type. Used by the api-ref projection (which then strips * trailing `| undefined`). Matches legacy api-surface output. */ declaredType?: string; description?: string; default?: string; deprecated?: string; /** undefined when the source has no required-ness signal (e.g. class * members in a class shape). Projections that always want a boolean * should default to `false`. */ required?: boolean; /** Set when every declaration of this property comes from a known third * party. Projections decide whether to drop. */ inheritedFrom?: InheritedFrom; children?: { typeName?: string; props: PropModel[] }[]; }; export type ExtractedShape = { kind: "interface" | "type" | "class" | "function" | "component" | "callable"; name: string; parameters: PropModel[]; signature?: string; }; // ── Text utilities ───────────────────────────────────────────────────────── /** Strip `import("...").` prefixes and a leading `|`. Preserves `| undefined` * so projections can decide whether to strip. Used internally by signature * generation; the api-ref projection strips `| undefined` itself. */ export function rawTypeText(typeText: string): string { return typeText.replace(/import\(".*?"\)\./g, ""); } /** Internal helper used by ts-morph logic that compares against canonical * type text. Strips `import()` prefixes, leading `|`, and trailing * `| undefined`. */ export function cleanTypeText(typeText: string): string { return typeText .replace(/import\(".*?"\)\./g, "") .replace(/^\s*\|\s*/, "") .replace(/\s*\|\s*undefined$/, ""); } export function cleanSignatureText(text: string): string { return cleanTypeText(text) .replace(/\r\n/g, "\n") .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } // ── Declaration helpers ──────────────────────────────────────────────────── export function resolveAliasedDeclaration(declaration: TsNode): TsNode { if (Node.isExportSpecifier(declaration)) { const aliased = declaration.getSymbol()?.getAliasedSymbol(); const resolved = aliased?.getDeclarations()[0]; if (resolved) return resolved; } return declaration; } function declarationPriority(node: TsNode): number { if (Node.isBindingElement(node)) return 0; if (Node.isVariableDeclaration(node)) return 0; if (Node.isClassDeclaration(node)) return 1; if (Node.isFunctionDeclaration(node)) return 2; if (Node.isInterfaceDeclaration(node)) return 3; if (Node.isTypeAliasDeclaration(node)) return 4; if (Node.isModuleDeclaration(node)) return 5; return 6; } export function chooseDeclaration(declarations: TsNode[]): TsNode | undefined { return declarations .map(resolveAliasedDeclaration) .sort((a, b) => declarationPriority(a) - declarationPriority(b))[0]; } function declarationTypeText(node: TsNode): string { if ( Node.isVariableDeclaration(node) || Node.isParameterDeclaration(node) || Node.isPropertySignature(node) || Node.isPropertyDeclaration(node) ) { const typeNode = node.getTypeNode(); if (typeNode) return cleanSignatureText(typeNode.getText()); return cleanSignatureText(node.getType().getText(node)); } return cleanSignatureText(node.getType().getText(node)); } function returnTypeText(node: TsNode): string { if (Node.isMethodDeclaration(node) || Node.isFunctionDeclaration(node)) { const typeNode = node.getReturnTypeNode(); if (typeNode) return cleanSignatureText(typeNode.getText()); return cleanSignatureText(node.getReturnType().getText(node)); } return declarationTypeText(node); } function parameterSignature(parameter: TsNode): string { if (!Node.isParameterDeclaration(parameter)) return parameter.getText(); const dotDotDot = parameter.isRestParameter() ? "..." : ""; const name = parameter.getNameNode().getText(); const optional = parameter.hasQuestionToken() ? "?" : ""; const type = declarationTypeText(parameter); const initializer = parameter.getInitializer()?.getText(); const defaultValue = initializer ? ` = ${initializer}` : ""; return `${dotDotDot}${name}${optional}: ${type}${defaultValue}`; } function typeParameterText(node: TsNode): string { if ( Node.isClassDeclaration(node) || Node.isFunctionDeclaration(node) || Node.isMethodDeclaration(node) ) { const params = node.getTypeParameters().map((param) => param.getText()); return params.length > 0 ? `<${params.join(", ")}>` : ""; } return ""; } function isPublicClassMember(node: TsNode): boolean { if ( Node.isConstructorDeclaration(node) || Node.isMethodDeclaration(node) || Node.isPropertyDeclaration(node) ) { const scope = node.getScope(); return scope !== Scope.Private && scope !== Scope.Protected; } return false; } function classMemberPrefix(node: TsNode): string { if ( (Node.isMethodDeclaration(node) || Node.isPropertyDeclaration(node)) && node.isStatic() ) { return "static "; } return ""; } // ── JSDoc layer (single source of truth) ─────────────────────────────────── const warnedUnresolvedJsDocLinks = new Set(); type JsDocLinkResolver = (target: string) => string | undefined; export type JsDocRenderOptions = { linkResolver?: JsDocLinkResolver; /** Returns true when `target` names a discovered public export, even one * that has no standalone anchor (e.g. a supporting type rendered inline). * Lets the renderer distinguish a legitimately anchorless reference from a * genuinely broken/typo'd link, so only the latter warns. */ isKnownExport?: (target: string) => boolean; }; function isExternalLinkTarget(target: string): boolean { return /^(https?:|mailto:)/.test(target); } function parseJsDocLinkContent(content: string): { target: string; label: string; } { const trimmed = content.trim(); const pipeIndex = trimmed.indexOf("|"); if (pipeIndex !== -1) { const target = trimmed.slice(0, pipeIndex).trim(); const label = trimmed.slice(pipeIndex + 1).trim(); return { target, label: label || target }; } const match = trimmed.match(/^(\S+)(?:\s+([\s\S]+))?$/); const target = match?.[1]?.trim() ?? trimmed; const label = match?.[2]?.trim() || target; return { target, label }; } function warnUnresolvedJsDocLink(target: string, source: string): void { const key = `${source}:${target}`; if (warnedUnresolvedJsDocLinks.has(key)) return; warnedUnresolvedJsDocLinks.add(key); console.warn(`Warning: unresolved JSDoc link "${target}" in ${source}`); } function renderJsDocLinkTag( content: string, source: string, { linkResolver, isKnownExport }: JsDocRenderOptions = {}, ): string { const { target, label } = parseJsDocLinkContent(content); const isExternal = isExternalLinkTarget(target); const href = isExternal ? target : linkResolver?.(target); if (!href) { // Only warn for genuinely unknown targets (typos or non-public symbols). // A known export that simply has no standalone anchor — a supporting type // rendered inline in a parameters table — degrades to its label silently, // so the warning channel means exactly "this link target does not exist". if (!isExternal && !isKnownExport?.(target)) { warnUnresolvedJsDocLink(target, source); } return label; } const labelText = label.replaceAll("[", "\\[").replaceAll("]", "\\]"); return `[${labelText}](${href.replaceAll(")", "%29")})`; } export function renderJsDocLinks( text: string, source: string, options?: JsDocRenderOptions, ): string { return text.replace(/\{@link\s+([^}]+)\}/g, (_, content: string) => renderJsDocLinkTag(content, source, options), ); } function cleanJsDocTagText(text: string | undefined): string | undefined { return text ?.replace(/\n\s*\*\s?/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function deprecatedTagParts(doc: JSDoc | undefined): { deprecated?: string | undefined; trailingDescription?: string | undefined; } { const tag = doc?.getTags().find((tag) => tag.getTagName() === "deprecated"); const cleaned = cleanJsDocTagText(tag?.getCommentText()); if (!cleaned) return {}; const [deprecated, ...rest] = cleaned.split(/\n\s*\n/); const trailingDescription = rest.join("\n\n").trim(); return { deprecated: deprecated?.trim() || undefined, trailingDescription: trailingDescription || undefined, }; } function jsDocSourceLabel(node: TsNode | undefined): string { if (!node) return "unknown source"; const filePath = path.relative(REPO_ROOT, node.getSourceFile().getFilePath()); let name: string | undefined; if ( Node.isClassDeclaration(node) || Node.isFunctionDeclaration(node) || Node.isInterfaceDeclaration(node) || Node.isMethodDeclaration(node) || Node.isParameterDeclaration(node) || Node.isPropertyDeclaration(node) || Node.isPropertySignature(node) || Node.isTypeAliasDeclaration(node) || Node.isVariableDeclaration(node) ) { name = node.getName(); } return name ? `${name} (${filePath})` : filePath; } export function getJsDocCommentText( doc: JSDoc, source = "unknown source", options?: JsDocRenderOptions, ): string | undefined { const text = doc.getCommentText() ?? deprecatedTagParts(doc).trailingDescription; if (!text) return undefined; const cleaned = renderJsDocLinks(text, source, options) .replace(/\s+([.,;:])/g, "$1") .trim(); return cleaned || undefined; } export function jsDocTag( doc: JSDoc | undefined, name: string, source = "unknown source", options?: JsDocRenderOptions, ): string | undefined { const tag = doc?.getTags().find((tag) => tag.getTagName() === name); const tagText = name === "deprecated" ? deprecatedTagParts(doc).deprecated : cleanJsDocTagText(tag?.getCommentText()); return tagText ? renderJsDocLinks(tagText, `${source} @${name}`, options) : undefined; } export function jsDocTags( doc: JSDoc | undefined, name: string, source = "unknown source", options?: JsDocRenderOptions, ): string[] | undefined { const tags = doc ?.getTags() .filter((tag) => tag.getTagName() === name) .map((tag) => cleanJsDocTagText(tag.getCommentText())) .filter((text): text is string => Boolean(text)) .map((text) => renderJsDocLinks(text, `${source} @${name}`, options)) ?? []; return tags.length > 0 ? tags : undefined; } function getJsDocs(node: TsNode | undefined): JSDoc[] { if (!node) return []; if ( Node.isInterfaceDeclaration(node) || Node.isTypeAliasDeclaration(node) || Node.isClassDeclaration(node) || Node.isFunctionDeclaration(node) || Node.isVariableStatement(node) ) { return node.getJsDocs(); } if (Node.isVariableDeclaration(node)) { return node.getVariableStatement()?.getJsDocs() ?? []; } return []; } export function extractJsDoc( node: TsNode | undefined, options?: JsDocRenderOptions, ): { jsDoc?: string | undefined; deprecated?: string | undefined; examples?: string[] | undefined; } { const doc = getJsDocs(node)[0]; const source = jsDocSourceLabel(node); return { jsDoc: doc ? getJsDocCommentText(doc, source, options) : undefined, deprecated: jsDocTag(doc, "deprecated", source, options), examples: jsDocTags(doc, "example", source, options), }; } function hasJsDocs(node: TsNode): node is TsNode & JSDocableNode { return ( typeof (node as TsNode & Partial).getJsDocs === "function" ); } function propertyJsDocMeta( node: TsNode | undefined, options?: JsDocRenderOptions, ): { description?: string | undefined; default?: string | undefined; deprecated?: string | undefined; } { if (!node || !hasJsDocs(node)) return {}; const doc = node.getJsDocs()[0]; if (!doc) return {}; const source = jsDocSourceLabel(node); return { description: getJsDocCommentText(doc, source, options), default: jsDocTag(doc, "default", source, options), deprecated: jsDocTag(doc, "deprecated", source, options), }; } function propertyDeclarationsJsDocMeta( declarations: TsNode[], options?: JsDocRenderOptions, ): { description?: string | undefined; default?: string | undefined; deprecated?: string | undefined; } { return declarations.reduce<{ description?: string | undefined; default?: string | undefined; deprecated?: string | undefined; }>((meta, declaration) => { const declarationMeta = propertyJsDocMeta(declaration, options); return { description: meta.description ?? declarationMeta.description, default: meta.default ?? declarationMeta.default, deprecated: meta.deprecated ?? declarationMeta.deprecated, }; }, {}); } // ── Signatures ───────────────────────────────────────────────────────────── function propertySignatureLine(node: TsNode): string | undefined { if (!Node.isPropertyDeclaration(node) || !isPublicClassMember(node)) { return undefined; } const name = node.getNameNode().getText(); const optional = node.hasQuestionToken() ? "?" : ""; return `${classMemberPrefix(node)}${name}${optional}: ${declarationTypeText(node)};`; } function methodSignatureLine(node: TsNode): string | undefined { if (!Node.isMethodDeclaration(node) || !isPublicClassMember(node)) { return undefined; } const asyncPrefix = node.isAsync() ? "async " : ""; const params = node.getParameters().map(parameterSignature).join(", "); const returnType = returnTypeText(node); return `${classMemberPrefix(node)}${asyncPrefix}${node.getName()}${typeParameterText(node)}(${params}): ${returnType};`; } function constructorSignatureLine(node: TsNode): string | undefined { if (!Node.isConstructorDeclaration(node) || !isPublicClassMember(node)) { return undefined; } const params = node.getParameters().map(parameterSignature).join(", "); return `constructor(${params});`; } function classSignature(node: TsNode, name: string): string | undefined { if (!Node.isClassDeclaration(node)) return undefined; const extendsText = node.getExtends()?.getText(); const implementsText = node.getImplements().map((item) => item.getText()); const header = [ `class ${name}${typeParameterText(node)}`, extendsText ? `extends ${extendsText}` : undefined, implementsText.length > 0 ? `implements ${implementsText.join(", ")}` : undefined, ] .filter(Boolean) .join(" "); const members = [ ...node.getConstructors().map(constructorSignatureLine), ...node.getProperties().map(propertySignatureLine), ...node.getMethods().map(methodSignatureLine), ].filter((line): line is string => Boolean(line)); if (members.length === 0) return `${header} {}`; return [`${header} {`, ...members.map((line) => ` ${line}`), "}"].join("\n"); } function functionSignature(node: TsNode, name: string): string | undefined { if (!Node.isFunctionDeclaration(node)) return undefined; const asyncPrefix = node.isAsync() ? "async " : ""; const params = node.getParameters().map(parameterSignature).join(", "); return `function ${asyncPrefix}${name}${typeParameterText(node)}(${params}): ${returnTypeText(node)};`; } function localTypeSignature(node: TsNode): string | undefined { if (Node.isInterfaceDeclaration(node)) { return cleanSignatureText(node.getText()).replace(/^export\s+/, ""); } if (Node.isTypeAliasDeclaration(node)) { return `type ${node.getName()} = ${cleanSignatureText(node.getTypeNodeOrThrow().getText())};`; } return undefined; } function namespaceSupportingTypes(node: TsNode, name: string): string[] { return node .getSourceFile() .getModules() .filter((module) => module.getName() === name) .flatMap((module) => { const body = module.getBody(); if (!Node.isModuleBlock(body)) return []; const typeLines = body .getStatements() .map(localTypeSignature) .filter((line): line is string => Boolean(line)); if (typeLines.length === 0) return []; return [ [ `namespace ${name} {`, ...typeLines.map((line) => line .split("\n") .map((innerLine) => ` ${innerLine}`) .join("\n"), ), "}", ].join("\n"), ]; }); } const localTypeDeclarationsCache = new Map>(); function getLocalTypeDeclarations( sourceFile: SourceFile, ): Map { const filePath = sourceFile.getFilePath(); const cached = localTypeDeclarationsCache.get(filePath); if (cached) return cached; const declarationsByName = new Map(); const addDeclarations = (name: string, declarations: TsNode[]) => { declarationsByName.set(name, [ ...(declarationsByName.get(name) ?? []), ...declarations, ]); }; for (const [name, declarations] of sourceFile.getExportedDeclarations()) { addDeclarations(name, declarations); } for (const importDecl of sourceFile.getImportDeclarations()) { for (const namedImport of importDecl.getNamedImports()) { const symbol = namedImport.getNameNode().getSymbol(); addDeclarations( namedImport.getName(), symbol?.getAliasedSymbol()?.getDeclarations() ?? [], ); } } localTypeDeclarationsCache.set(filePath, declarationsByName); return declarationsByName; } function referencedLocalTypes(node: TsNode, typeText: string): string[] { const referencedNames = [ ...new Set( typeText.match(/\b[A-Z][A-Za-z0-9_]*(?:Props|Adapters)\b/g) ?? [], ), ]; if (referencedNames.length === 0) return []; const declarationsByName = getLocalTypeDeclarations(node.getSourceFile()); return referencedNames.flatMap((name) => (declarationsByName.get(name) ?? []) .map(resolveAliasedDeclaration) .map(localTypeSignature) .filter((line): line is string => Boolean(line)), ); } function variableSignature(node: TsNode, name: string): string | undefined { if (!Node.isVariableDeclaration(node)) return undefined; const typeText = declarationTypeText(node); const namespaceTypes = namespaceSupportingTypes(node, name); return [ ...referencedLocalTypes(node, [typeText, ...namespaceTypes].join("\n")), ...namespaceTypes, `const ${name}: ${typeText};`, ].join("\n\n"); } function bindingElementSignature( node: TsNode, name: string, ): string | undefined { if (!Node.isBindingElement(node)) return undefined; return `const ${name}: ${cleanSignatureText(node.getType().getText(node))};`; } function typeSignature(node: TsNode, name: string): string | undefined { if (Node.isInterfaceDeclaration(node)) { return cleanSignatureText(node.getText()); } if (Node.isTypeAliasDeclaration(node)) { return `type ${name} = ${cleanSignatureText(node.getTypeNodeOrThrow().getText())};`; } return undefined; } export function extractSignature( node: TsNode | undefined, name: string, ): string | undefined { if (!node) return undefined; const signature = classSignature(node, name) ?? functionSignature(node, name) ?? variableSignature(node, name) ?? bindingElementSignature(node, name) ?? typeSignature(node, name); return signature ? cleanSignatureText(signature) : undefined; } // ── Inheritance classification ───────────────────────────────────────────── function classifyInheritance(filePath: string): InheritedFrom | undefined { if (filePath.includes("node_modules/@types/react")) return "react"; if (filePath.includes("node_modules/react-textarea-autosize")) return "react-textarea-autosize"; if ( filePath.includes("node_modules/@radix-ui") || filePath.includes("node_modules/radix-ui") ) { return "radix"; } if (filePath.includes("node_modules/csstype")) return "csstype"; if (filePath.includes("node_modules/tw-")) return "tw"; return undefined; } /** Returns the inherited-from category if every declaration of the property * comes from third-party code. Returns undefined if any declaration is * local — even if some other declaration comes from a third party. */ function inheritanceForProperty( prop: TsMorphSymbol, ): InheritedFrom | undefined { const declarations = prop.getDeclarations(); if (declarations.length === 0) return undefined; let category: InheritedFrom | undefined; for (const decl of declarations) { const source = classifyInheritance(decl.getSourceFile().getFilePath()); if (!source) return undefined; // Prefer the most specific category: react > csstype > tw > textarea-autosize > radix > other. // For our purposes any single category is fine; picking the first is stable. category ??= source; } return category; } // ── Property → PropModel ─────────────────────────────────────────────────── function typeDisplayName(type: Type, fallback: string): string { const name = type.getAliasSymbol()?.getName() ?? type.getSymbol()?.getName(); if (!name || name === "__type") return fallback; return name; } function propertyTypePath(ownerTypeName: string, propertyName: string): string { return `${ownerTypeName}["${propertyName}"]`; } function nonNullableUnionTypes(type: Type): Type[] { if (!type.isUnion()) return [type]; return type .getUnionTypes() .filter((unionType) => !unionType.isUndefined() && !unionType.isNull()); } function typeIncludesUndefined(type: Type): boolean { return ( type.isUndefined() || type.getUnionTypes().some((t) => t.isUndefined()) ); } function isPrimitiveType(type: Type): boolean { return ( type.isString() || type.isNumber() || type.isBoolean() || type.isStringLiteral() || type.isNumberLiteral() || type.isBooleanLiteral() || type.isUndefined() || type.isNull() ); } function symbolHasNonNeverDeclaration(symbol: TsMorphSymbol): boolean { return symbol.getDeclarations().some((declaration) => { const typeText = Node.isPropertySignature(declaration) || Node.isPropertyDeclaration(declaration) ? declarationTypeText(declaration) : symbol.getTypeAtLocation(declaration).getText(declaration); return cleanTypeText(typeText) !== "never"; }); } function documentableProperties(type: Type): TsMorphSymbol[] { const directProperties = type.getProperties(); if (!type.isUnion() || directProperties.length > 0) return directProperties; const types = nonNullableUnionTypes(type); if (types.length > 3) return directProperties; const properties = new Map(); for (const currentType of types) { for (const prop of currentType.getProperties()) { const name = prop.getName(); const existing = properties.get(name); if ( !existing || (!symbolHasNonNeverDeclaration(existing) && symbolHasNonNeverDeclaration(prop)) ) { properties.set(name, prop); } } } if (properties.size > 30) return directProperties; return [...properties.values()]; } function shouldExpandChildType(type: Type, typeText: string): boolean { if (type.isArray() || type.isTuple()) return false; const candidateTypes = nonNullableUnionTypes(type); if (candidateTypes.length === 0) return false; if (candidateTypes.every(isPrimitiveType)) return false; if ( type.isUnion() && candidateTypes.some((unionType) => isPrimitiveType(unionType)) ) { return false; } if (type.getCallSignatures().length > 0) return false; if (typeText.includes("ReactNode") || typeText.startsWith("React.")) { return false; } // Note: do NOT pre-filter by inheritance here. The model carries the full // tree and each projection decides which children to drop. Pre-filtering by // inheritance would prevent projections that keep some inherited categories // (e.g. api-ref keeps radix) from rendering nested portal/dismiss // sub-objects whose direct properties happen to come from third-party. const childProps = documentableProperties(type).filter( (childProp) => !childProp.getName().startsWith("__"), ); return childProps.length > 0 && childProps.length <= 30; } function processTypeChildren( type: Type, typeName: string, location: TsNode, depth: number, options?: JsDocRenderOptions, ): PropModel["children"] | undefined { if (depth >= 3) return undefined; const childModel = processTypeProperties( type, typeName, location, depth + 1, options, ); if (!childModel || childModel.length === 0) return undefined; return [{ typeName, props: childModel }]; } function parameterFromProperty( prop: TsMorphSymbol, location: TsNode, depth: number, ownerTypeName: string, options?: JsDocRenderOptions, ): PropModel | undefined { const name = prop.getName(); if (name.startsWith("__")) return undefined; const declarations = prop.getDeclarations(); const decl = declarations.find((declaration) => { const typeText = Node.isPropertySignature(declaration) || Node.isPropertyDeclaration(declaration) ? declarationTypeText(declaration) : prop.getTypeAtLocation(declaration).getText(declaration); return cleanTypeText(typeText) !== "never"; }) ?? declarations[0]; if ( name === "children" && decl?.getSourceFile().getFilePath().includes("node_modules/") ) { return undefined; } const propType = decl ? prop.getTypeAtLocation(decl) : prop.getTypeAtLocation(location); // Two type representations: // - `rawType`: resolved type text, preserves `| undefined`. Primitive-docs // projection consumes this verbatim. // - `declaredType`: prefers the explicit type-node the author wrote where // available, else falls back to the resolved type. Api-ref projection // consumes this and strips trailing `| undefined`. Mirrors the historic // split between the two generators. const rawType = rawTypeText(propType.getText(decl ?? location)); const declaredType = decl && (Node.isPropertySignature(decl) || Node.isPropertyDeclaration(decl)) ? declarationTypeText(decl) : cleanTypeText(propType.getText(decl ?? location)); const childTypeName = typeDisplayName( propType, propertyTypePath(ownerTypeName, name), ); const children = shouldExpandChildType(propType, rawType) ? processTypeChildren( propType, childTypeName, decl ?? location, depth, options, ) : undefined; let required: boolean; if (prop.isOptional() || typeIncludesUndefined(propType)) { required = false; } else if ( decl && ((Node.isPropertySignature(decl) && !decl.hasQuestionToken()) || (Node.isPropertyDeclaration(decl) && !decl.hasQuestionToken())) ) { required = true; } else { required = !prop.isOptional(); } const jsDoc = propertyDeclarationsJsDocMeta(declarations, options); const inheritedFrom = inheritanceForProperty(prop); const model: PropModel = { name, rawType, declaredType, required, }; if (jsDoc.description) model.description = jsDoc.description; if (jsDoc.default) model.default = jsDoc.default; if (jsDoc.deprecated) model.deprecated = jsDoc.deprecated; if (inheritedFrom) model.inheritedFrom = inheritedFrom; if (children) model.children = children; return model; } function processTypeProperties( type: Type, typeName: string, location: TsNode, depth = 0, options?: JsDocRenderOptions, ): PropModel[] | undefined { const properties = documentableProperties(type) .map((prop) => parameterFromProperty(prop, location, depth, typeName, options), ) .filter((param): param is PropModel => Boolean(param)); if (properties.length === 0) return undefined; return properties; } function parameterFromSignatureParameter( parameter: TsMorphSymbol, location: TsNode, depth: number, options?: JsDocRenderOptions, ): PropModel | undefined { const decl = parameter.getDeclarations()[0]; const parameterType = decl ? parameter.getTypeAtLocation(decl) : parameter.getTypeAtLocation(location); const rawType = rawTypeText(parameterType.getText(decl ?? location)); // For function parameters the api-ref projection wants the cleaned text // (legacy used cleanTypeText on the call-signature parameter type). There // is no explicit "type-node" for an inferred parameter, so declaredType // mirrors rawType minus `| undefined`. const declaredType = cleanTypeText(parameterType.getText(decl ?? location)); let name = parameter.getName(); if (decl && Node.isParameterDeclaration(decl)) { const nameNode = decl.getNameNode(); if (Node.isObjectBindingPattern(nameNode)) { name = declaredType.endsWith("Props") ? "props" : "options"; } } let required = !parameter.isOptional(); if (decl && Node.isParameterDeclaration(decl)) { required = !decl.hasQuestionToken() && !decl.hasInitializer() && !decl.isRestParameter(); } const jsDoc = propertyJsDocMeta(decl, options); const model: PropModel = { name, rawType, declaredType, required, }; if (jsDoc.description) model.description = jsDoc.description; if (jsDoc.default) model.default = jsDoc.default; if (jsDoc.deprecated) model.deprecated = jsDoc.deprecated; if (shouldExpandChildType(parameterType, declaredType)) { const children = processTypeChildren( parameterType, typeDisplayName(parameterType, declaredType), decl ?? location, depth, options, ); if (children) model.children = children; } return model; } // ── Export shape extraction ──────────────────────────────────────────────── export function processTypeOrInterface( declaration: InterfaceDeclaration | TypeAliasDeclaration, typeName: string, options?: JsDocRenderOptions, ): PropModel[] | undefined { return processTypeProperties( declaration.getType(), typeName, declaration, 0, options, ); } function processCallableDeclaration( declaration: TsNode, options?: JsDocRenderOptions, ): PropModel[] | undefined { const signature = declaration.getType().getCallSignatures()[0]; if (!signature) return undefined; const parameters = signature .getParameters() .map((parameter) => parameterFromSignatureParameter(parameter, declaration, 0, options), ) .filter((param): param is PropModel => Boolean(param)); if (parameters.length === 0) return undefined; return parameters; } function getComponentPropsType(declaration: TsNode): Type | undefined { const type = declaration.getType(); const parameter = type.getCallSignatures()[0]?.getParameters()[0]; const parameterDeclaration = parameter?.getDeclarations()[0]; if (parameterDeclaration) { return parameter.getTypeAtLocation(parameterDeclaration); } return undefined; } export function processComponentDeclaration( declaration: TsNode, typeName: string, options?: JsDocRenderOptions, ): PropModel[] | undefined { const propsType = getComponentPropsType(declaration); if (!propsType) return undefined; return processTypeProperties( propsType, `${typeName} props`, declaration, 0, options, ); } function classExtractedShape( declaration: TsNode, typeName: string, ): PropModel[] | undefined { if (!Node.isClassDeclaration(declaration)) return undefined; const parameters: PropModel[] = []; // Class members carry no per-member required-ness or JSDoc here. Match // legacy api-surface output exactly: empty description, required omitted. for (const ctor of declaration.getConstructors()) { if (!isPublicClassMember(ctor)) continue; const t = `(${ctor.getParameters().map(parameterSignature).join(", ")}) => ${typeName}`; parameters.push({ name: "constructor", rawType: t, declaredType: t, description: "", }); } for (const property of declaration.getProperties()) { if (!isPublicClassMember(property)) continue; const t = declarationTypeText(property); parameters.push({ name: `${classMemberPrefix(property)}${property.getName()}`, rawType: t, declaredType: t, description: "", }); } for (const method of declaration.getMethods()) { if (!isPublicClassMember(method)) continue; const t = `(${method.getParameters().map(parameterSignature).join(", ")}) => ${returnTypeText(method)}`; parameters.push({ name: `${classMemberPrefix(method)}${method.getName()}`, rawType: t, declaredType: t, description: "", }); } if (parameters.length === 0) return undefined; return parameters; } export function extractExportShape( item: ExportInfo, ): ExtractedShape | undefined { if (!item.sourcePath) return undefined; const filePath = path.join(REPO_ROOT, item.sourcePath); if (!fs.existsSync(filePath)) return undefined; const project = getProject(); const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath); const declaration = chooseDeclaration( sourceFile.getExportedDeclarations().get(item.name) ?? [], ); return shapeForDeclaration( declaration, item.name, item.kind, item.jsDocRenderOptions, ); } function shapeForDeclaration( declaration: TsNode | undefined, name: string, exportKind: ExportInfo["kind"], options?: JsDocRenderOptions, ): ExtractedShape | undefined { if (!declaration) return undefined; if (Node.isInterfaceDeclaration(declaration)) { const params = processTypeOrInterface(declaration, name, options); return params ? { kind: "interface", name, parameters: params } : undefined; } if (Node.isTypeAliasDeclaration(declaration)) { const params = processTypeOrInterface(declaration, name, options); return params ? { kind: "type", name, parameters: params } : undefined; } if (exportKind === "component") { const params = processComponentDeclaration(declaration, name, options); return params ? { kind: "component", name, parameters: params } : undefined; } if (exportKind === "function") { const params = processCallableDeclaration(declaration, options); return params ? { kind: "function", name, parameters: params } : undefined; } // Match legacy: only attempt class extraction here, no callable fallback for // "value" kind exports (those render their signature in MDX, not a table). const params = classExtractedShape(declaration, name); return params ? { kind: "class", name, parameters: params } : undefined; } /** Walk every exported type in a source file and produce shapes for any * type alias or interface (skips classes/values/components). Used to absorb * the supporting types referenced by a primary export. */ export function extractSupportingTypeShapes( filePath: string, options?: JsDocRenderOptions, ): Map { const project = getProject(); const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath); const result = new Map(); sourceFile.getExportedDeclarations().forEach((declarations, name) => { for (const rawDeclaration of declarations) { const declaration = resolveAliasedDeclaration(rawDeclaration); if ( Node.isInterfaceDeclaration(declaration) || Node.isTypeAliasDeclaration(declaration) ) { const params = processTypeOrInterface(declaration, name, options); if (params) { result.set(name, { kind: Node.isInterfaceDeclaration(declaration) ? "interface" : "type", name, parameters: params, }); } } } }); return result; } export function isPrimitiveOnlyType(filePath: string, name: string): boolean { const project = getProject(); const sourceFile = project.getSourceFile(filePath) ?? project.addSourceFileAtPath(filePath); const declaration = chooseDeclaration( sourceFile.getExportedDeclarations().get(name) ?? [], ); return ( !!declaration && Node.isTypeAliasDeclaration(declaration) && nonNullableUnionTypes(declaration.getType()).every(isPrimitiveType) ); }