mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
1099 lines
37 KiB
TypeScript
1099 lines
37 KiB
TypeScript
/**
|
|
* 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<string, ParamDef> = {};
|
|
|
|
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<string, { type: string; detailedType?: string; description?: string }> = {};
|
|
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<string, ParamDef> = {};
|
|
|
|
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<string, { type: string; detailedType?: string; description?: string }> {
|
|
const fields: Record<string, { type: string; detailedType?: string; description?: string }> = {};
|
|
|
|
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<string, ParamDef> = {};
|
|
|
|
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<string, { type: string; detailedType?: string; description?: string }> | 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<string, { type: string; detailedType?: string; description?: string }> = {};
|
|
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>): 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<string>,
|
|
seenSlugs: Set<string>,
|
|
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<string>,
|
|
seenSlugs: Set<string>,
|
|
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<string>();
|
|
const seenSlugs = new Set<string>();
|
|
|
|
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<string, unknown> = { ...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 };
|
|
}
|