/** * Util reference handler — TAE-based auto-discovery. * * Generates JSON reference files for hooks, controllers, mixins, factories, * contexts, selectors, and utilities by scanning package entry points. * * Exports are included by naming convention or `@public` JSDoc tag: * select* (capital 3rd), use* (capital 3rd), *Controller (class), * create* (function), or any export tagged @public. * * Extraction routing is determined by export node type: * - Class / *Controller non-function → controller extraction (raw TS AST) * - Non-function → context extraction (type only) * - Function → function extraction (TAE call signatures) * * 4 Discovery Strategies (run per entry point, in order): * * Strategy 1 — TAE on local modules (primary path) * Parses each resolved local module with typescript-api-extractor. * * Strategy 2 — TAE on index file (class re-exports) * Parses the entry index file itself to find controllers that are * re-exported but whose source module is separate. * * Strategy 3 — Raw TS AST fallback (failed modules) * When TAE fails on a module (e.g., UniqueESSymbol in HTML bundle), * falls back to walking the raw TypeScript AST for exports. * * Strategy 4 — Raw TS AST for missed classes * Scans local modules for exported classes that TAE parsed but missed. * * All overloads are preserved. When a function or constructor has multiple * overload signatures, each becomes a separate entry in the overloads array. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { kebabCase } from 'es-toolkit/string'; import * as ts from 'typescript'; import * as tae from 'typescript-api-extractor'; import { type ParamDef, type ReturnValue, type UtilOverload, type UtilReference, UtilReferenceSchema, } from '../../../src/types/util-reference.js'; import { abbreviateType, formatDetailedType, formatType } from './formatter.js'; const PREFIX = '\x1b[35m[api-docs-builder]\x1b[0m'; const log = { info: (...args: unknown[]) => console.log(PREFIX, ...args), warn: (...args: unknown[]) => console.warn(PREFIX, '\x1b[33mwarn:\x1b[0m', ...args), error: (...args: unknown[]) => console.error(PREFIX, '\x1b[31merror:\x1b[0m', ...args), success: (...args: unknown[]) => console.log(PREFIX, ...args), }; // ─── Types ───────────────────────────────────────────────────────── export interface UtilEntry { slug: string; data: UtilReference; framework: 'react' | 'html' | null; } interface EntryPoint { index: string; framework: 'react' | 'html' | null; } // ─── Entry Points ────────────────────────────────────────────────── // IMPORTANT: React entries must come before HTML entries. On slug collision, // the first framework keeps the bare slug; later frameworks get prefixed // (e.g., "create-player" for React, "html-create-player" for HTML). const UTIL_ENTRY_POINTS: EntryPoint[] = [ { index: 'packages/react/src/index.ts', framework: 'react' }, { index: 'packages/store/src/react/hooks/index.ts', framework: 'react' }, { index: 'packages/html/src/index.ts', framework: 'html' }, { index: 'packages/store/src/html/controllers/index.ts', framework: 'html' }, { index: 'packages/core/src/dom/store/selectors.ts', framework: null }, { index: 'packages/store/src/core/selector.ts', framework: null }, ]; // ─── Phase 1: Resolve Local Modules ─────────────────────────────── function resolveModulePath(fromFile: string, specifier: string): string { const dir = path.dirname(fromFile); const resolved = path.resolve(dir, specifier); // Try exact match, then with extensions const extensions = ['', '.ts', '.tsx']; for (const ext of extensions) { const full = resolved + ext; if (fs.existsSync(full)) return full; } // Try index files for (const ext of ['.ts', '.tsx']) { const indexFile = path.join(resolved, `index${ext}`); if (fs.existsSync(indexFile)) return indexFile; } return resolved; } function resolveLocalModules(indexPath: string): string[] { const sourceFile = ts.createSourceFile(indexPath, fs.readFileSync(indexPath, 'utf-8'), ts.ScriptTarget.Latest, true); const localPaths: string[] = []; ts.forEachChild(sourceFile, (node) => { if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { const specifier = node.moduleSpecifier.text; if (specifier.startsWith('.')) { localPaths.push(resolveModulePath(indexPath, specifier)); } } }); return localPaths; } // ─── Phase 2: Convention Matching ────────────────────────────────── function isUtilExport(exportNode: tae.ExportNode): boolean { const name = exportNode.name; const type = exportNode.type; // Skip type-only exports (interfaces, type aliases without runtime value) if (type instanceof tae.ObjectNode && !type.typeName) return false; // Naming conventions (auto-included) if (name.startsWith('select') && name.charAt(6) >= 'A' && name.charAt(6) <= 'Z' && type instanceof tae.FunctionNode) { return true; } if (name.startsWith('use') && name.charAt(3) >= 'A' && name.charAt(3) <= 'Z' && type instanceof tae.FunctionNode) { return true; } if (name.endsWith('Controller') && !(type instanceof tae.FunctionNode)) return true; if (name.startsWith('create') && type instanceof tae.FunctionNode) return true; // @public tag (for anything else — utilities, contexts, etc.) if (exportNode.isPublic(true)) return true; return false; } // ─── Display Name ────────────────────────────────────────────────── function getDisplayName(name: string): string { if (name.startsWith('create') && name.includes('Mixin')) { // createProviderMixin → ProviderMixin return name.replace(/^create/, ''); } return name; } // ─── Extraction: Functions ───────────────────────────────────────── function extractFunctionOverloads( exportNode: tae.ExportNode, filePath: string, program: ts.Program, allExports?: tae.ExportNode[] ): UtilOverload[] { const funcType = exportNode.type; if (!(funcType instanceof tae.FunctionNode)) return []; const signatures = funcType.callSignatures; if (signatures.length === 0) return []; // Get per-overload JSDoc from raw TS AST const overloadDocs = getOverloadDocs(filePath, program, exportNode.name); const overloads = signatures.map((sig, i) => buildOverload(sig, overloadDocs[i]?.description, overloadDocs[i]?.label, allExports) ); fixDegradedTypes(overloads, filePath, program, exportNode.name); return overloads; } function buildOverload( sig: tae.CallSignature, doc?: string, label?: string, allExports?: tae.ExportNode[] ): UtilOverload { const parameters: Record = {}; for (const param of sig.parameters) { const typeStr = allExports ? formatDetailedType(param.type, allExports, param.optional) : formatType(param.type, param.optional); const abbreviated = abbreviateType(param.name, typeStr); const entry: ParamDef = { type: abbreviated ?? typeStr }; if (abbreviated && typeStr !== abbreviated) entry.detailedType = typeStr; if (param.documentation?.description) entry.description = param.documentation.description; if (!param.optional) entry.required = true; // Clean undefined fields if (entry.detailedType === undefined) delete entry.detailedType; if (entry.description === undefined) delete entry.description; if (!entry.required) delete entry.required; parameters[param.name] = entry; } const returnValue = buildReturnValue(sig.returnValueType, allExports); const overload: UtilOverload = { parameters, returnValue }; if (label) overload.label = label; if (doc) overload.description = doc; return overload; } function buildReturnValue(type: tae.AnyType, allExports?: tae.ExportNode[]): ReturnValue { const typeStr = allExports ? formatDetailedType(type, allExports, false) : formatType(type, false); const abbreviated = abbreviateType('return', typeStr); const result: ReturnValue = { type: abbreviated ?? typeStr }; if (abbreviated && typeStr !== abbreviated) result.detailedType = typeStr; // Resolve ExternalTypeNode via allExports before checking for ObjectNode fields let resolvedType = type; if (allExports && type instanceof tae.ExternalTypeNode) { const resolved = allExports.find((e) => e.name === type.typeName.name && e.reexportedFrom === undefined); if (resolved) resolvedType = resolved.type; } // Expand object properties as fields if (resolvedType instanceof tae.ObjectNode && resolvedType.properties.length > 0) { const fields: Record = {}; for (const prop of resolvedType.properties) { const propType = allExports ? formatDetailedType(prop.type, allExports, prop.optional) : formatType(prop.type, prop.optional); const propAbbrev = abbreviateType(prop.name, propType); const field: { type: string; detailedType?: string; description?: string } = { type: propAbbrev ?? propType }; if (propAbbrev && propType !== propAbbrev) field.detailedType = propType; if (prop.documentation?.description) field.description = prop.documentation.description; fields[prop.name] = field; } result.fields = fields; } return result; } // ─── Degraded Type Repair ─────────────────────────────────────────── function isDegradedType(type: string): boolean { return /\bany\b/.test(type) || type.includes('__type'); } function fixDegradedTypes(overloads: UtilOverload[], filePath: string, program: ts.Program, funcName: string): void { const sourceFile = program.getSourceFile(filePath); if (!sourceFile) return; // Collect overload declarations (no body) and implementation fallback const overloadDecls: ts.FunctionDeclaration[] = []; let implDecl: ts.FunctionDeclaration | undefined; function visit(node: ts.Node) { if (ts.isFunctionDeclaration(node) && node.name?.text === funcName) { if (!node.body) { overloadDecls.push(node); } else { implDecl = node; } } ts.forEachChild(node, visit); } visit(sourceFile); const decls = overloadDecls.length > 0 ? overloadDecls : implDecl ? [implDecl] : []; if (decls.length === 0) return; for (let i = 0; i < overloads.length; i++) { const overload = overloads[i]!; const decl = decls[i]; if (!decl) continue; // Fix degraded param types for (const [paramName, paramDef] of Object.entries(overload.parameters)) { const effectiveType = paramDef.detailedType ?? paramDef.type; if (!isDegradedType(effectiveType)) continue; const astParam = decl.parameters.find((p) => ts.isIdentifier(p.name) && p.name.text === paramName); if (!astParam?.type) continue; const rawType = astParam.type.getText(sourceFile); const abbreviated = abbreviateType(paramName, rawType); paramDef.type = abbreviated ?? rawType; if (abbreviated && rawType !== abbreviated) { paramDef.detailedType = rawType; } else { delete paramDef.detailedType; } } // Fix degraded return type const effectiveReturn = overload.returnValue.detailedType ?? overload.returnValue.type; if (isDegradedType(effectiveReturn) && decl.type) { const rawReturn = decl.type.getText(sourceFile); const abbreviated = abbreviateType('return', rawReturn); overload.returnValue.type = abbreviated ?? rawReturn; if (abbreviated && rawReturn !== abbreviated) { overload.returnValue.detailedType = rawReturn; } else { delete overload.returnValue.detailedType; } } } } // ─── Extraction: Controllers (Classes via raw TS AST) ────────────── function extractControllerOverloads(filePath: string, program: ts.Program, className: string): UtilOverload[] { const sourceFile = program.getSourceFile(filePath); if (!sourceFile) return []; let classDecl: ts.ClassDeclaration | undefined; function findClass(node: ts.Node) { if (ts.isClassDeclaration(node) && node.name?.text === className) { classDecl = node; } ts.forEachChild(node, findClass); } findClass(sourceFile); if (!classDecl) return []; // Get constructor overloads (declarations without body), falling back to // the implementation constructor when there are no overload declarations. const overloadDecls: ts.ConstructorDeclaration[] = []; let implDecl: ts.ConstructorDeclaration | undefined; for (const member of classDecl.members) { if (ts.isConstructorDeclaration(member)) { if (!member.body) { overloadDecls.push(member); } else { implDecl = member; } } } const constructorDecls = overloadDecls.length > 0 ? overloadDecls : implDecl ? [implDecl] : []; if (constructorDecls.length === 0) return []; // Get public instance members for returnValue.fields const fields = extractPublicMembers(classDecl, sourceFile); return constructorDecls.map((decl) => { const parameters: Record = {}; for (const param of decl.parameters) { const result = buildParamEntry(param, decl, sourceFile); if (result) parameters[result.name] = result.entry; } // Build return value with class type and public members const typeParams = getClassTypeParams(classDecl!); const returnValue: ReturnValue = { type: typeParams ? `${className}<${typeParams}>` : className, }; if (Object.keys(fields).length > 0) { returnValue.fields = fields; } const overload: UtilOverload = { parameters, returnValue }; // Get overload-specific JSDoc const label = getJSDocTagValue(decl, 'label'); if (label) overload.label = label; const jsDoc = getNodeJSDoc(decl); if (jsDoc) overload.description = jsDoc; return overload; }); } function extractPublicMembers( classDecl: ts.ClassDeclaration, sourceFile: ts.SourceFile ): Record { const fields: Record = {}; for (const member of classDecl.members) { // Skip private, protected, static, constructor if ( member.modifiers?.some( (m) => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword || m.kind === ts.SyntaxKind.StaticKeyword ) ) continue; // Skip # private fields if (ts.isPropertyDeclaration(member) && ts.isPrivateIdentifier(member.name)) continue; // Skip lifecycle methods const name = member.name && ts.isIdentifier(member.name) ? member.name.text : undefined; if (!name) continue; if (['hostConnected', 'hostDisconnected', 'hostUpdate', 'hostUpdated'].includes(name)) continue; if (ts.isGetAccessorDeclaration(member)) { const typeStr = member.type ? member.type.getText(sourceFile) : 'unknown'; const abbreviated = abbreviateType(name, typeStr); const description = getNodeJSDoc(member); const field: { type: string; detailedType?: string; description?: string } = { type: abbreviated ?? typeStr }; if (abbreviated && typeStr !== abbreviated) field.detailedType = typeStr; if (description) field.description = description; fields[name] = field; } else if (ts.isMethodDeclaration(member) && !member.body) { // Public method declaration (without body = overload, but we skip those) } else if (ts.isMethodDeclaration(member)) { const params = member.parameters .map((p) => { const pName = ts.isIdentifier(p.name) ? p.name.text : '...'; const pType = p.type ? p.type.getText(sourceFile) : 'unknown'; return `${pName}: ${pType}`; }) .join(', '); const retType = member.type ? member.type.getText(sourceFile) : 'void'; const typeStr = `(${params}) => ${retType}`; const abbreviated = abbreviateType(name, typeStr); const description = getNodeJSDoc(member); const field: { type: string; detailedType?: string; description?: string } = { type: abbreviated ?? typeStr }; if (abbreviated && typeStr !== abbreviated) field.detailedType = typeStr; if (description) field.description = description; fields[name] = field; } } return fields; } function getClassTypeParams(classDecl: ts.ClassDeclaration): string { if (!classDecl.typeParameters || classDecl.typeParameters.length === 0) return ''; return classDecl.typeParameters.map((tp) => tp.name.text).join(', '); } // ─── Extraction: Context (non-function @public exports) ──────────── function extractContextOverload(exportNode: tae.ExportNode): UtilOverload { const typeStr = formatType(exportNode.type, false); return { parameters: {}, returnValue: { type: typeStr }, }; } // ─── JSDoc Helpers ───────────────────────────────────────────────── interface OverloadDoc { description?: string; label?: string; } function getOverloadDocs(filePath: string, program: ts.Program, funcName: string): OverloadDoc[] { const sourceFile = program.getSourceFile(filePath); if (!sourceFile) return []; const docs: OverloadDoc[] = []; function visit(node: ts.Node) { if (ts.isFunctionDeclaration(node) && node.name?.text === funcName && !node.body) { // This is an overload declaration docs.push({ description: getNodeJSDoc(node), label: getJSDocTagValue(node, 'label'), }); } ts.forEachChild(node, visit); } visit(sourceFile); return docs; } function getNodeJSDoc(node: ts.Node): string | undefined { const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; if (!jsDocNodes?.length) return undefined; const doc = jsDocNodes[0]!; if (!doc.comment) return undefined; if (typeof doc.comment === 'string') return doc.comment; // Handle JSDocComment array return doc.comment.map((c: ts.JSDocText | ts.JSDocLink) => ('text' in c ? c.text : '')).join(''); } function getJSDocParamDescription(node: ts.Node, paramName: string): string | undefined { const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; if (!jsDocNodes?.length) return undefined; for (const doc of jsDocNodes) { if (!doc.tags) continue; for (const tag of doc.tags) { if (ts.isJSDocParameterTag(tag) && ts.isIdentifier(tag.name) && tag.name.text === paramName) { if (!tag.comment) return undefined; const raw = typeof tag.comment === 'string' ? tag.comment : tag.comment.map((c: ts.JSDocText | ts.JSDocLink) => ('text' in c ? c.text : '')).join(''); return raw.replace(/^\s*-\s+/, ''); } } } return undefined; } function hasJSDocTag(node: ts.Node, tagName: string): boolean { const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; if (!jsDocNodes?.length) return false; for (const doc of jsDocNodes) { if (!doc.tags) continue; for (const tag of doc.tags) { if (tag.tagName.text === tagName) return true; } } return false; } function getJSDocTagValue(node: ts.Node, tagName: string): string | undefined { const jsDocNodes = (node as any).jsDoc as ts.JSDoc[] | undefined; if (!jsDocNodes?.length) return undefined; for (const doc of jsDocNodes) { if (!doc.tags) continue; for (const tag of doc.tags) { if (tag.tagName.text === tagName) { if (!tag.comment) return undefined; if (typeof tag.comment === 'string') return tag.comment.trim(); return tag.comment .map((c: ts.JSDocText | ts.JSDocLink) => ('text' in c ? c.text : '')) .join('') .trim(); } } } return undefined; } // ─── Shared AST Helpers ───────────────────────────────────────────── function buildParamEntry( param: ts.ParameterDeclaration, decl: ts.FunctionLikeDeclaration, sourceFile: ts.SourceFile ): { name: string; entry: ParamDef } | undefined { if (!ts.isIdentifier(param.name)) return undefined; const name = param.name.text; const isOptional = !!param.questionToken || !!param.initializer; let typeStr = 'unknown'; if (param.type) { typeStr = param.type.getText(sourceFile); } const abbreviated = abbreviateType(name, typeStr); const description = getJSDocParamDescription(decl, name); const entry: ParamDef = { type: abbreviated ?? typeStr }; if (abbreviated && typeStr !== abbreviated) entry.detailedType = typeStr; if (description) entry.description = description; if (!isOptional) entry.required = true; if (!entry.required) delete entry.required; return { name, entry }; } // ─── Raw TS AST: Fallback Discovery ──────────────────────────────── interface RawExportInfo { name: string; isFunction: boolean; isClass: boolean; hasPublicTag: boolean; description?: string; sourceFile: string; } function discoverExportsFromRawAST(modulePath: string, program: ts.Program): RawExportInfo[] { const sourceFile = program.getSourceFile(modulePath); if (!sourceFile) return []; const results: RawExportInfo[] = []; function visit(node: ts.Node) { // Exported function declarations if ( ts.isFunctionDeclaration(node) && node.name && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && !node.body // overload declaration ) { const name = node.name.text; const jsDoc = getNodeJSDoc(node); const hasPublicTag = hasJSDocTag(node, 'public'); // Only add if not already in results (first overload wins for the name) if (!results.some((r) => r.name === name)) { results.push({ name, isFunction: true, isClass: false, hasPublicTag, description: jsDoc, sourceFile: modulePath, }); } } // Exported function with body (single signature) if ( ts.isFunctionDeclaration(node) && node.name && node.body && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) && !results.some((r) => r.name === node.name!.text) ) { const name = node.name.text; const jsDoc = getNodeJSDoc(node); const hasPublicTag = hasJSDocTag(node, 'public'); results.push({ name, isFunction: true, isClass: false, hasPublicTag, description: jsDoc, sourceFile: modulePath, }); } // Exported class declarations if ( ts.isClassDeclaration(node) && node.name && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ) { const name = node.name.text; const jsDoc = getNodeJSDoc(node); results.push({ name, isFunction: false, isClass: true, hasPublicTag: false, description: jsDoc, sourceFile: modulePath, }); } // Exported const/variable declarations if (ts.isVariableStatement(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { for (const decl of node.declarationList.declarations) { if (ts.isIdentifier(decl.name)) { const jsDoc = getNodeJSDoc(node); const hasPublicTag = hasJSDocTag(node, 'public'); results.push({ name: decl.name.text, isFunction: false, isClass: false, hasPublicTag, description: jsDoc, sourceFile: modulePath, }); } } } ts.forEachChild(node, visit); } visit(sourceFile); return results; } function isRawUtilExport(info: RawExportInfo): boolean { const { name, isFunction, isClass, hasPublicTag } = info; if (name.startsWith('select') && name.charAt(6) >= 'A' && name.charAt(6) <= 'Z') return true; if (name.startsWith('use') && name.charAt(3) >= 'A' && name.charAt(3) <= 'Z' && isFunction) return true; if (name.endsWith('Controller') && isClass) return true; if (name.startsWith('create') && isFunction) return true; if (hasPublicTag) return true; return false; } // ─── Raw TS AST: Function Extraction ─────────────────────────────── function extractFunctionOverloadsFromAST(filePath: string, program: ts.Program, funcName: string): UtilOverload[] { const sourceFile = program.getSourceFile(filePath); if (!sourceFile) return []; // Collect overload declarations (no body) and implementation (has body) const overloadDecls: ts.FunctionDeclaration[] = []; let implDecl: ts.FunctionDeclaration | undefined; function visit(node: ts.Node) { if (ts.isFunctionDeclaration(node) && node.name?.text === funcName) { if (!node.body) { overloadDecls.push(node); } else { implDecl = node; } } ts.forEachChild(node, visit); } visit(sourceFile); const decls = overloadDecls.length > 0 ? overloadDecls : implDecl ? [implDecl] : []; if (decls.length === 0) return []; return decls.map((d) => buildOverloadFromAST(d, sourceFile)); } function buildOverloadFromAST(decl: ts.FunctionDeclaration, sourceFile: ts.SourceFile): UtilOverload { const parameters: Record = {}; for (const param of decl.parameters) { const result = buildParamEntry(param, decl, sourceFile); if (result) parameters[result.name] = result.entry; } let returnType = 'unknown'; if (decl.type) { returnType = decl.type.getText(sourceFile); } const returnValue: ReturnValue = { type: returnType }; // Try to expand return type fields from source if it's an interface/type in the same file const fields = extractReturnTypeFields(returnType, sourceFile); if (fields && Object.keys(fields).length > 0) { returnValue.fields = fields; } const overload: UtilOverload = { parameters, returnValue }; const label = getJSDocTagValue(decl, 'label'); if (label) overload.label = label; const doc = getNodeJSDoc(decl); if (doc) overload.description = doc; return overload; } function extractReturnTypeFields( returnType: string, sourceFile: ts.SourceFile ): Record | undefined { // Extract the base type name (strip generic parameters) const match = returnType.match(/^(\w+)/); if (!match) return undefined; const typeName = match[1]!; // Find the interface/type in the same file let interfaceDecl: ts.InterfaceDeclaration | undefined; function visit(node: ts.Node) { if (ts.isInterfaceDeclaration(node) && node.name.text === typeName) { interfaceDecl = node; } ts.forEachChild(node, visit); } visit(sourceFile); if (!interfaceDecl) return undefined; const fields: Record = {}; for (const member of interfaceDecl.members) { if (!ts.isPropertySignature(member) || !ts.isIdentifier(member.name)) continue; const name = member.name.text; const typeStr = member.type ? member.type.getText(sourceFile) : 'unknown'; const abbreviated = abbreviateType(name, typeStr); const description = getNodeJSDoc(member); const field: { type: string; detailedType?: string; description?: string } = { type: abbreviated ?? typeStr }; if (abbreviated && typeStr !== abbreviated) field.detailedType = typeStr; if (description) field.description = description; fields[name] = field; } return Object.keys(fields).length > 0 ? fields : undefined; } // ─── Slug Resolution ─────────────────────────────────────────────── function resolveSlugCollision(slug: string, framework: EntryPoint['framework'], seenSlugs: Set): string { if (seenSlugs.has(slug)) { if (!framework) { log.error(`Framework-agnostic slug collision: ${slug}`); } if (framework === 'react') { log.error(`Unexpected: React slug "${slug}" collided — check UTIL_ENTRY_POINTS order`); } slug = `${framework}-${slug}`; } seenSlugs.add(slug); return slug; } // ─── Discovery Pipeline ──────────────────────────────────────────── function processExport( exportNode: tae.ExportNode, modulePath: string, entryPoint: EntryPoint, program: ts.Program, seenKeys: Set, seenSlugs: Set, entries: UtilEntry[], allExports?: tae.ExportNode[] ): void { const key = `${entryPoint.framework}:${exportNode.name}`; if (seenKeys.has(key)) return; if (!isUtilExport(exportNode)) return; const displayName = getDisplayName(exportNode.name); const slug = resolveSlugCollision(kebabCase(displayName), entryPoint.framework, seenSlugs); let overloads: UtilOverload[]; if (exportNode.name.endsWith('Controller') && !(exportNode.type instanceof tae.FunctionNode)) { // Controllers use raw TS AST because TAE represents them as ObjectNode overloads = extractControllerOverloads(modulePath, program, exportNode.name); } else if (!(exportNode.type instanceof tae.FunctionNode)) { overloads = [extractContextOverload(exportNode)]; } else { overloads = extractFunctionOverloads(exportNode, modulePath, program, allExports); } if (overloads.length === 0) { log.warn(`No overloads extracted for ${exportNode.name}, skipping`); return; } const description = exportNode.documentation?.description; const data: UtilReference = { name: displayName, overloads, }; if (description) data.description = description; entries.push({ slug, data, framework: entryPoint.framework, }); seenKeys.add(key); } function processRawExport( info: RawExportInfo, entryPoint: EntryPoint, program: ts.Program, seenKeys: Set, seenSlugs: Set, entries: UtilEntry[] ): void { const key = `${entryPoint.framework}:${info.name}`; if (seenKeys.has(key)) return; if (!isRawUtilExport(info)) return; const displayName = getDisplayName(info.name); const slug = resolveSlugCollision(kebabCase(displayName), entryPoint.framework, seenSlugs); let overloads: UtilOverload[]; if (info.isClass) { overloads = extractControllerOverloads(info.sourceFile, program, info.name); } else if (!info.isFunction && !info.isClass) { overloads = [{ parameters: {}, returnValue: { type: 'unknown' } }]; } else { overloads = extractFunctionOverloadsFromAST(info.sourceFile, program, info.name); } if (overloads.length === 0) { log.warn(`No overloads extracted for ${info.name} (AST fallback), skipping`); return; } const data: UtilReference = { name: displayName, overloads, }; if (info.description) data.description = info.description; entries.push({ slug, data, framework: entryPoint.framework, }); seenKeys.add(key); } function discoverUtilExports(monorepoRoot: string, program: ts.Program): UtilEntry[] { const entries: UtilEntry[] = []; const seenKeys = new Set(); const seenSlugs = new Set(); for (const entryPoint of UTIL_ENTRY_POINTS) { const indexPath = path.join(monorepoRoot, entryPoint.index); if (!fs.existsSync(indexPath)) { log.warn(`Entry point not found: ${indexPath}`); continue; } const localModules = resolveLocalModules(indexPath); // When the entry point is a leaf module (no re-exports), scan it directly const modulesToScan = localModules.length > 0 ? localModules : [indexPath]; const failedModules: string[] = []; // Collect all TAE exports for type resolution (formatDetailedType) const allExports: tae.ExportNode[] = []; // Strategy 1: TAE on local modules — primary path for hooks, factories, mixins, // utilities, contexts, and selectors (e.g., usePlayer, createPlayer, selectPlayback) for (const modulePath of modulesToScan) { if (!fs.existsSync(modulePath)) continue; let ast: tae.Module; try { ast = tae.parseFromProgram(modulePath, program); } catch { failedModules.push(modulePath); continue; } allExports.push(...ast.exports); for (const exportNode of ast.exports) { processExport(exportNode, modulePath, entryPoint, program, seenKeys, seenSlugs, entries, allExports); } } // Strategy 2: TAE on index file — finds controllers re-exported from the entry // (e.g., PlayerController re-exported from packages/html/src/index.ts) try { const indexAst = tae.parseFromProgram(indexPath, program); allExports.push(...indexAst.exports); for (const exportNode of indexAst.exports) { // For controllers from the index, find the source module file for extraction if (exportNode.name.endsWith('Controller') && !(exportNode.type instanceof tae.FunctionNode)) { const sourceModule = findClassSourceModule(exportNode.name, localModules, program); if (sourceModule) { processExport(exportNode, sourceModule, entryPoint, program, seenKeys, seenSlugs, entries, allExports); } } } } catch { // Index parsing failed (e.g., HTML index with UniqueESSymbol) } // Strategy 3: Raw TS AST fallback — when TAE fails on a module (e.g., UniqueESSymbol // in HTML bundle), walks the raw TypeScript AST for exports for (const modulePath of failedModules) { const rawExports = discoverExportsFromRawAST(modulePath, program); for (const info of rawExports) { processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries); } } // Strategy 4: Raw TS AST for missed classes — catches exported classes that TAE // parsed but skipped (e.g., SnapshotController) for (const modulePath of localModules) { if (!fs.existsSync(modulePath)) continue; const rawExports = discoverExportsFromRawAST(modulePath, program); for (const info of rawExports) { if (!info.isClass) continue; processRawExport(info, entryPoint, program, seenKeys, seenSlugs, entries); } } } return entries; } function findClassSourceModule(className: string, localModules: string[], program: ts.Program): string | undefined { for (const modulePath of localModules) { const sourceFile = program.getSourceFile(modulePath); if (!sourceFile) continue; let found = false; function visit(node: ts.Node) { if (ts.isClassDeclaration(node) && node.name?.text === className) { found = true; } if (!found) ts.forEachChild(node, visit); } visit(sourceFile); if (found) return modulePath; } return undefined; } // ─── Program Creation ────────────────────────────────────────────── function createUtilProgram(monorepoRoot: string): ts.Program { const files: string[] = []; for (const entryPoint of UTIL_ENTRY_POINTS) { const indexPath = path.join(monorepoRoot, entryPoint.index); if (!fs.existsSync(indexPath)) continue; files.push(indexPath); const localModules = resolveLocalModules(indexPath); for (const mod of localModules) { if (fs.existsSync(mod) && !files.includes(mod)) { files.push(mod); } } } const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json'); const config = tae.loadConfig(tsconfigPath); config.options.rootDir = monorepoRoot; return ts.createProgram(files, config.options); } // ─── Public API ──────────────────────────────────────────────────── export function getUtilEntries(monorepoRoot: string): UtilEntry[] { const program = createUtilProgram(monorepoRoot); return discoverUtilExports(monorepoRoot, program); } export function generateUtilReferences(outputPath: string, monorepoRoot: string): { success: number; errors: number } { if (!fs.existsSync(outputPath)) { fs.mkdirSync(outputPath, { recursive: true }); } const entries = getUtilEntries(monorepoRoot); let success = 0; let errors = 0; log.info(`Found ${entries.length} util APIs. Processing...`); for (const entry of entries) { const dataToValidate: Record = { ...entry.data }; if (entry.framework !== null) { dataToValidate.frameworks = [entry.framework]; } const validated = UtilReferenceSchema.safeParse(dataToValidate); if (!validated.success) { log.error(`Schema validation failed for ${entry.data.name} (${entry.slug}):`); for (const issue of validated.error.issues) { log.error(` - ${issue.path.join('.')}: ${issue.message}`); } errors++; continue; } const outputFile = path.join(outputPath, `${entry.slug}.json`); const json = `${JSON.stringify(validated.data, null, 2)}\n`; fs.writeFileSync(outputFile, json); log.success(`\u2705 Generated ${path.basename(outputFile)} (${entry.framework ?? 'all'})`); success++; } return { success, errors }; }