@compodoc/compodoc
Version:
The missing documentation tool for your Angular application
385 lines (338 loc) • 13.9 kB
text/typescript
import * as fs from 'fs-extra';
import * as path from 'path';
import { ts } from 'ts-morph';
import { logger } from './logger';
const { glob } = require('tinyglobby');
/**
* Result of parsing API markdown exports
*/
export interface ApiMarkdownExports {
// Map of symbol name to the source file paths where it's exported
symbolToFiles: Map<string, Set<string>>;
// Set of all relevant *.api.md files found
apiMdFiles: Set<string>;
}
/**
* Utility class for parsing *.api.md files generated by API Extractor
*/
export class ApiMarkdownParser {
private sourceRoot: string;
private symbolToFiles: Map<string, Set<string>>;
private apiMdFiles: Set<string>;
constructor(sourceRoot: string) {
this.sourceRoot = path.resolve(sourceRoot);
this.symbolToFiles = new Map<string, Set<string>>();
this.apiMdFiles = new Set<string>();
}
/**
* Parse all *.api.md files in the source root and extract exported symbols
*/
public async parseApiMarkdownFiles(): Promise<ApiMarkdownExports> {
logger.info(`Scanning for *.api.md files in ${this.sourceRoot}`);
if (!fs.existsSync(this.sourceRoot)) {
logger.error(`Source root path does not exist: ${this.sourceRoot}`);
return {
symbolToFiles: this.symbolToFiles,
apiMdFiles: this.apiMdFiles
};
}
// Find all *.api.md files recursively
const pattern = path.join(this.sourceRoot, '**/*.api.md');
const apiMdFiles = await glob(pattern, {
absolute: true,
ignore: ['**/node_modules/**']
});
logger.info(`Found ${apiMdFiles.length} *.api.md file(s)`);
// Process each *.api.md file
for (const apiMdFile of apiMdFiles) {
// Check if the file is relevant before adding it
if (await this.isRelevantApiMarkdownFile(apiMdFile)) {
this.apiMdFiles.add(apiMdFile);
await this.parseApiMarkdownFile(apiMdFile);
} else {
logger.debug(`Skipping irrelevant API markdown file: ${apiMdFile}`);
}
}
logger.info(`Extracted ${this.symbolToFiles.size} public API symbol(s) from ${this.apiMdFiles.size} relevant *.api.md file(s)`);
return {
symbolToFiles: this.symbolToFiles,
apiMdFiles: this.apiMdFiles
};
}
/**
* Check if an API markdown file is relevant (contains meaningful exports)
* A file is relevant if it contains at least one export that is:
* - A class, interface, function, type, enum, or variable
* - Not a default export of an empty object
* - Not an Angular internal symbol (ɵfac, ɵinj, ɵmod)
*/
private async isRelevantApiMarkdownFile(filePath: string): Promise<boolean> {
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Extract the TypeScript code block from the markdown file
const tsCodeBlock = this.extractTypeScriptCodeBlock(content);
if (!tsCodeBlock) {
logger.debug(`No TypeScript code block found in ${filePath}`);
return false;
}
// Parse the TypeScript code to check for meaningful exports
const sourceFile = ts.createSourceFile(
filePath,
tsCodeBlock,
ts.ScriptTarget.Latest,
true
);
// Check each top-level statement
for (const statement of sourceFile.statements) {
if (this.isMeaningfulExport(statement)) {
return true;
}
}
return false;
} catch (error) {
logger.error(`Error checking relevance of ${filePath}: ${error}`);
return false;
}
}
/**
* Check if a statement is a meaningful export
* Exclude default exports of empty objects and Angular internal symbols
*/
private isMeaningfulExport(statement: ts.Node): boolean {
// Handle: export class Foo {}
if (ts.isClassDeclaration(statement)) {
const name = (statement as any).name?.text;
return !!name && !this.isAngularInternalSymbol(name);
}
// Handle: export interface Bar {}
if (ts.isInterfaceDeclaration(statement)) {
const name = (statement as any).name?.text;
return !!name;
}
// Handle: export const baz = ...
if (ts.isVariableStatement(statement)) {
const hasExportModifier = (statement as any).modifiers?.some(
(m: ts.Modifier) => m.kind === ts.SyntaxKind.ExportKeyword
);
if (hasExportModifier) {
// Check if it's not just a default export of an empty object
for (const decl of (statement as any).declarationList?.declarations || []) {
const name = decl.name?.text;
if (name && !this.isAngularInternalSymbol(name) && name !== '_default') {
return true;
}
}
}
return false;
}
// Handle: export function foo() {}
if (ts.isFunctionDeclaration(statement)) {
const name = (statement as any).name?.text;
return !!name && !this.isAngularInternalSymbol(name);
}
// Handle: export type FooType = ...
if (ts.isTypeAliasDeclaration(statement)) {
const name = (statement as any).name?.text;
return !!name;
}
// Handle: export enum FooEnum {}
if (ts.isEnumDeclaration(statement)) {
const name = (statement as any).name?.text;
return !!name;
}
// Handle: export { Foo, Bar }
if (ts.isExportDeclaration(statement)) {
const exportClause = (statement as any).exportClause;
// Skip: export default _default
if ((statement as any).isTypeOnly) {
return false;
}
// Check if it's a named export declaration
if (exportClause && ts.isNamedExports(exportClause)) {
const elements = exportClause.elements;
for (const element of elements) {
const name = element.name?.text;
if (name && !this.isAngularInternalSymbol(name) && name !== '_default') {
return true;
}
}
}
return false;
}
// Handle: export default Foo
if (ts.isExportAssignment(statement)) {
// export default _default is not meaningful
if (ts.isIdentifier((statement as any).expression)) {
const name = (statement as any).expression.text;
if (name === '_default') {
return false;
}
return !this.isAngularInternalSymbol(name);
}
return false;
}
return false;
}
/**
* Check if a symbol is an Angular internal symbol
*/
private isAngularInternalSymbol(symbolName: string): boolean {
// Angular internal symbols start with ɵ
return symbolName.startsWith('ɵ');
}
/**
* Extract the TypeScript code block from a markdown file
* API Extractor markdown files have TypeScript code in a ```ts block
*
* Note: Uses string-based parsing instead of regex to avoid ReDoS vulnerabilities
* that can occur with patterns like /```ts\s*\n([\s\S]*?)\n```/ on malformed input.
*/
private extractTypeScriptCodeBlock(content: string): string | null {
// Find the opening marker (```ts or ```typescript)
const openMarkers = ['```ts\n', '```ts\r\n', '```typescript\n', '```typescript\r\n'];
let startIndex = -1;
let markerLength = 0;
for (const marker of openMarkers) {
const idx = content.indexOf(marker);
if (idx !== -1 && (startIndex === -1 || idx < startIndex)) {
startIndex = idx;
markerLength = marker.length;
}
}
if (startIndex === -1) {
return null;
}
// Find the closing marker (``` at the start of a line)
const contentStart = startIndex + markerLength;
const closeMarker = '\n```';
const endIndex = content.indexOf(closeMarker, contentStart);
if (endIndex === -1) {
return null;
}
const codeBlock = content.slice(contentStart, endIndex);
// Return null for empty code blocks
if (!codeBlock.trim()) {
return null;
}
return codeBlock;
}
/**
* Parse a single *.api.md file and extract its exports
*/
private async parseApiMarkdownFile(filePath: string): Promise<void> {
logger.debug(`Parsing API markdown file: ${filePath}`);
try {
const content = fs.readFileSync(filePath, 'utf-8');
const tsCodeBlock = this.extractTypeScriptCodeBlock(content);
if (!tsCodeBlock) {
logger.debug(`No TypeScript code block found in ${filePath}`);
return;
}
const sourceFile = ts.createSourceFile(
filePath,
tsCodeBlock,
ts.ScriptTarget.Latest,
true
);
// Process all top-level statements in the file
for (const statement of sourceFile.statements) {
this.processStatement(statement, filePath);
}
} catch (error) {
logger.error(`Error parsing API markdown file ${filePath}: ${error}`);
}
}
/**
* Process a TypeScript statement to extract exports
*/
private processStatement(statement: ts.Node, sourceFilePath: string): void {
// Handle: export class Foo {}
if (ts.isClassDeclaration(statement)) {
const name = (statement as any).name?.text;
if (name && !this.isAngularInternalSymbol(name)) {
this.addSymbol(name, sourceFilePath);
}
}
// Handle: export interface Bar {}
else if (ts.isInterfaceDeclaration(statement)) {
const name = (statement as any).name?.text;
if (name) {
this.addSymbol(name, sourceFilePath);
}
}
// Handle: export const baz = ...
else if (ts.isVariableStatement(statement)) {
const hasExportModifier = (statement as any).modifiers?.some(
(m: ts.Modifier) => m.kind === ts.SyntaxKind.ExportKeyword
);
if (hasExportModifier) {
for (const decl of (statement as any).declarationList?.declarations || []) {
const name = decl.name?.text;
if (name && !this.isAngularInternalSymbol(name) && name !== '_default') {
this.addSymbol(name, sourceFilePath);
}
}
}
}
// Handle: export function foo() {}
else if (ts.isFunctionDeclaration(statement)) {
const name = (statement as any).name?.text;
if (name && !this.isAngularInternalSymbol(name)) {
this.addSymbol(name, sourceFilePath);
}
}
// Handle: export type FooType = ...
else if (ts.isTypeAliasDeclaration(statement)) {
const name = (statement as any).name?.text;
if (name) {
this.addSymbol(name, sourceFilePath);
}
}
// Handle: export enum FooEnum {}
else if (ts.isEnumDeclaration(statement)) {
const name = (statement as any).name?.text;
if (name) {
this.addSymbol(name, sourceFilePath);
}
}
// Handle: export { Foo, Bar }
else if (ts.isExportDeclaration(statement)) {
const exportClause = (statement as any).exportClause;
if (exportClause && ts.isNamedExports(exportClause)) {
const elements = exportClause.elements;
for (const element of elements) {
const name = element.name?.text;
if (name && !this.isAngularInternalSymbol(name) && name !== '_default') {
this.addSymbol(name, sourceFilePath);
}
}
}
}
// Handle: export default Foo
else if (ts.isExportAssignment(statement)) {
if (ts.isIdentifier((statement as any).expression)) {
const name = (statement as any).expression.text;
if (name !== '_default' && !this.isAngularInternalSymbol(name)) {
this.addSymbol(name, sourceFilePath);
}
}
}
}
/**
* Add a symbol to the tracking map
*/
private addSymbol(symbolName: string, declarationFile: string): void {
if (!this.symbolToFiles.has(symbolName)) {
this.symbolToFiles.set(symbolName, new Set<string>());
}
this.symbolToFiles.get(symbolName)!.add(declarationFile);
logger.debug(`Tracked API markdown symbol: ${symbolName} from ${declarationFile}`);
}
}
/**
* Parse public API exports from *.api.md files in the source root
*/
export async function parseApiMarkdownExports(sourceRoot: string): Promise<ApiMarkdownExports> {
const parser = new ApiMarkdownParser(sourceRoot);
return await parser.parseApiMarkdownFiles();
}