mirror of
https://github.com/arcodange-org/mediabunny.git
synced 2026-09-27 02:43:48 +02:00
2045 lines
78 KiB
TypeScript
2045 lines
78 KiB
TypeScript
// This script has been 100% vibe-coded with Claude (and Gemini!), meaning I literally haven't looked at any of the
|
|
// code. It's probably a mess, but it solves a one-off problem where only the output matters, and the output is indeed
|
|
// good, which is the point of a custom script for this: full, precise control.
|
|
|
|
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
/* eslint-disable @stylistic/max-len */
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
|
/* eslint-disable @stylistic/brace-style */
|
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
|
|
import * as ts from 'typescript';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
|
|
const generateDocs = (entryFiles: string[], apiConfigFile: string, dry = false) => {
|
|
const program = ts.createProgram(entryFiles, {
|
|
target: ts.ScriptTarget.ES2020,
|
|
module: ts.ModuleKind.ESNext,
|
|
moduleResolution: ts.ModuleResolutionKind.Node10,
|
|
allowJs: false,
|
|
declaration: true,
|
|
esModuleInterop: true,
|
|
skipLibCheck: true,
|
|
strict: true,
|
|
});
|
|
|
|
const sourceFiles = entryFiles.map((entryFile) => {
|
|
const sourceFile = program.getSourceFile(entryFile);
|
|
if (!sourceFile) {
|
|
throw new Error(`Could not find source file: ${entryFile}`);
|
|
}
|
|
return sourceFile;
|
|
});
|
|
|
|
const typeChecker = program.getTypeChecker();
|
|
const outputDir = path.resolve(process.cwd(), 'docs/api');
|
|
|
|
// Load API config
|
|
const apiConfigPath = path.resolve(process.cwd(), apiConfigFile);
|
|
if (!fs.existsSync(apiConfigPath)) {
|
|
throw new Error(`API config file not found: ${apiConfigPath}`);
|
|
}
|
|
const apiConfig: Record<string, string> = JSON.parse(fs.readFileSync(apiConfigPath, 'utf-8'));
|
|
|
|
// Extract special fields
|
|
const headingText = apiConfig['heading'] || 'API Reference';
|
|
const introText = apiConfig['intro'];
|
|
const indexDescription = apiConfig['description'];
|
|
|
|
// Create a copy without the special fields for group processing
|
|
const groupConfig = { ...apiConfig };
|
|
delete groupConfig['heading'];
|
|
delete groupConfig['intro'];
|
|
delete groupConfig['description'];
|
|
|
|
// Clear and recreate output directory (skip if dry run)
|
|
if (!dry) {
|
|
if (fs.existsSync(outputDir)) {
|
|
fs.rmSync(outputDir, { recursive: true });
|
|
}
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
// Collect all exported types for cross-referencing
|
|
const exportedTypes = new Set<string>();
|
|
const classHierarchy = new Map<string, string[]>(); // Maps parent class to array of subclasses
|
|
const classInstances = new Map<string, string[]>(); // Maps class name to array of instance variable names
|
|
|
|
const hasDeprecatedTag = (node: ts.Node): boolean => {
|
|
return ts.getJSDocTags(node).some(tag => tag.tagName.text === 'deprecated');
|
|
};
|
|
|
|
const collectExportedTypes = (module: ts.Symbol, visited = new Set<ts.Symbol>()): void => {
|
|
if (visited.has(module)) return;
|
|
visited.add(module);
|
|
|
|
const exports = typeChecker.getExportsOfModule(module);
|
|
exports.forEach((exportSymbol) => {
|
|
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
|
if (!declaration) return;
|
|
|
|
// Collect classes, interfaces, types, enums, variables (only if @public)
|
|
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)
|
|
|| ts.isTypeAliasDeclaration(declaration) || ts.isEnumDeclaration(declaration)
|
|
|| ts.isVariableDeclaration(declaration)) {
|
|
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
|
if (hasPublicTag) {
|
|
exportedTypes.add(exportSymbol.getName());
|
|
}
|
|
}
|
|
|
|
// Follow reexports
|
|
else if (exportSymbol.flags & ts.SymbolFlags.Alias) {
|
|
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
|
|
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
|
|
if (aliasedDeclaration) {
|
|
// Check if the aliased symbol has @public tag
|
|
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
|
|
if (hasPublicTag) {
|
|
exportedTypes.add(exportSymbol.getName());
|
|
}
|
|
|
|
// Also recursively collect from the source module
|
|
const sourceFile = aliasedDeclaration.getSourceFile();
|
|
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
|
|
if (moduleSymbol) {
|
|
collectExportedTypes(moduleSymbol, visited);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Get all exported symbols recursively
|
|
const getAllExportedSymbols = (module: ts.Symbol, visited = new Set<ts.Symbol>()): ts.Symbol[] => {
|
|
if (visited.has(module)) return [];
|
|
visited.add(module);
|
|
|
|
const exports = typeChecker.getExportsOfModule(module);
|
|
const symbols: ts.Symbol[] = [];
|
|
|
|
exports.forEach((exportSymbol) => {
|
|
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
|
if (!declaration) return;
|
|
|
|
// If it's a reexport, follow it recursively
|
|
if (exportSymbol.flags & ts.SymbolFlags.Alias) {
|
|
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
|
|
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
|
|
if (aliasedDeclaration) {
|
|
const sourceFile = aliasedDeclaration.getSourceFile();
|
|
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
|
|
// If the aliased declaration lives in a module we've already visited (e.g. a
|
|
// same-file `export type { Foo }` re-export of a local declaration), recursing
|
|
// won't reach it, so add the alias symbol directly. Otherwise follow the reexport.
|
|
if (moduleSymbol && !visited.has(moduleSymbol)) {
|
|
symbols.push(...getAllExportedSymbols(moduleSymbol, visited));
|
|
} else {
|
|
// Push the aliased symbol (not the alias) so downstream sees the real
|
|
// declaration and its JSDoc rather than the empty ExportSpecifier.
|
|
const hasPublicTag = ts.getJSDocTags(aliasedDeclaration).some(tag => tag.tagName.text === 'public');
|
|
if (hasPublicTag) {
|
|
symbols.push(aliasedSymbol);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Otherwise, add any symbol with @public tag (we'll filter by type later)
|
|
else {
|
|
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
|
if (hasPublicTag) {
|
|
symbols.push(exportSymbol);
|
|
}
|
|
}
|
|
});
|
|
|
|
return symbols;
|
|
};
|
|
|
|
// Collect all exported types from all source files
|
|
const allModuleSymbols: ts.Symbol[] = [];
|
|
sourceFiles.forEach((sourceFile) => {
|
|
const moduleSymbol = typeChecker.getSymbolAtLocation(sourceFile);
|
|
if (moduleSymbol) {
|
|
allModuleSymbols.push(moduleSymbol);
|
|
collectExportedTypes(moduleSymbol);
|
|
}
|
|
});
|
|
|
|
// Helper to find all potential type references in a type string
|
|
const findAllTypeReferences = (typeString: string): string[] => {
|
|
const matches: string[] = [];
|
|
let i = 0;
|
|
|
|
while (i < typeString.length) {
|
|
const char = typeString[i]!;
|
|
|
|
// Skip string literals (both single and double quotes)
|
|
if (char === '"' || char === '\'') {
|
|
const quote = char;
|
|
i++; // Skip opening quote
|
|
|
|
// Find closing quote, handling escaped quotes
|
|
while (i < typeString.length) {
|
|
if (typeString[i] === '\\') {
|
|
i += 2; // Skip escaped character
|
|
} else if (typeString[i] === quote) {
|
|
i++; // Skip closing quote
|
|
break;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip template literals
|
|
if (char === '`') {
|
|
i++; // Skip opening backtick
|
|
while (i < typeString.length && typeString[i] !== '`') {
|
|
if (typeString[i] === '\\') {
|
|
i += 2; // Skip escaped character
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
if (i < typeString.length) i++; // Skip closing backtick
|
|
continue;
|
|
}
|
|
|
|
// Check for PascalCase identifier at current position
|
|
if (/[A-Z]/.test(char)) {
|
|
let match = '';
|
|
let j = i;
|
|
|
|
// Collect the full identifier
|
|
while (j < typeString.length && /[a-zA-Z0-9_]/.test(typeString[j]!)) {
|
|
match += typeString[j];
|
|
j++;
|
|
}
|
|
|
|
// Ensure it's a word boundary (not part of a larger word)
|
|
const prevChar = i > 0 ? typeString[i - 1]! : ' ';
|
|
const nextChar = j < typeString.length ? typeString[j]! : ' ';
|
|
|
|
if (!/[a-zA-Z0-9_]/.test(prevChar) && !/[a-zA-Z0-9_]/.test(nextChar)) {
|
|
matches.push(match);
|
|
}
|
|
|
|
i = j;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
|
|
return [...new Set(matches)]; // Remove duplicates
|
|
};
|
|
|
|
// Helper to filter references to only exported types (excluding current type)
|
|
const filterToExportedTypes = (references: string[], currentTypeName?: string): string[] => {
|
|
return references.filter(ref => exportedTypes.has(ref) && ref !== currentTypeName);
|
|
};
|
|
|
|
// Helper to split union types while respecting bracket depth and string literals
|
|
const splitUnionType = (typeString: string): string[] => {
|
|
const parts: string[] = [];
|
|
let current = '';
|
|
let depth = 0;
|
|
let i = 0;
|
|
|
|
while (i < typeString.length) {
|
|
const char = typeString[i];
|
|
|
|
// Skip string literals (both single and double quotes)
|
|
if (char === '"' || char === '\'') {
|
|
const quote = char;
|
|
current += char;
|
|
i++; // Skip opening quote
|
|
|
|
// Find closing quote, handling escaped quotes
|
|
while (i < typeString.length) {
|
|
current += typeString[i];
|
|
if (typeString[i] === '\\') {
|
|
i++; // Skip escaped character
|
|
if (i < typeString.length) {
|
|
current += typeString[i];
|
|
i++;
|
|
}
|
|
} else if (typeString[i] === quote) {
|
|
i++; // Skip closing quote
|
|
break;
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip template literals
|
|
if (char === '`') {
|
|
current += char;
|
|
i++; // Skip opening backtick
|
|
while (i < typeString.length && typeString[i] !== '`') {
|
|
current += typeString[i];
|
|
if (typeString[i] === '\\') {
|
|
i++; // Skip escaped character
|
|
if (i < typeString.length) {
|
|
current += typeString[i];
|
|
}
|
|
}
|
|
i++;
|
|
}
|
|
if (i < typeString.length) {
|
|
current += typeString[i]; // Add closing backtick
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Track bracket depth
|
|
if (char === '(' || char === '{' || char === '[' || char === '<') {
|
|
depth++;
|
|
current += char;
|
|
} else if (char === ')' || char === '}' || char === ']' || char === '>') {
|
|
depth--;
|
|
current += char;
|
|
} else if (char === '|' && depth === 0) {
|
|
// Found a top-level union separator
|
|
// Check if it's part of ' | ' pattern
|
|
if (i > 0 && typeString[i - 1] === ' ' && i < typeString.length - 1 && typeString[i + 1] === ' ') {
|
|
// This is a union separator
|
|
parts.push(current.trim());
|
|
current = '';
|
|
i += 2; // Skip ' | '
|
|
continue;
|
|
} else {
|
|
// Just a pipe character, not a union separator
|
|
current += char;
|
|
}
|
|
} else {
|
|
current += char;
|
|
}
|
|
i++;
|
|
}
|
|
|
|
// Add the final part
|
|
if (current.trim()) {
|
|
parts.push(current.trim());
|
|
}
|
|
|
|
return parts.length > 1 ? parts : [typeString];
|
|
};
|
|
|
|
// Helper to process {@link} tags in JSDoc comments
|
|
const processLinkTags = (text: string, currentTypeName?: string): string => {
|
|
// Updated regex to handle member links and optional link text, e.g., {@link Type.member | text}
|
|
return text.replace(/\{@link\s+([^}|]+)(?:\s*\|\s*([^}]+))?\}/g, (_, target, linkText) => {
|
|
const cleanTarget = target.trim();
|
|
|
|
// Split into type and member parts
|
|
const parts = cleanTarget.split('.');
|
|
const typeName = parts[0];
|
|
const memberName = parts.length > 1 ? parts[1] : undefined;
|
|
|
|
let displayText: string;
|
|
if (linkText) {
|
|
// If custom link text is provided, always use it.
|
|
displayText = linkText.trim();
|
|
} else if (memberName && typeName === currentTypeName) {
|
|
// Member link on the current type: just the member name.
|
|
displayText = `\`${memberName}\``;
|
|
} else {
|
|
// Type link, or member link on another type: use the full target.
|
|
displayText = `\`${cleanTarget}\``;
|
|
}
|
|
|
|
// Check if the base type is a known exported type
|
|
if (exportedTypes.has(typeName)) {
|
|
let linkUrl = '';
|
|
|
|
if (memberName) {
|
|
// It's a link to a member (property or method)
|
|
const anchor = memberName.toLowerCase();
|
|
|
|
if (typeName === currentTypeName) {
|
|
// Link to an anchor on the same page
|
|
linkUrl = `#${anchor}`;
|
|
} else {
|
|
// Link to another page's anchor
|
|
linkUrl = `./${typeName}.md#${anchor}`;
|
|
}
|
|
} else {
|
|
// It's a link to a type
|
|
if (typeName === currentTypeName) {
|
|
// Don't link to the current page, just format it
|
|
return `\`${cleanTarget}\``;
|
|
}
|
|
linkUrl = `./${typeName}.md`;
|
|
}
|
|
return `[${displayText}](${linkUrl})`;
|
|
}
|
|
|
|
// Fallback for unknown types: just format as code
|
|
return `\`${cleanTarget}\``;
|
|
});
|
|
};
|
|
|
|
// Helper to build a "> **Deprecated.** ..." notice from a node's @deprecated JSDoc tag.
|
|
// Returns an empty string if the node has no @deprecated tag.
|
|
const getDeprecationNotice = (node: ts.Node, currentTypeName?: string): string => {
|
|
const tag = ts.getJSDocTags(node).find(t => t.tagName.text === 'deprecated');
|
|
if (!tag) {
|
|
return '';
|
|
}
|
|
let text = '';
|
|
if (tag.comment) {
|
|
if (typeof tag.comment === 'string') {
|
|
text = processLinkTags(tag.comment.trim(), currentTypeName);
|
|
} else {
|
|
// comment is a NodeArray of JSDocComment elements (text + inline tags)
|
|
const raw = tag.comment.map((part) => {
|
|
if (ts.isJSDocLinkLike(part)) {
|
|
const linkName = part.name?.getText() ?? '';
|
|
const linkText = part.text?.trim() ?? '';
|
|
// Reconstruct as {@link Name text}
|
|
return `{@link ${linkName}${linkText ? ' ' + linkText : ''}}`;
|
|
}
|
|
return part.text ?? '';
|
|
}).join('');
|
|
text = processLinkTags(raw.trim(), currentTypeName);
|
|
}
|
|
}
|
|
return text ? `> **Deprecated.** ${text}\n\n` : '> **Deprecated.**\n\n';
|
|
};
|
|
|
|
// Helper to extract linked types from {@link} tags in text
|
|
const extractLinkedTypes = (text: string): string[] => {
|
|
if (!text) return [];
|
|
const linkedTypes: string[] = [];
|
|
// Use a regex to find all link targets
|
|
const regex = /\{@link\s+([^}|]+)/g;
|
|
let match;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
const target = match[1]!.trim();
|
|
// Return only the base type name (the part before the first dot)
|
|
const typeName = target.split('.')[0];
|
|
linkedTypes.push(typeName!);
|
|
}
|
|
return linkedTypes;
|
|
};
|
|
|
|
// Helper to format references with proper "and" and period
|
|
// Optionally filters out references that were already mentioned in @link tags
|
|
const formatReferences = (references: string[], linkedTypes: string[] = []): string => {
|
|
if (references.length === 0) return '';
|
|
|
|
// Filter out references that were already linked in the description
|
|
const filteredReferences = references.filter(ref => !linkedTypes.includes(ref));
|
|
|
|
if (filteredReferences.length === 0) return '';
|
|
|
|
const refLinks = filteredReferences.map(ref => `[\`${ref}\`](./${ref}.md)`);
|
|
const formatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' });
|
|
return `\n\nSee ${formatter.format(refLinks)}.`;
|
|
};
|
|
|
|
// Helper to format object types with proper indentation
|
|
const formatObjectType = (typeText: string): string => {
|
|
// Remove JSDoc comments but preserve original structure
|
|
let lines = typeText.split('\n');
|
|
const result: string[] = [];
|
|
|
|
// First remove all JSDoc comments from the entire text
|
|
const cleanText = typeText.replace(/\/\*\*[\s\S]*?\*\//g, '');
|
|
lines = cleanText.split('\n');
|
|
|
|
for (const line of lines) {
|
|
// Skip empty lines
|
|
if (line.trim() === '') continue;
|
|
|
|
result.push(line);
|
|
}
|
|
|
|
return result.join('\n');
|
|
};
|
|
|
|
// Helper to get better type string representation
|
|
const getTypeString = (type: ts.Type): string => {
|
|
// Handle array types specially
|
|
if (typeChecker.isArrayType(type)) {
|
|
const elementType = typeChecker.getTypeArguments(type as ts.TypeReference)[0];
|
|
if (elementType) {
|
|
return `${getTypeString(elementType)}[]`;
|
|
}
|
|
}
|
|
|
|
// Check if it's an array-like type by checking the symbol name
|
|
const typeString = typeChecker.typeToString(type);
|
|
if (typeString === 'Array' && type.symbol && type.symbol.getName() === 'Array') {
|
|
// Try to get type arguments from the type reference
|
|
if ((type as any).typeArguments && (type as any).typeArguments.length > 0) {
|
|
const elementType = (type as any).typeArguments[0];
|
|
return `${getTypeString(elementType)}[]`;
|
|
}
|
|
// If we can't determine the element type, try looking at the declaration
|
|
if (type.symbol.declarations && type.symbol.declarations[0]) {
|
|
const declaration = type.symbol.declarations[0];
|
|
if (
|
|
ts.isTypeReferenceNode(declaration)
|
|
&& declaration.typeArguments
|
|
&& declaration.typeArguments.length > 0
|
|
) {
|
|
return `${declaration.typeArguments[0]!.getText()}[]`;
|
|
}
|
|
}
|
|
}
|
|
|
|
return typeString;
|
|
};
|
|
|
|
// Helper to clean up optional parameter types
|
|
const cleanOptionalType = (type: string, isOptional: boolean) => {
|
|
let cleanedType = type;
|
|
if (isOptional) {
|
|
// Remove "| undefined" from union types for optional parameters
|
|
cleanedType = cleanedType.replace(/\s*\|\s*undefined$/, '').replace(/^undefined\s*\|\s*/, '');
|
|
}
|
|
// Convert string literals from double quotes to single quotes
|
|
cleanedType = cleanedType.replace(/"([^"]*)"/g, '\'$1\'');
|
|
|
|
// Format long union types with line breaks if they exceed 80 characters
|
|
// Only apply to top-level unions, not unions nested within intersections, object types, or generic types
|
|
if (cleanedType.includes(' | ') && cleanedType.length > 80 && !cleanedType.includes('&') && !cleanedType.includes('{') && !cleanedType.includes('<')) {
|
|
const unionMembers = splitUnionType(cleanedType);
|
|
if (unionMembers.length > 1) {
|
|
cleanedType = '\n\t| ' + unionMembers.join('\n\t| ');
|
|
}
|
|
}
|
|
|
|
return cleanedType;
|
|
};
|
|
|
|
// Get all exported symbols from all modules
|
|
const allSymbols: ts.Symbol[] = [];
|
|
allModuleSymbols.forEach((moduleSymbol) => {
|
|
allSymbols.push(...getAllExportedSymbols(moduleSymbol));
|
|
});
|
|
const indexEntries: Array<{ name: string; type: string; group: string; order: number }> = [];
|
|
|
|
// Create a map to track the order of symbols based on their appearance in the entry file
|
|
const symbolOrderMap = new Map<string, number>();
|
|
let orderIndex = 0;
|
|
|
|
// Walk through all source files to establish order based on declaration/export order
|
|
const establishSymbolOrder = (node: ts.Node): void => {
|
|
if (ts.isExportDeclaration(node)) {
|
|
// Handle export declarations like "export { Foo } from './foo'"
|
|
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
node.exportClause.elements.forEach((element) => {
|
|
const exportName = (element.propertyName || element.name).getText();
|
|
if (!symbolOrderMap.has(exportName)) {
|
|
symbolOrderMap.set(exportName, orderIndex++);
|
|
}
|
|
});
|
|
}
|
|
} else if (ts.isVariableStatement(node)) {
|
|
// Handle variable statements like "export const foo = ..."
|
|
if (node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
node.declarationList.declarations.forEach((declaration) => {
|
|
const name = declaration.name.getText();
|
|
if (name && !symbolOrderMap.has(name)) {
|
|
symbolOrderMap.set(name, orderIndex++);
|
|
}
|
|
});
|
|
}
|
|
} else if (ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isFunctionDeclaration(node)) {
|
|
// Handle direct declarations
|
|
if (node.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
const name = (node as any).name?.getText();
|
|
if (name && !symbolOrderMap.has(name)) {
|
|
symbolOrderMap.set(name, orderIndex++);
|
|
}
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, establishSymbolOrder);
|
|
};
|
|
|
|
// Establish symbol order from all source files
|
|
sourceFiles.forEach((sourceFile) => {
|
|
establishSymbolOrder(sourceFile);
|
|
});
|
|
|
|
// Phase 1: Collect class hierarchy information
|
|
allSymbols.forEach((exportSymbol) => {
|
|
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
|
if (!declaration) return;
|
|
|
|
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
|
if (!hasPublicTag) return;
|
|
|
|
// Collect inheritance information for classes
|
|
if (ts.isClassDeclaration(declaration) && declaration.heritageClauses) {
|
|
const symbolName = declaration.name?.getText();
|
|
if (!symbolName) return;
|
|
|
|
const extendsClauseNode = declaration.heritageClauses.find(
|
|
clause => clause.token === ts.SyntaxKind.ExtendsKeyword,
|
|
);
|
|
if (extendsClauseNode && extendsClauseNode.types[0]) {
|
|
const superClassName = extendsClauseNode.types[0].expression.getText();
|
|
if (!classHierarchy.has(superClassName)) {
|
|
classHierarchy.set(superClassName, []);
|
|
}
|
|
classHierarchy.get(superClassName)!.push(symbolName);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Collect class instances
|
|
allSymbols.forEach((exportSymbol) => {
|
|
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
|
if (!declaration) return;
|
|
|
|
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
|
if (!hasPublicTag) return;
|
|
|
|
if (ts.isVariableDeclaration(declaration)) {
|
|
const variableName = declaration.name.getText();
|
|
let className: string | undefined;
|
|
// Check explicit type annotation
|
|
if (declaration.type && ts.isTypeReferenceNode(declaration.type)) {
|
|
className = declaration.type.typeName.getText();
|
|
}
|
|
// Check initializer for constructor calls
|
|
else if (declaration.initializer && ts.isNewExpression(declaration.initializer)) {
|
|
className = declaration.initializer.expression.getText();
|
|
}
|
|
if (className && exportedTypes.has(className)) {
|
|
if (!classInstances.has(className)) {
|
|
classInstances.set(className, []);
|
|
}
|
|
classInstances.get(className)!.push(variableName);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Data structures for "Used by" feature
|
|
const usedByReferences = new Map<string, Set<{ user: string; context: string; type: 'constructor' | 'method' | 'property' | 'extends' | 'type_param' | 'type_alias' | 'variable' | 'function' }>>();
|
|
const generatedDocs = new Map<string, string>();
|
|
|
|
const addUsage = (
|
|
used: string,
|
|
user: string,
|
|
context: string,
|
|
type: 'constructor' | 'method' | 'property' | 'extends' | 'type_param' | 'type_alias' | 'variable' | 'function',
|
|
) => {
|
|
// No self-references
|
|
if (used === user) return;
|
|
|
|
if (!usedByReferences.has(used)) {
|
|
usedByReferences.set(used, new Set());
|
|
}
|
|
const usageSet = usedByReferences.get(used)!;
|
|
|
|
// Check for duplicates before adding
|
|
for (const item of usageSet) {
|
|
if (item.user === user && item.context === context && item.type === type) {
|
|
return;
|
|
}
|
|
}
|
|
usageSet.add({ user, context, type });
|
|
};
|
|
|
|
// Phase 2: Generate documentation for each symbol (and collect usage data)
|
|
allSymbols.forEach((exportSymbol) => {
|
|
const declaration = exportSymbol.valueDeclaration || exportSymbol.declarations?.[0];
|
|
if (!declaration) return;
|
|
|
|
// Check if it's a supported symbol type
|
|
const nodeKind = ts.SyntaxKind[declaration.kind];
|
|
const symbolName = (declaration as any).name?.getText() || exportSymbol.getName();
|
|
|
|
// Only process symbols with @public tag
|
|
const hasPublicTag = ts.getJSDocTags(declaration).some(tag => tag.tagName.text === 'public');
|
|
if (!hasPublicTag) {
|
|
return;
|
|
}
|
|
|
|
// Check for @group tag (handle re-exports by looking at the original declaration)
|
|
let targetDeclaration = declaration;
|
|
if (exportSymbol.flags & ts.SymbolFlags.Alias) {
|
|
const aliasedSymbol = typeChecker.getAliasedSymbol(exportSymbol);
|
|
const aliasedDeclaration = aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0];
|
|
if (aliasedDeclaration) {
|
|
targetDeclaration = aliasedDeclaration;
|
|
}
|
|
}
|
|
|
|
const groupTag = ts.getJSDocTags(targetDeclaration).find(tag => tag.tagName.text === 'group');
|
|
if (!groupTag || typeof groupTag.comment !== 'string') {
|
|
throw new Error(`Symbol '${symbolName}' is missing @group JSDoc tag`);
|
|
}
|
|
const groupName = groupTag.comment.trim().replace(/\\(.)/g, '$1');
|
|
|
|
// Validate that the group exists in the API config
|
|
if (!Object.prototype.hasOwnProperty.call(groupConfig, groupName)) {
|
|
throw new Error(`Symbol '${symbolName}' has @group '${groupName}' which is not defined in API config`);
|
|
}
|
|
|
|
// Check if it's a supported type
|
|
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration) || ts.isTypeAliasDeclaration(declaration) || ts.isVariableDeclaration(declaration) || ts.isEnumDeclaration(declaration)) {
|
|
// Supported types - continue processing
|
|
} else {
|
|
// Unsupported type - throw error with type info
|
|
throw new Error(`Unsupported symbol type: ${nodeKind} for symbol '${symbolName}'`);
|
|
}
|
|
|
|
if (!declaration.name) return;
|
|
|
|
// Handle variable declarations separately
|
|
if (ts.isVariableDeclaration(declaration)) {
|
|
const variableName = declaration.name.getText();
|
|
|
|
// Get variable description from JSDoc
|
|
const description = extractJsDocDescription(declaration, {
|
|
tagHandling: 'filterAll',
|
|
transform: text => processLinkTags(text, variableName),
|
|
});
|
|
|
|
// Check if it's a function type
|
|
const variableType = typeChecker.getTypeAtLocation(declaration);
|
|
const isFunctionType = variableType.getCallSignatures().length > 0;
|
|
|
|
// Add to index
|
|
const order = symbolOrderMap.get(variableName);
|
|
if (order === undefined) {
|
|
throw new Error(`Symbol '${variableName}' not found in entry files export order`);
|
|
}
|
|
indexEntries.push({ name: variableName, type: isFunctionType ? 'Function' : 'Constant', group: groupName, order });
|
|
|
|
if (isFunctionType) {
|
|
// Handle function variables like methods
|
|
const signature = variableType.getCallSignatures()[0];
|
|
if (signature) {
|
|
const parameters = signature.getParameters();
|
|
const params = parameters.map((param) => {
|
|
const paramDecl = param.valueDeclaration;
|
|
if (paramDecl && ts.isParameter(paramDecl)) {
|
|
const paramName = param.getName();
|
|
const hasQuestionToken = paramDecl.questionToken !== undefined;
|
|
const hasDefault = paramDecl.initializer !== undefined;
|
|
const isRest = paramDecl.dotDotDotToken !== undefined;
|
|
const rawParamType = paramDecl.type ? paramDecl.type.getText() : typeChecker.typeToString(typeChecker.getTypeOfSymbolAtLocation(param, paramDecl));
|
|
const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
|
|
|
|
if (hasDefault) {
|
|
const defaultValue = paramDecl.initializer.getText();
|
|
return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`;
|
|
} else {
|
|
return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`;
|
|
}
|
|
}
|
|
return `\t${param.getName()}: unknown`;
|
|
});
|
|
|
|
const returnType = getTypeString(signature.getReturnType());
|
|
const functionSig = params.length > 0
|
|
? `${variableName}(\n${params.join(',\n')},\n): ${returnType};`
|
|
: `${variableName}(): ${returnType};`;
|
|
|
|
const deprecationNotice = getDeprecationNotice(declaration, variableName);
|
|
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Function" />\n\n# ${variableName}\n\n${deprecationNotice}\`\`\`ts\n${functionSig}\n\`\`\`${description ? `\n\n${description}` : ''}`;
|
|
|
|
// Find referenced types in all parameters and return type
|
|
const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
|
|
const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], variableName);
|
|
markdown += formatReferences(allReferences);
|
|
|
|
// In tandem: update usage map
|
|
allReferences.forEach(ref => addUsage(ref, variableName, variableName, 'function'));
|
|
|
|
generatedDocs.set(variableName, markdown);
|
|
}
|
|
} else {
|
|
// Handle regular variables
|
|
const deprecationNotice = getDeprecationNotice(declaration, variableName);
|
|
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Constant" />\n\n# ${variableName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`;
|
|
const variableValue = declaration.initializer ? declaration.initializer.getText() : 'undefined';
|
|
const variableDefinition = `const ${variableName} = ${variableValue};`;
|
|
markdown += `\`\`\`ts\n${variableDefinition}\n\`\`\``;
|
|
|
|
// Find referenced types in the variable value
|
|
const references = filterToExportedTypes(findAllTypeReferences(variableValue), variableName);
|
|
markdown += formatReferences(references);
|
|
|
|
// In tandem: update usage map
|
|
references.forEach(ref => addUsage(ref, variableName, variableName, 'variable'));
|
|
|
|
generatedDocs.set(variableName, markdown);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Handle enum declarations separately
|
|
if (ts.isEnumDeclaration(declaration)) {
|
|
const enumName = declaration.name.text;
|
|
|
|
const description = extractJsDocDescription(declaration, {
|
|
tagHandling: 'filterAll',
|
|
transform: text => processLinkTags(text, enumName),
|
|
});
|
|
|
|
const order = symbolOrderMap.get(enumName);
|
|
if (order === undefined) {
|
|
throw new Error(`Symbol '${enumName}' not found in entry files export order`);
|
|
}
|
|
indexEntries.push({ name: enumName, type: 'Enum', group: groupName, order });
|
|
|
|
// Reconstruct the enum body so each member keeps its value and description
|
|
const memberLines = declaration.members.map((member) => {
|
|
const memberName = member.name.getText();
|
|
const initializer = member.initializer ? ` = ${member.initializer.getText()}` : '';
|
|
const memberDescription = extractJsDocDescription(member, {
|
|
tagHandling: 'filterAll',
|
|
transform: text => processLinkTags(text, enumName),
|
|
});
|
|
const commentLine = memberDescription ? `\t/** ${memberDescription} */\n` : '';
|
|
return `${commentLine}\t${memberName}${initializer},`;
|
|
});
|
|
|
|
const enumDefinition = `enum ${enumName} {\n${memberLines.join('\n')}\n}`;
|
|
const deprecationNotice = getDeprecationNotice(declaration, enumName);
|
|
let markdown = `${buildFrontmatter(description)}<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n<VPBadge type="info" text="Enum" />\n\n# ${enumName}\n\n${deprecationNotice}${description ? `${description}\n\n` : ''}`;
|
|
markdown += `\`\`\`ts\n${enumDefinition}\n\`\`\``;
|
|
|
|
generatedDocs.set(enumName, markdown);
|
|
|
|
return;
|
|
}
|
|
|
|
{
|
|
const className = declaration.name.text;
|
|
const isAbstract = ts.isClassDeclaration(declaration) && declaration.modifiers?.some(mod => mod.kind === ts.SyntaxKind.AbstractKeyword);
|
|
|
|
// Add to index
|
|
const order = symbolOrderMap.get(className);
|
|
if (order === undefined) {
|
|
throw new Error(`Symbol '${className}' not found in entry files export order`);
|
|
}
|
|
if (isAbstract) {
|
|
indexEntries.push({ name: className, type: 'Abstract class', group: groupName, order });
|
|
} else if (ts.isClassDeclaration(declaration)) {
|
|
indexEntries.push({ name: className, type: 'Class', group: groupName, order });
|
|
} else if (ts.isTypeAliasDeclaration(declaration)) {
|
|
indexEntries.push({ name: className, type: 'Type', group: groupName, order });
|
|
} else if (ts.isInterfaceDeclaration(declaration)) {
|
|
indexEntries.push({ name: className, type: 'Interface', group: groupName, order });
|
|
}
|
|
|
|
const properties: string[] = [];
|
|
const events: string[] = [];
|
|
const methods: string[] = [];
|
|
const staticMethods: string[] = [];
|
|
const deprecatedProperties: string[] = [];
|
|
const deprecatedMethods: string[] = [];
|
|
let constructor: string | null = null;
|
|
let extendsClause = '';
|
|
let implementsClause = '';
|
|
let typeParameters: string | null = null;
|
|
|
|
// Get class description from JSDoc (or from superclass if none)
|
|
let description = extractJsDocDescription(declaration, {
|
|
tagHandling: 'filterAll',
|
|
transform: text => processLinkTags(text, className),
|
|
});
|
|
|
|
// If no description, check superclass (only for classes/interfaces)
|
|
if (!description && (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) {
|
|
const classType = typeChecker.getTypeAtLocation(declaration);
|
|
const baseTypes = classType.getBaseTypes();
|
|
if (baseTypes && baseTypes.length > 0) {
|
|
const baseSymbol = baseTypes[0]!.getSymbol();
|
|
if (baseSymbol && baseSymbol.valueDeclaration) {
|
|
const baseJsDoc = ts.getJSDocCommentsAndTags(baseSymbol.valueDeclaration)[0];
|
|
if (baseJsDoc && ts.isJSDoc(baseJsDoc) && typeof baseJsDoc.comment === 'string') {
|
|
description = processLinkTags(baseJsDoc.comment.trim(), className);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for type parameters (only for classes/interfaces)
|
|
if ((ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.typeParameters && declaration.typeParameters.length > 0) {
|
|
const typeParamStrings = declaration.typeParameters.map((tp) => {
|
|
const name = tp.name.text;
|
|
const constraint = tp.constraint ? ` extends ${tp.constraint.getText()}` : '';
|
|
const defaultType = tp.default ? ` = ${tp.default.getText()}` : '';
|
|
return `\t${name}${constraint}${defaultType}`;
|
|
});
|
|
|
|
const typeParamSig = `${className}<\n${typeParamStrings.join(',\n')},\n>`;
|
|
|
|
// Get type parameter descriptions
|
|
const typeParamDocs: string[] = [];
|
|
const classJsDoc = ts.getJSDocCommentsAndTags(declaration)[0];
|
|
if (classJsDoc && ts.isJSDoc(classJsDoc)) {
|
|
const templateTags = classJsDoc.tags?.filter((tag: any) => tag.tagName.text === 'template') || [];
|
|
templateTags.forEach((tag: any) => {
|
|
if (typeof tag.comment === 'string') {
|
|
const parts = tag.comment.trim().split(/\s+/);
|
|
const paramName = parts[0];
|
|
const paramDesc = parts.slice(1).join(' ').replace(/^-\s*/, '');
|
|
if (paramDesc) {
|
|
typeParamDocs.push(`- **${paramName}**: ${paramDesc}`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
typeParameters = `## Type parameters\n\n\`\`\`ts\n${typeParamSig}\n\`\`\``;
|
|
if (typeParamDocs.length > 0) {
|
|
typeParameters += `\n\n${typeParamDocs.join('\n')}`;
|
|
}
|
|
|
|
// Find referenced types in type parameters for classes/interfaces
|
|
const typeParamRefs: string[] = [];
|
|
declaration.typeParameters.forEach((tp) => {
|
|
if (tp.constraint) {
|
|
typeParamRefs.push(...findAllTypeReferences(tp.constraint.getText()));
|
|
}
|
|
if (tp.default) {
|
|
typeParamRefs.push(...findAllTypeReferences(tp.default.getText()));
|
|
}
|
|
});
|
|
const typeParamReferences = filterToExportedTypes([...new Set(typeParamRefs)], className);
|
|
const typeParamReferencesText = formatReferences(typeParamReferences);
|
|
if (typeParamReferencesText) {
|
|
typeParameters += typeParamReferencesText;
|
|
}
|
|
|
|
// In tandem: update usage map
|
|
typeParamReferences.forEach(ref => addUsage(ref, className, className, 'type_param'));
|
|
}
|
|
|
|
// Check for extends clause (only for classes/interfaces)
|
|
if ((ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) && declaration.heritageClauses) {
|
|
const extendsClauseNode = declaration.heritageClauses.find(
|
|
clause => clause.token === ts.SyntaxKind.ExtendsKeyword,
|
|
);
|
|
if (extendsClauseNode && extendsClauseNode.types[0]) {
|
|
const superClassName = extendsClauseNode.types[0].expression.getText();
|
|
if (exportedTypes.has(superClassName)) {
|
|
extendsClause = `\n\n**Extends:** [\`${superClassName}\`](./${superClassName}.md)\n`;
|
|
// In tandem: update usage map
|
|
addUsage(superClassName, className, className, 'extends');
|
|
} else {
|
|
extendsClause = `\n\n**Extends:** \`${superClassName}\`\n`;
|
|
}
|
|
}
|
|
|
|
// Check for implements clause (only for classes)
|
|
if (ts.isClassDeclaration(declaration)) {
|
|
const implementsClauseNode = declaration.heritageClauses.find(
|
|
clause => clause.token === ts.SyntaxKind.ImplementsKeyword,
|
|
);
|
|
if (implementsClauseNode && implementsClauseNode.types.length > 0) {
|
|
const implementedInterfaces = implementsClauseNode.types.map((type) => {
|
|
const interfaceName = type.expression.getText();
|
|
if (exportedTypes.has(interfaceName)) {
|
|
// In tandem: update usage map
|
|
addUsage(interfaceName, className, className, 'extends');
|
|
return `[\`${interfaceName}\`](./${interfaceName}.md)`;
|
|
} else {
|
|
return `\`${interfaceName}\``;
|
|
}
|
|
});
|
|
implementsClause = `\n\n**Implements:** ${implementedInterfaces.join(', ')}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper to get JSDoc description with superclass fallback (recursive)
|
|
const getDescriptionWithFallback = (member: ts.ClassElement | ts.TypeElement, memberName: string): string => {
|
|
const ownDescription = extractJsDocDescription(member, {
|
|
tagHandling: 'filterAll',
|
|
transform: text => processLinkTags(text, className),
|
|
});
|
|
if (ownDescription) {
|
|
return ownDescription;
|
|
}
|
|
|
|
// Recursively check superclass hierarchy for this member's documentation
|
|
const findInHierarchy = (currentDeclaration: ts.ClassDeclaration): string => {
|
|
const currentType = typeChecker.getTypeAtLocation(currentDeclaration);
|
|
const baseTypes = currentType.getBaseTypes();
|
|
|
|
if (baseTypes && baseTypes.length > 0) {
|
|
const baseSymbol = baseTypes[0]!.getSymbol();
|
|
if (baseSymbol && baseSymbol.valueDeclaration && ts.isClassDeclaration(baseSymbol.valueDeclaration)) {
|
|
const baseMember = baseSymbol.valueDeclaration.members.find(m =>
|
|
m.name && m.name.getText() === memberName,
|
|
);
|
|
if (baseMember) {
|
|
const baseJsDoc = ts.getJSDocCommentsAndTags(baseMember)[0];
|
|
if (baseJsDoc && ts.isJSDoc(baseJsDoc) && typeof baseJsDoc.comment === 'string') {
|
|
return processLinkTags(baseJsDoc.comment.trim(), className);
|
|
}
|
|
}
|
|
// Recursively check further up the hierarchy
|
|
return findInHierarchy(baseSymbol.valueDeclaration);
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
|
|
if (ts.isClassDeclaration(declaration)) {
|
|
return findInHierarchy(declaration);
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
// Helper to extract linked types from a member's JSDoc
|
|
const getLinkedTypesFromMember = (member: ts.ClassElement | ts.TypeElement): string[] => {
|
|
const jsDoc = ts.getJSDocCommentsAndTags(member)[0];
|
|
if (jsDoc && ts.isJSDoc(jsDoc)) {
|
|
// First try the parsed comment
|
|
if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
|
|
return extractLinkedTypes(jsDoc.comment.trim());
|
|
} else {
|
|
// If no parsed comment, extract from raw source
|
|
const sourceFile = member.getSourceFile();
|
|
const sourceText = sourceFile.getFullText();
|
|
const start = jsDoc.getStart();
|
|
const end = jsDoc.getEnd();
|
|
const rawJsDoc = sourceText.substring(start, end);
|
|
|
|
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
|
|
if (match && match[1]) {
|
|
const content = match[1]
|
|
.split('\n')
|
|
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
|
|
.join('\n')
|
|
.trim();
|
|
|
|
// Filter out @tags but keep the description
|
|
const lines = content.split('\n');
|
|
const descLines = lines.filter(line => !line.trim().startsWith('@'));
|
|
const rawDesc = descLines.join('\n').trim();
|
|
|
|
return extractLinkedTypes(rawDesc);
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
// Helper to process members (both own and inherited)
|
|
const processMember = (member: ts.ClassElement | ts.TypeElement, _isInherited = false) => {
|
|
// Check if member has @internal in JSDoc
|
|
const hasInternalTag = ts.getJSDocTags(member).some(tag => tag.tagName.text === 'internal');
|
|
if (hasInternalTag) return;
|
|
|
|
const isDeprecatedMember = hasDeprecatedTag(member);
|
|
|
|
const addDeprecationNotice = (content: string) => {
|
|
// Insert the deprecation notice right after the heading line
|
|
const headingEnd = content.indexOf('\n');
|
|
return content.slice(0, headingEnd) + '\n\n' + getDeprecationNotice(member, className) + content.slice(headingEnd + 1);
|
|
};
|
|
|
|
const pushProperty = (content: string) => {
|
|
if (isDeprecatedMember) {
|
|
deprecatedProperties.push(addDeprecationNotice(content));
|
|
} else {
|
|
properties.push(content);
|
|
}
|
|
};
|
|
|
|
const pushMethod = (content: string) => {
|
|
if (isDeprecatedMember) {
|
|
deprecatedMethods.push(addDeprecationNotice(content));
|
|
} else {
|
|
methods.push(content);
|
|
}
|
|
};
|
|
|
|
if (ts.isConstructorDeclaration(member) && !isAbstract) {
|
|
// Skip private constructors
|
|
const isPrivate = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword);
|
|
if (isPrivate) return;
|
|
|
|
// Only process the first constructor we encounter to build the constructor section
|
|
// We'll collect all overloads separately
|
|
if (!constructor) {
|
|
// Collect all constructor overloads from the class
|
|
const constructorOverloads: ts.ConstructorDeclaration[] = [];
|
|
if (ts.isClassDeclaration(declaration)) {
|
|
declaration.members.forEach((m) => {
|
|
if (ts.isConstructorDeclaration(m) && !m.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword)) {
|
|
constructorOverloads.push(m);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Build individual constructor blocks with their own descriptions
|
|
const constructorBlocks: string[] = [];
|
|
const allReferencedTypes: string[] = [];
|
|
|
|
// First, process constructor parameters with visibility modifiers as properties from any constructor
|
|
// that has them (typically the implementation or the main signature)
|
|
let processedProperties = false;
|
|
constructorOverloads.forEach((ctor) => {
|
|
// Process constructor parameters with visibility modifiers as properties (only once)
|
|
if (!processedProperties) {
|
|
const hasVisibilityParams = ctor.parameters.some(param =>
|
|
param.modifiers?.some(mod =>
|
|
mod.kind === ts.SyntaxKind.PublicKeyword
|
|
|| mod.kind === ts.SyntaxKind.PrivateKeyword
|
|
|| mod.kind === ts.SyntaxKind.ProtectedKeyword
|
|
|| mod.kind === ts.SyntaxKind.ReadonlyKeyword,
|
|
),
|
|
);
|
|
|
|
if (hasVisibilityParams) {
|
|
ctor.parameters.forEach((param) => {
|
|
// Check if parameter has visibility modifier (public, private, protected, readonly)
|
|
const hasVisibilityModifier = param.modifiers?.some(mod =>
|
|
mod.kind === ts.SyntaxKind.PublicKeyword
|
|
|| mod.kind === ts.SyntaxKind.PrivateKeyword
|
|
|| mod.kind === ts.SyntaxKind.ProtectedKeyword
|
|
|| mod.kind === ts.SyntaxKind.ReadonlyKeyword,
|
|
);
|
|
|
|
if (!hasVisibilityModifier) return;
|
|
|
|
// Skip private parameters
|
|
const isPrivate = param.modifiers?.some(mod => mod.kind === ts.SyntaxKind.PrivateKeyword);
|
|
if (isPrivate) return;
|
|
|
|
const paramName = param.name.getText();
|
|
const hasQuestionToken = param.questionToken !== undefined;
|
|
const isReadonly = param.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ReadonlyKeyword);
|
|
const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param));
|
|
const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
|
|
|
|
const propertyDef = `${isReadonly ? 'readonly ' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType};`;
|
|
|
|
// Get description from JSDoc comment on the parameter
|
|
const paramJsDoc = ts.getJSDocCommentsAndTags(param)[0];
|
|
let desc = '';
|
|
if (paramJsDoc && ts.isJSDoc(paramJsDoc) && typeof paramJsDoc.comment === 'string') {
|
|
desc = processLinkTags(paramJsDoc.comment.trim(), className);
|
|
}
|
|
|
|
// Find referenced types
|
|
const references = filterToExportedTypes(findAllTypeReferences(paramType), className);
|
|
const referencesText = formatReferences(references);
|
|
|
|
// In tandem: update usage map
|
|
references.forEach(ref => addUsage(ref, className, paramName, 'property'));
|
|
|
|
const propertyContent = `### \`${paramName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
|
|
properties.push(propertyContent);
|
|
});
|
|
processedProperties = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
constructorOverloads.forEach((ctor) => {
|
|
// Skip the implementation (the one with a body) unless it's the only constructor
|
|
if (ctor.body && constructorOverloads.length > 1) {
|
|
return;
|
|
}
|
|
|
|
// Build parameter list for this overload
|
|
const params = ctor.parameters.map((param) => {
|
|
const paramName = param.name.getText();
|
|
const hasQuestionToken = param.questionToken !== undefined;
|
|
const hasDefault = param.initializer !== undefined;
|
|
const isRest = param.dotDotDotToken !== undefined;
|
|
const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param));
|
|
const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
|
|
|
|
if (hasDefault) {
|
|
const defaultValue = param.initializer.getText();
|
|
return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`;
|
|
} else {
|
|
return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`;
|
|
}
|
|
});
|
|
|
|
const constructorSig = params.length > 0
|
|
? `constructor(\n${params.join(',\n')},\n): ${className};`
|
|
: `constructor(): ${className};`;
|
|
|
|
// Collect referenced types from this overload
|
|
const ctorTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, ''));
|
|
allReferencedTypes.push(...ctorTypeStrings.flatMap(findAllTypeReferences));
|
|
|
|
// Get description for this specific overload
|
|
const constructorDesc = getDescriptionWithFallback(ctor, 'constructor');
|
|
const linkedTypes = getLinkedTypesFromMember(ctor);
|
|
|
|
// Get parameter descriptions for this specific overload
|
|
const paramDocs: string[] = [];
|
|
const jsDoc = ts.getJSDocCommentsAndTags(ctor)[0];
|
|
if (jsDoc && ts.isJSDoc(jsDoc)) {
|
|
const paramTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'param') || [];
|
|
paramTags.forEach((tag: any) => {
|
|
if (ts.isJSDocParameterTag(tag) && tag.name && typeof tag.comment === 'string') {
|
|
const paramName = tag.name.getText();
|
|
const paramDesc = tag.comment.trim().replace(/^-\s*/, '');
|
|
paramDocs.push(`- **${paramName}**: ${paramDesc}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Build this constructor block
|
|
let constructorBlock = `\`\`\`ts\n${constructorSig}\n\`\`\``;
|
|
if (constructorDesc) {
|
|
constructorBlock += `\n\n${constructorDesc}`;
|
|
}
|
|
if (paramDocs.length > 0) {
|
|
constructorBlock += `\n\n**Parameters:**\n\n${paramDocs.join('\n')}`;
|
|
}
|
|
|
|
// Find referenced types for this overload
|
|
const overloadReferences = filterToExportedTypes([...new Set(ctorTypeStrings.flatMap(findAllTypeReferences))], className);
|
|
constructorBlock += formatReferences(overloadReferences, linkedTypes);
|
|
|
|
// In tandem: update usage map
|
|
overloadReferences.forEach(ref => addUsage(ref, className, className, 'constructor'));
|
|
|
|
constructorBlocks.push(constructorBlock);
|
|
});
|
|
|
|
// Build the constructor section
|
|
const headingText = constructorBlocks.length > 1 ? 'Constructors' : 'Constructor';
|
|
const separator = constructorBlocks.length > 1 ? '\n\n---\n\n' : '\n\n';
|
|
constructor = `## ${headingText}\n\n${constructorBlocks.join(separator)}`;
|
|
}
|
|
} else if ((ts.isPropertyDeclaration(member) || ts.isPropertySignature(member)) && member.name) {
|
|
const name = member.name.getText();
|
|
const isReadonly = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.ReadonlyKeyword);
|
|
const isOptional = member.questionToken !== undefined;
|
|
// Prefer explicit type annotation if available, otherwise use type checker
|
|
let rawType: string;
|
|
if (member.type) {
|
|
rawType = member.type.getText();
|
|
// For optional properties, check if the type is a reference to an exported type
|
|
// If so, don't apply undefined removal logic to preserve the type reference
|
|
if (isOptional && exportedTypes.has(rawType)) {
|
|
// Keep the original type reference for exported types
|
|
} else {
|
|
rawType = cleanOptionalType(rawType, isOptional);
|
|
}
|
|
} else {
|
|
const memberType = typeChecker.getTypeAtLocation(member);
|
|
const typeName = memberType.getSymbol()?.getName();
|
|
// If the type has a symbol name and it's in our exported types, use that instead of expanding
|
|
if (typeName && exportedTypes.has(typeName)) {
|
|
rawType = typeName;
|
|
} else {
|
|
rawType = getTypeString(memberType);
|
|
rawType = cleanOptionalType(rawType, isOptional);
|
|
}
|
|
}
|
|
const type = rawType;
|
|
const propertyDef = `${isReadonly ? 'readonly ' : ''}${name}${isOptional ? '?' : ''}: ${type};`;
|
|
|
|
// Get description from JSDoc
|
|
const desc = getDescriptionWithFallback(member, name);
|
|
const linkedTypes = getLinkedTypesFromMember(member);
|
|
|
|
// Find referenced types
|
|
const references = filterToExportedTypes(findAllTypeReferences(type), className);
|
|
const referencesText = formatReferences(references, linkedTypes);
|
|
|
|
// In tandem: update usage map
|
|
references.forEach(ref => addUsage(ref, className, name, 'property'));
|
|
|
|
// Check if this is an event handler (starts with "on" and can be a function)
|
|
const isEventHandler = name.startsWith('on') && (
|
|
type.includes('=>')
|
|
|| type.includes('Function')
|
|
|| type.includes('() =>')
|
|
|| (type.includes('(') && type.includes(') =>'))
|
|
);
|
|
|
|
const inheritedBadge = '';
|
|
const propertyContent = `### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
|
|
|
|
if (isEventHandler) {
|
|
events.push(propertyContent);
|
|
} else {
|
|
pushProperty(propertyContent);
|
|
}
|
|
} else if (ts.isGetAccessorDeclaration(member) && member.name) {
|
|
const name = member.name.getText();
|
|
// For getters, prefer the explicit return type annotation if available
|
|
let rawType: string;
|
|
if (member.type) {
|
|
rawType = member.type.getText();
|
|
} else {
|
|
const memberType = typeChecker.getTypeAtLocation(member);
|
|
const typeName = memberType.getSymbol()?.getName();
|
|
// If the type has a symbol name and it's in our exported types, use that instead of expanding
|
|
if (typeName && exportedTypes.has(typeName)) {
|
|
rawType = typeName;
|
|
} else {
|
|
rawType = getTypeString(memberType);
|
|
}
|
|
}
|
|
const type = cleanOptionalType(rawType, false);
|
|
const accessorDef = `get ${name}(): ${type};`;
|
|
|
|
// Get description from JSDoc
|
|
const desc = getDescriptionWithFallback(member, name);
|
|
const linkedTypes = getLinkedTypesFromMember(member);
|
|
|
|
// Find referenced types
|
|
const references = filterToExportedTypes(findAllTypeReferences(type), className);
|
|
const referencesText = formatReferences(references, linkedTypes);
|
|
|
|
// In tandem: update usage map
|
|
references.forEach(ref => addUsage(ref, className, name, 'property'));
|
|
|
|
const inheritedBadge = '';
|
|
pushProperty(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
|
|
} else if (ts.isSetAccessorDeclaration(member) && member.name) {
|
|
const name = member.name.getText();
|
|
const param = member.parameters[0];
|
|
const isOptional = param?.questionToken !== undefined;
|
|
const rawParamType = param?.type
|
|
? typeChecker.typeToString(typeChecker.getTypeAtLocation(param))
|
|
: 'any';
|
|
const paramType = cleanOptionalType(rawParamType, isOptional);
|
|
const accessorDef = `set ${name}(value: ${paramType});`;
|
|
|
|
// Get description from JSDoc
|
|
const desc = getDescriptionWithFallback(member, name);
|
|
const linkedTypes = getLinkedTypesFromMember(member);
|
|
|
|
// Find referenced types
|
|
const references = filterToExportedTypes(findAllTypeReferences(paramType), className);
|
|
const referencesText = formatReferences(references, linkedTypes);
|
|
|
|
// In tandem: update usage map
|
|
references.forEach(ref => addUsage(ref, className, name, 'property'));
|
|
|
|
const inheritedBadge = '';
|
|
pushProperty(`### \`${name}\`${inheritedBadge}\n\n\`\`\`ts\n${accessorDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`);
|
|
} else if ((ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) && member.name) {
|
|
const name = member.name.getText();
|
|
const isStatic = member.modifiers?.some(mod => mod.kind === ts.SyntaxKind.StaticKeyword);
|
|
|
|
// For method overloads, skip the implementation if it has no JSDoc
|
|
// (the overload signatures should have the documentation)
|
|
const methodJsDoc = ts.getJSDocCommentsAndTags(member)[0];
|
|
const hasJsDoc = methodJsDoc && ts.isJSDoc(methodJsDoc) && methodJsDoc.comment;
|
|
|
|
// If this is a method declaration (has a body) and has no JSDoc, check if there are overloads
|
|
if (ts.isMethodDeclaration(member) && member.body && !hasJsDoc && ts.isClassDeclaration(declaration)) {
|
|
// Check if there are other methods with the same name (overloads)
|
|
const sameNameMethods = declaration.members.filter(m =>
|
|
(ts.isMethodDeclaration(m) || ts.isMethodSignature(m))
|
|
&& m.name && m.name.getText() === name,
|
|
);
|
|
|
|
// If there are multiple methods with the same name, this is likely an overloaded method
|
|
// Skip the implementation (the one with a body) if it has no JSDoc
|
|
if (sameNameMethods.length > 1) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Build parameter list with each on its own line
|
|
const params = member.parameters.map((param) => {
|
|
const paramName = param.name.getText();
|
|
const hasQuestionToken = param.questionToken !== undefined;
|
|
const hasDefault = param.initializer !== undefined;
|
|
const isRest = param.dotDotDotToken !== undefined;
|
|
const rawParamType = param.type ? param.type.getText() : typeChecker.typeToString(typeChecker.getTypeAtLocation(param));
|
|
const paramType = cleanOptionalType(rawParamType, hasQuestionToken);
|
|
|
|
if (hasDefault) {
|
|
const defaultValue = param.initializer.getText();
|
|
return `\t${isRest ? '...' : ''}${paramName}: ${paramType} = ${defaultValue}`;
|
|
} else {
|
|
return `\t${isRest ? '...' : ''}${paramName}${hasQuestionToken ? '?' : ''}: ${paramType}`;
|
|
}
|
|
});
|
|
|
|
// Get return type
|
|
const signature = typeChecker.getSignatureFromDeclaration(member);
|
|
const returnType = signature ? getTypeString(signature.getReturnType()) : 'void';
|
|
|
|
// Format method signature
|
|
const methodSig = params.length > 0
|
|
? `${isStatic ? 'static ' : ''}${name}(\n${params.join(',\n')},\n): ${returnType};`
|
|
: `${isStatic ? 'static ' : ''}${name}(): ${returnType};`;
|
|
|
|
// Get method description from JSDoc
|
|
const desc = getDescriptionWithFallback(member, name);
|
|
const linkedTypes = getLinkedTypesFromMember(member);
|
|
|
|
// Get parameter and return descriptions
|
|
const paramDocs: string[] = [];
|
|
let returnDoc = '';
|
|
const jsDoc = ts.getJSDocCommentsAndTags(member)[0];
|
|
if (jsDoc && ts.isJSDoc(jsDoc)) {
|
|
const paramTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'param') || [];
|
|
paramTags.forEach((tag: any) => {
|
|
if (ts.isJSDocParameterTag(tag) && tag.name && typeof tag.comment === 'string') {
|
|
const paramName = tag.name.getText();
|
|
const paramDesc = tag.comment.trim().replace(/^-\s*/, '');
|
|
paramDocs.push(`- **${paramName}**: ${paramDesc}`);
|
|
}
|
|
});
|
|
|
|
const returnTags = jsDoc.tags?.filter((tag: any) => tag.tagName.text === 'returns' || tag.tagName.text === 'return') || [];
|
|
if (returnTags.length > 0 && returnTags[0] && typeof returnTags[0].comment === 'string') {
|
|
returnDoc = returnTags[0].comment.trim().replace(/^-\s*/, '');
|
|
}
|
|
}
|
|
|
|
const inheritedBadge = '';
|
|
let methodContent = `### \`${name}()\`${inheritedBadge}\n\n\`\`\`ts\n${methodSig}\n\`\`\``;
|
|
if (desc) {
|
|
methodContent += `\n\n${desc}`;
|
|
}
|
|
if (paramDocs.length > 0) {
|
|
methodContent += `\n\n**Parameters:**\n\n${paramDocs.join('\n')}`;
|
|
}
|
|
if (returnDoc) {
|
|
methodContent += `\n\n**Returns:** ${returnDoc}`;
|
|
}
|
|
|
|
// Find referenced types in all parameters and return type
|
|
const allTypeStrings = params.map(p => p.replace(/\t.*?:\s*/, '')).concat([returnType]);
|
|
const allReferences = filterToExportedTypes([...new Set(allTypeStrings.flatMap(findAllTypeReferences))], className);
|
|
methodContent += formatReferences(allReferences, linkedTypes);
|
|
|
|
// In tandem: update usage map
|
|
allReferences.forEach(ref => addUsage(ref, className, name, 'method'));
|
|
|
|
if (isStatic) {
|
|
staticMethods.push(methodContent);
|
|
} else {
|
|
pushMethod(methodContent);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Track names of own members to avoid duplicates with inherited
|
|
const ownMemberNames = new Set<string>();
|
|
|
|
// Process own members first
|
|
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
|
|
declaration.members.forEach((member) => {
|
|
if (member.name) {
|
|
ownMemberNames.add(member.name.getText());
|
|
}
|
|
processMember(member);
|
|
});
|
|
} else if (ts.isTypeAliasDeclaration(declaration)) {
|
|
// For type aliases, check if it's a simple union type (like string literals)
|
|
const resolvedType = typeChecker.getTypeAtLocation(declaration);
|
|
|
|
// If it's a primitive type or simple union, skip property processing
|
|
const isPrimitive = !!(resolvedType.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral));
|
|
const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t =>
|
|
t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean),
|
|
);
|
|
|
|
if (!isPrimitive && !isSimpleUnion) {
|
|
const typeProperties = typeChecker.getPropertiesOfType(resolvedType);
|
|
|
|
typeProperties.forEach((prop) => {
|
|
// Create a synthetic property signature for each resolved property
|
|
const propName = prop.getName();
|
|
const propType = typeChecker.getTypeOfSymbolAtLocation(prop, declaration);
|
|
const propTypeString = getTypeString(propType);
|
|
const isOptional = (prop.flags & ts.SymbolFlags.Optional) !== 0;
|
|
|
|
// Get JSDoc from the original declaration
|
|
let desc = '';
|
|
const propDeclaration = prop.valueDeclaration || prop.declarations?.[0];
|
|
if (propDeclaration) {
|
|
const rawDesc = getFullJSDocDescription(propDeclaration);
|
|
if (rawDesc) {
|
|
desc = processLinkTags(rawDesc, className);
|
|
}
|
|
}
|
|
|
|
// Check if this property's original type annotation references an exported type
|
|
let cleanedType = propTypeString;
|
|
if (propDeclaration && ts.isPropertySignature(propDeclaration) && propDeclaration.type) {
|
|
const originalType = propDeclaration.type.getText();
|
|
if (exportedTypes.has(originalType)) {
|
|
cleanedType = originalType;
|
|
} else {
|
|
cleanedType = cleanOptionalType(propTypeString, isOptional);
|
|
}
|
|
} else {
|
|
cleanedType = cleanOptionalType(propTypeString, isOptional);
|
|
}
|
|
|
|
const propertyDef = `${propName}${isOptional ? '?' : ''}: ${cleanedType};`;
|
|
|
|
// Find referenced types
|
|
const references = filterToExportedTypes(findAllTypeReferences(cleanedType), className);
|
|
const referencesText = formatReferences(references);
|
|
|
|
// In tandem: update usage map
|
|
references.forEach(ref => addUsage(ref, className, propName, 'property'));
|
|
|
|
// Check if this is an event handler (starts with "on" and can be a function)
|
|
const isEventHandler = propName.startsWith('on') && (
|
|
cleanedType.includes('=>')
|
|
|| cleanedType.includes('Function')
|
|
|| cleanedType.includes('() =>')
|
|
|| (cleanedType.includes('(') && cleanedType.includes(') =>'))
|
|
);
|
|
|
|
// Type alias properties are never inherited
|
|
const propertyContent = `### \`${propName}\`\n\n\`\`\`ts\n${propertyDef}\n\`\`\`${desc ? `\n\n${desc}` : ''}${referencesText}`;
|
|
|
|
if (isEventHandler) {
|
|
events.push(propertyContent);
|
|
} else {
|
|
properties.push(propertyContent);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Process inherited members (skip if overridden, only for classes)
|
|
if (ts.isClassDeclaration(declaration)) {
|
|
const classType = typeChecker.getTypeAtLocation(declaration);
|
|
const baseTypes = classType.getBaseTypes();
|
|
if (baseTypes && baseTypes.length > 0) {
|
|
baseTypes.forEach((baseType) => {
|
|
const baseSymbol = baseType.getSymbol();
|
|
if (baseSymbol && baseSymbol.valueDeclaration && ts.isClassDeclaration(baseSymbol.valueDeclaration)) {
|
|
baseSymbol.valueDeclaration.members.forEach((member) => {
|
|
// Skip if this member is overridden in the derived class
|
|
if (member.name && ownMemberNames.has(member.name.getText())) {
|
|
return;
|
|
}
|
|
// Never process inherited constructors - always use the derived class constructor
|
|
if (ts.isConstructorDeclaration(member)) {
|
|
return;
|
|
}
|
|
processMember(member, true);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Sort properties and methods alphabetically, but with bracketed names like
|
|
// `[Symbol.dispose]()` always at the bottom. Type aliases keep source order.
|
|
const compareMemberNames = (a: string, b: string) => {
|
|
const nameA = a.match(/### `([^`]+)`/)?.[1] ?? '';
|
|
const nameB = b.match(/### `([^`]+)`/)?.[1] ?? '';
|
|
const aIsBracket = nameA.startsWith('[');
|
|
const bIsBracket = nameB.startsWith('[');
|
|
if (aIsBracket !== bIsBracket) {
|
|
return aIsBracket ? 1 : -1;
|
|
}
|
|
return nameA.localeCompare(nameB);
|
|
};
|
|
|
|
if (!ts.isTypeAliasDeclaration(declaration)) {
|
|
properties.sort(compareMemberNames);
|
|
methods.sort(compareMemberNames);
|
|
}
|
|
|
|
staticMethods.sort(compareMemberNames);
|
|
deprecatedProperties.sort(compareMemberNames);
|
|
deprecatedMethods.sort(compareMemberNames);
|
|
|
|
let markdown = buildFrontmatter(description);
|
|
|
|
// Add VPBadge import and badge for all types
|
|
markdown += `<script setup>\nimport { VPBadge } from 'vitepress/theme'\n</script>\n\n`;
|
|
|
|
if (isAbstract) {
|
|
markdown += `<VPBadge type="info" text="Abstract class" />\n\n`;
|
|
} else if (ts.isClassDeclaration(declaration)) {
|
|
markdown += `<VPBadge type="info" text="Class" />\n\n`;
|
|
} else if (ts.isTypeAliasDeclaration(declaration)) {
|
|
markdown += `<VPBadge type="info" text="Type" />\n\n`;
|
|
} else if (ts.isInterfaceDeclaration(declaration)) {
|
|
markdown += `<VPBadge type="info" text="Interface" />\n\n`;
|
|
}
|
|
|
|
const deprecationNotice = getDeprecationNotice(declaration, className);
|
|
markdown += `# ${className}\n\n${deprecationNotice}${description ? `${description}\n` : ''}${extendsClause}${implementsClause}`;
|
|
|
|
// Add subclasses section for classes that have subclasses
|
|
if (ts.isClassDeclaration(declaration) && classHierarchy.has(className)) {
|
|
// Recursively build hierarchical list
|
|
const buildHierarchicalList = (parentClass: string, depth = 0, visited = new Set<string>()): string => {
|
|
if (visited.has(parentClass)) return ''; // Prevent infinite loops
|
|
visited.add(parentClass);
|
|
|
|
const directChildren = classHierarchy.get(parentClass) || [];
|
|
if (directChildren.length === 0) return '';
|
|
|
|
// Sort children by definition order
|
|
const sortedChildren = [...directChildren].sort((a, b) => {
|
|
const orderA = symbolOrderMap.get(a);
|
|
const orderB = symbolOrderMap.get(b);
|
|
if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`);
|
|
if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`);
|
|
return orderA - orderB;
|
|
});
|
|
|
|
let result = '';
|
|
const indent = ' '.repeat(depth);
|
|
|
|
for (const child of sortedChildren) {
|
|
result += `${indent}- [\`${child}\`](./${child}.md)\n`;
|
|
// Recursively add children of this child
|
|
result += buildHierarchicalList(child, depth + 1, visited);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const hierarchicalList = buildHierarchicalList(className);
|
|
if (hierarchicalList) {
|
|
markdown += `\n## Subclasses\n\n${hierarchicalList}`;
|
|
}
|
|
}
|
|
|
|
// Add instances section for classes that have instances
|
|
if (ts.isClassDeclaration(declaration) && classInstances.has(className)) {
|
|
const instances = classInstances.get(className)!;
|
|
// Sort by definition order instead of alphabetically
|
|
instances.sort((a, b) => {
|
|
const orderA = symbolOrderMap.get(a);
|
|
const orderB = symbolOrderMap.get(b);
|
|
if (orderA === undefined) throw new Error(`Symbol '${a}' not found in entry files export order`);
|
|
if (orderB === undefined) throw new Error(`Symbol '${b}' not found in entry files export order`);
|
|
return orderA - orderB;
|
|
});
|
|
markdown += `\n## Instances\n\n`;
|
|
instances.forEach((instance) => {
|
|
markdown += `- [\`${instance}\`](./${instance}.md)\n`;
|
|
});
|
|
}
|
|
|
|
// Add placeholder for "Used by" section for classes and interfaces
|
|
if (ts.isClassDeclaration(declaration) || ts.isInterfaceDeclaration(declaration)) {
|
|
markdown += '\n<!-- USED_BY_SECTION -->\n';
|
|
}
|
|
|
|
// Add type definition for type aliases
|
|
if (ts.isTypeAliasDeclaration(declaration) && declaration.type) {
|
|
const resolvedType = typeChecker.getTypeAtLocation(declaration);
|
|
const isPrimitive = !!(resolvedType.flags & (ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean | ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral));
|
|
const isSimpleUnion = resolvedType.isUnion() && resolvedType.types.every(t =>
|
|
t.flags & (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral | ts.TypeFlags.BooleanLiteral | ts.TypeFlags.String | ts.TypeFlags.Number | ts.TypeFlags.Boolean),
|
|
);
|
|
|
|
// Build the type name with generic parameters if they exist
|
|
let typeName = className;
|
|
if (declaration.typeParameters && declaration.typeParameters.length > 0) {
|
|
const typeParamStrings = declaration.typeParameters.map((tp) => {
|
|
const name = tp.name.text;
|
|
const constraint = tp.constraint ? ` extends ${tp.constraint.getText()}` : '';
|
|
const defaultType = tp.default ? ` = ${tp.default.getText()}` : '';
|
|
return `${name}${constraint}${defaultType}`;
|
|
});
|
|
typeName = `${className}<${typeParamStrings.join(', ')}>`;
|
|
}
|
|
|
|
let typeText;
|
|
if (isPrimitive || isSimpleUnion) {
|
|
// For primitive types or simple unions, use resolved type string
|
|
const resolvedTypeString = typeChecker.typeToString(resolvedType);
|
|
if (resolvedTypeString === className) {
|
|
// If resolved type is just the alias name, use original text
|
|
typeText = declaration.type.getText();
|
|
} else {
|
|
typeText = resolvedTypeString;
|
|
}
|
|
// Convert string literals from double quotes to single quotes
|
|
typeText = typeText.replace(/"([^"]*)"/g, '\'$1\'');
|
|
if (typeText.includes(' | ')) {
|
|
const unionMembers = splitUnionType(typeText);
|
|
if (unionMembers.length > 1) {
|
|
typeText = '\n\t| ' + unionMembers.join('\n\t| ');
|
|
}
|
|
}
|
|
} else {
|
|
// For complex types, use the original text
|
|
typeText = declaration.type.getText();
|
|
// Format object types with proper line breaks
|
|
if (typeText.includes('{')) {
|
|
typeText = formatObjectType(typeText);
|
|
}
|
|
}
|
|
const typeDefinition = `type ${typeName} = ${typeText};`;
|
|
|
|
// Find referenced types in the type definition and generic parameters
|
|
const allTypeRefs = findAllTypeReferences(typeText);
|
|
// Also find references in type parameters (extends clauses and default types)
|
|
if (declaration.typeParameters && declaration.typeParameters.length > 0) {
|
|
declaration.typeParameters.forEach((tp) => {
|
|
if (tp.constraint) {
|
|
allTypeRefs.push(...findAllTypeReferences(tp.constraint.getText()));
|
|
}
|
|
if (tp.default) {
|
|
allTypeRefs.push(...findAllTypeReferences(tp.default.getText()));
|
|
}
|
|
});
|
|
}
|
|
const typeReferences = filterToExportedTypes([...new Set(allTypeRefs)], className);
|
|
const typeReferencesText = formatReferences(typeReferences);
|
|
|
|
// In tandem: update usage map
|
|
typeReferences.forEach(ref => addUsage(ref, className, className, 'type_alias'));
|
|
|
|
markdown += `\n\`\`\`ts\n${typeDefinition}\n\`\`\`${typeReferencesText}`;
|
|
|
|
// Add placeholder for "Used by" section for type aliases
|
|
markdown += '\n<!-- USED_BY_SECTION -->\n';
|
|
}
|
|
|
|
if (typeParameters) {
|
|
markdown += `\n${typeParameters}\n\n`;
|
|
}
|
|
|
|
if (constructor) {
|
|
markdown += `\n${constructor}\n\n`;
|
|
}
|
|
|
|
if (staticMethods.length > 0) {
|
|
markdown += `\n## Static methods\n\n${staticMethods.join('\n\n')}\n\n`;
|
|
}
|
|
|
|
if (properties.length > 0) {
|
|
markdown += `\n## Properties\n\n${properties.join('\n\n')}\n\n`;
|
|
}
|
|
|
|
if (events.length > 0) {
|
|
markdown += `\n## Events\n\n${events.join('\n\n')}\n\n`;
|
|
}
|
|
|
|
if (methods.length > 0) {
|
|
markdown += `\n## Methods\n\n${methods.join('\n\n')}\n`;
|
|
}
|
|
|
|
const deprecated = [...deprecatedProperties, ...deprecatedMethods];
|
|
if (deprecated.length > 0) {
|
|
markdown += `\n## Deprecated\n\n${deprecated.join('\n\n')}\n`;
|
|
}
|
|
|
|
generatedDocs.set(className, markdown);
|
|
}
|
|
});
|
|
|
|
// Phase 3: Assemble final docs with "Used by" sections and write files
|
|
generatedDocs.forEach((markdown, symbolName) => {
|
|
const usages = usedByReferences.get(symbolName);
|
|
let usedByMarkdown = '';
|
|
|
|
if (usages && usages.size > 0) {
|
|
const symbolSubclasses = classHierarchy.get(symbolName) || [];
|
|
|
|
const usedByLines = [...usages]
|
|
.filter((usage) => {
|
|
// Filter out subclasses from "Used by" since they already appear in "Subclasses" section
|
|
if (usage.type === 'extends' && symbolSubclasses.includes(usage.user)) {
|
|
return false;
|
|
}
|
|
|
|
// Filter out top-level type references when there are more specific contexts available
|
|
// This prevents redundancy where both "TypeName" and "TypeName.property" appear
|
|
if (usage.type === 'type_alias' || usage.type === 'type_param') {
|
|
// Check if there are more specific usages from the same user (property, method, constructor, etc.)
|
|
const hasMoreSpecificUsage = [...usages].some(otherUsage =>
|
|
otherUsage.user === usage.user
|
|
&& otherUsage.type !== 'type_alias'
|
|
&& otherUsage.type !== 'type_param'
|
|
&& otherUsage.type !== 'extends',
|
|
);
|
|
if (hasMoreSpecificUsage) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
})
|
|
.map((usage) => {
|
|
let displayText = '';
|
|
let link = '';
|
|
|
|
switch (usage.type) {
|
|
case 'constructor':
|
|
displayText = `new ${usage.user}()`;
|
|
link = `./${usage.user}.md#constructor`;
|
|
break;
|
|
case 'method':
|
|
displayText = `${usage.user}.${usage.context}()`;
|
|
link = `./${usage.user}.md#${usage.context.toLowerCase()}`;
|
|
break;
|
|
case 'property':
|
|
case 'variable':
|
|
displayText = `${usage.user}.${usage.context}`;
|
|
link = `./${usage.user}.md#${usage.context.toLowerCase()}`;
|
|
break;
|
|
case 'function':
|
|
displayText = `${usage.user}()`;
|
|
link = `./${usage.user}.md`;
|
|
break;
|
|
case 'extends':
|
|
case 'type_param':
|
|
case 'type_alias':
|
|
displayText = usage.user;
|
|
link = `./${usage.user}.md`;
|
|
break;
|
|
}
|
|
return { text: `[\`${displayText}\`](${link})`, sortKey: displayText.toLowerCase() };
|
|
});
|
|
|
|
if (usedByLines.length > 0) {
|
|
// Sort alphabetically by display text
|
|
usedByLines.sort((a, b) => a.sortKey.localeCompare(b.sortKey));
|
|
const listItems = usedByLines.map(item => `- ${item.text}`).join('\n');
|
|
usedByMarkdown = `\n## Used by\n\n${listItems}\n`;
|
|
}
|
|
}
|
|
|
|
const finalMarkdown = markdown.replace('<!-- USED_BY_SECTION -->', usedByMarkdown);
|
|
if (!dry) {
|
|
const outputPath = path.join(outputDir, `${symbolName}.md`);
|
|
fs.writeFileSync(outputPath, finalMarkdown);
|
|
console.log(`Generated: ${outputPath}`);
|
|
}
|
|
});
|
|
|
|
// Generate index.md with all exported symbols grouped by group
|
|
const entriesByGroup = new Map<string, Array<{ name: string; type: string; order: number }>>();
|
|
|
|
indexEntries.forEach((entry) => {
|
|
if (!entriesByGroup.has(entry.group)) {
|
|
entriesByGroup.set(entry.group, []);
|
|
}
|
|
entriesByGroup.get(entry.group)!.push({ name: entry.name, type: entry.type, order: entry.order });
|
|
});
|
|
|
|
// Sort groups according to API config order
|
|
const configGroups = Object.keys(groupConfig);
|
|
const sortedGroups = configGroups.filter(group => entriesByGroup.has(group));
|
|
|
|
// Check for groups in entries that aren't in config
|
|
const missingGroups = Array.from(entriesByGroup.keys()).filter(group => !configGroups.includes(group));
|
|
if (missingGroups.length > 0) {
|
|
throw new Error(`Groups found in code but not in API config: ${missingGroups.join(', ')}`);
|
|
}
|
|
|
|
let indexMarkdown = `${buildFrontmatter(indexDescription ?? '')}# ${headingText}\n\n`;
|
|
|
|
// Add intro text if provided
|
|
if (introText) {
|
|
indexMarkdown += `${introText}\n\n`;
|
|
}
|
|
|
|
sortedGroups.forEach((group) => {
|
|
const entries = entriesByGroup.get(group)!;
|
|
// Sort entries by definition order
|
|
entries.sort((a, b) => a.order - b.order);
|
|
|
|
indexMarkdown += `## ${group}\n\n`;
|
|
const groupDescription = groupConfig[group];
|
|
if (groupDescription) {
|
|
indexMarkdown += `${groupDescription}\n\n`;
|
|
}
|
|
entries.forEach((entry) => {
|
|
indexMarkdown += `- [${entry.name}](./${entry.name}.md)\n`;
|
|
});
|
|
indexMarkdown += '\n';
|
|
});
|
|
|
|
if (!dry) {
|
|
const indexPath = path.join(outputDir, 'index.md');
|
|
fs.writeFileSync(indexPath, indexMarkdown);
|
|
console.log(`Generated: ${indexPath}`);
|
|
}
|
|
|
|
// Generate index.json with sidebar config structure
|
|
const sidebarConfig = sortedGroups.map((group) => {
|
|
const entries = entriesByGroup.get(group)!;
|
|
// Sort entries by definition order
|
|
entries.sort((a, b) => a.order - b.order);
|
|
|
|
return {
|
|
text: group,
|
|
collapsed: true,
|
|
items: entries.map(entry => ({
|
|
text: entry.name,
|
|
link: `/api/${entry.name}`,
|
|
})),
|
|
};
|
|
});
|
|
|
|
if (!dry) {
|
|
const jsonPath = path.join(outputDir, 'index.json');
|
|
fs.writeFileSync(jsonPath, JSON.stringify(sidebarConfig, null, 2));
|
|
console.log(`Generated: ${jsonPath}`);
|
|
}
|
|
};
|
|
|
|
// Shared helper for extracting a JSDoc description, handling inline tags via raw-source fallback.
|
|
// - `tagHandling: 'stopAtFirst'` matches the behavior used by property-level descriptions: if any
|
|
// @-tag line is encountered, all subsequent lines are dropped (including any trailing description).
|
|
// - `tagHandling: 'filterAll'` matches the behavior used by class/variable top-level descriptions:
|
|
// every @-tag line is filtered out individually, preserving interleaved description lines.
|
|
// - `transform` is applied to the final non-empty description (e.g. to process {@link} tags).
|
|
const extractJsDocDescription = (
|
|
node: ts.Node,
|
|
opts: { tagHandling: 'stopAtFirst' | 'filterAll'; transform?: (text: string) => string },
|
|
): string => {
|
|
const jsDoc = ts.getJSDocCommentsAndTags(node)[0];
|
|
if (!jsDoc || !ts.isJSDoc(jsDoc)) {
|
|
return '';
|
|
}
|
|
|
|
const transform = opts.transform ?? ((t: string) => t);
|
|
|
|
// If the parsed comment is a non-empty string (no inline tags), use it directly.
|
|
// When the parsed comment is an empty string or a structured NodeArray (inline tags like
|
|
// {@link}), fall through to raw-source extraction. For empty-string comments this is safe:
|
|
// the raw block can only contain @-tags, so raw extraction also yields empty.
|
|
if (typeof jsDoc.comment === 'string' && jsDoc.comment.trim()) {
|
|
return transform(jsDoc.comment.trim());
|
|
}
|
|
|
|
// Structured comment (contains inline tags like {@link}); extract description from raw source.
|
|
const sourceFile = node.getSourceFile();
|
|
const sourceText = sourceFile.getFullText();
|
|
const rawJsDoc = sourceText.substring(jsDoc.getStart(), jsDoc.getEnd());
|
|
|
|
const match = rawJsDoc.match(/\/\*\*(.*?)\*\//s);
|
|
if (!match || !match[1]) {
|
|
return '';
|
|
}
|
|
|
|
const content = match[1]
|
|
.split('\n')
|
|
.map(line => line.replace(/^\s*\*\s?/, '')) // Remove leading * and spaces
|
|
.join('\n')
|
|
.trim();
|
|
|
|
const lines = content.split('\n');
|
|
let descLines: string[];
|
|
if (opts.tagHandling === 'stopAtFirst') {
|
|
descLines = [];
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('@')) {
|
|
break;
|
|
}
|
|
descLines.push(line);
|
|
}
|
|
} else {
|
|
// Skip @tag lines and their continuation lines (continuation ends at a blank line
|
|
// or the next @tag). Without this, multi-line tags like @deprecated bleed into the
|
|
// description.
|
|
descLines = [];
|
|
let inTagContinuation = false;
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith('@')) {
|
|
inTagContinuation = true;
|
|
continue;
|
|
}
|
|
if (inTagContinuation) {
|
|
if (trimmed === '') {
|
|
inTagContinuation = false;
|
|
}
|
|
continue;
|
|
}
|
|
descLines.push(line);
|
|
}
|
|
}
|
|
|
|
const rawDesc = descLines.join('\n').trim();
|
|
if (!rawDesc) {
|
|
return '';
|
|
}
|
|
return transform(rawDesc);
|
|
};
|
|
|
|
// Helper to get the full description text from a JSDoc comment, handling inline tags.
|
|
const getFullJSDocDescription = (node: ts.Node): string => {
|
|
return extractJsDocDescription(node, { tagHandling: 'stopAtFirst' });
|
|
};
|
|
|
|
// Convert a markdown description (possibly multi-paragraph, with **bold**, `code`,
|
|
// and [text](link) from processed @link tags) into a single-line plain-text string
|
|
// suitable for the `description` field in YAML frontmatter.
|
|
const descriptionToFrontmatter = (description: string): string => {
|
|
return description
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
.replace(/__([^_]+)__/g, '$1')
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
};
|
|
|
|
const buildFrontmatter = (description: string): string => {
|
|
if (!description) {
|
|
return '';
|
|
}
|
|
const cleaned = descriptionToFrontmatter(description);
|
|
if (!cleaned) {
|
|
return '';
|
|
}
|
|
const yamlValue = `"${cleaned.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
return `---\ndescription: ${yamlValue}\n---\n\n`;
|
|
};
|
|
|
|
const main = () => {
|
|
const args = process.argv.slice(2);
|
|
|
|
// Check for --dry flag
|
|
const dryIndex = args.indexOf('--dry');
|
|
const dry = dryIndex !== -1;
|
|
|
|
// Remove --dry flag from args
|
|
if (dry) {
|
|
args.splice(dryIndex, 1);
|
|
}
|
|
|
|
if (args.length < 2) {
|
|
console.error('Usage: npm run generate-docs [--dry] <entry-file1> [entry-file2 ...] <api-config-file>');
|
|
console.error(' --dry: Check if docs are generatable without writing files');
|
|
console.error(' entry-files: One or more TypeScript entry files');
|
|
console.error(' api-config-file: JSON config file defining groups');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Last argument is the config file, everything else are entry files
|
|
const apiConfigFile = args[args.length - 1]!;
|
|
const entryFiles = args.slice(0, -1);
|
|
|
|
generateDocs(entryFiles, apiConfigFile, dry);
|
|
};
|
|
|
|
main();
|