@compodoc/compodoc
Version:
The missing documentation tool for your Angular application
448 lines (394 loc) • 17.5 kB
text/typescript
import * as fs from "fs-extra";
import * as path from "node:path";
import { Application } from "./app/application";
import { printStartupBanner } from "./app/cli/banner";
import {
applyCliConfiguration,
loadCliConfiguration,
} from "./app/cli/config-loader";
import { defineCliFlags } from "./app/cli/flags";
import Configuration from "./app/configuration";
import FileEngine from "./app/engines/file.engine";
import I18nEngine from "./app/engines/i18n.engine";
import { logger } from "./utils/logger";
import { readConfig, EXCLUDE_PATTERNS, INCLUDE_PATTERNS } from "./utils/utils";
import { parsePublicApi } from "./utils/public-api-parser.util";
import { parseApiMarkdownExports } from "./utils/api-markdown-parser.util";
import { createSourcePathMapper } from "./utils/source-path-mapper.util";
const { glob, globSync } = require("tinyglobby");
const { program } = require("commander");
let scannedFiles: string[] = [];
let excludeFiles = EXCLUDE_PATTERNS;
let includeFiles: string[] = [];
let cwd = process.cwd();
process.setMaxListeners(0);
export class CliApplication extends Application {
/**
* Run compodoc from the command line.
*/
protected async start(): Promise<any> {
defineCliFlags(program).parse(process.argv);
const outputHelp = () => {
program.outputHelp();
process.exit(1);
};
const programOptions = program.opts();
const { configFile, configExplorerResult } = loadCliConfiguration(
programOptions.config,
process.cwd(),
);
const configuredFilePatterns = applyCliConfiguration(
Configuration.mainData,
configFile,
programOptions,
{ scannedFiles, excludeFiles, includeFiles },
);
scannedFiles = configuredFilePatterns.scannedFiles;
excludeFiles = configuredFilePatterns.excludeFiles;
includeFiles = configuredFilePatterns.includeFiles;
printStartupBanner({
isWatching: this.isWatching,
isLlmMdStdoutMode:
Configuration.mainData.exportFormat === "llm-md" &&
!Configuration.mainData.outputProvided,
loggerSilent: logger.silent,
workingDirectory: cwd,
});
if (configExplorerResult) {
if (typeof configExplorerResult.config !== "undefined") {
logger.info(
`Using configuration file : ${configExplorerResult.filepath}`,
);
}
}
if (!configExplorerResult) {
logger.warn(`No configuration file found, switching to CLI flags.`);
}
if (
programOptions.language &&
!I18nEngine.supportLanguage(programOptions.language)
) {
logger.warn(
`The language ${programOptions.language} is not available, falling back to ${I18nEngine.fallbackLanguage}`,
);
}
if (
programOptions.tsconfig &&
typeof programOptions.tsconfig === "boolean"
) {
logger.error(`Please provide a tsconfig file.`);
process.exit(1);
}
/**
* Check --files argument call
*/
const filesOption = programOptions.files;
if (filesOption) {
Configuration.mainData.hasFilesToCoverage = true;
super.setFiles(
Array.isArray(filesOption) ? filesOption : [filesOption],
);
}
if (
programOptions.serve &&
!Configuration.mainData.tsconfig &&
programOptions.output
) {
// if -s & -d, serve it
if (!FileEngine.existsSync(Configuration.mainData.output)) {
logger.error(
`${Configuration.mainData.output} folder doesn't exist`,
);
process.exit(1);
} else {
logger.info(
`Serving documentation from ${Configuration.mainData.output} at http://${Configuration.mainData.hostname}:${programOptions.port}`,
);
super.runWebServer(Configuration.mainData.output);
}
} else if (
programOptions.serve &&
!Configuration.mainData.tsconfig &&
!programOptions.output
) {
// if only -s find ./documentation, if ok serve, else error provide -d
if (!FileEngine.existsSync(Configuration.mainData.output)) {
logger.error("Provide output generated folder with -d flag");
process.exit(1);
} else {
logger.info(
`Serving documentation from ${Configuration.mainData.output} at http://${Configuration.mainData.hostname}:${programOptions.port}`,
);
super.runWebServer(Configuration.mainData.output);
}
} else if (Configuration.mainData.hasFilesToCoverage) {
if (programOptions.coverageMinimumPerFile) {
logger.info("Run documentation coverage test for files");
super.testCoverage();
} else {
logger.error("Missing coverage configuration");
}
} else {
if (programOptions.hideGenerator) {
Configuration.mainData.hideGenerator = true;
}
if (Configuration.mainData.tsconfig) {
/**
* tsconfig file provided only
*/
const testTsConfigPath =
Configuration.mainData.tsconfig.indexOf(process.cwd());
if (testTsConfigPath !== -1) {
Configuration.mainData.tsconfig =
Configuration.mainData.tsconfig.replace(
process.cwd() + path.sep,
"",
);
}
let sourceFolder;
let sourceFileInput: string | undefined;
if (program.args.length > 0) {
/**
* tsconfig file provided with source folder in arg
*/
const testTsConfigPath =
Configuration.mainData.tsconfig.indexOf(process.cwd());
if (testTsConfigPath !== -1) {
Configuration.mainData.tsconfig =
Configuration.mainData.tsconfig.replace(
process.cwd() + path.sep,
"",
);
}
const providedSourcePath = program.args[0];
if (!FileEngine.existsSync(providedSourcePath)) {
logger.error(
`Provided source ${providedSourcePath} was not found in the current directory`,
);
process.exit(1);
} else {
const sourceStats = fs.statSync(providedSourcePath);
if (sourceStats.isFile()) {
sourceFolder = path.dirname(providedSourcePath);
sourceFileInput = providedSourcePath;
logger.info("Using provided source file");
} else {
sourceFolder = providedSourcePath;
logger.info("Using provided source folder");
}
}
}
if (!FileEngine.existsSync(Configuration.mainData.tsconfig)) {
logger.error(
`"${Configuration.mainData.tsconfig}" file was not found in the current directory`,
);
process.exit(1);
} else {
const _file = path.join(
path.join(
process.cwd(),
path.dirname(Configuration.mainData.tsconfig),
),
path.basename(Configuration.mainData.tsconfig),
);
// use the current directory of tsconfig.json as a working directory
cwd = _file.split(path.sep).slice(0, -1).join(path.sep);
logger.info("Using tsconfig file ", _file);
const tsConfigFile = readConfig(_file);
if (tsConfigFile.files) {
scannedFiles = tsConfigFile.files;
// Normalize path of these files
scannedFiles = scannedFiles.map((scannedFile) => {
return cwd + path.sep + scannedFile;
});
}
// even if files are supplied with "files" attributes, enhance the array with includes
excludeFiles = [
...excludeFiles,
...(tsConfigFile.exclude || []),
];
includeFiles = [
...includeFiles,
...(tsConfigFile.include || []),
];
if (scannedFiles.length > 0) {
includeFiles = [...includeFiles, ...scannedFiles];
}
if (sourceFileInput) {
// Restrict scanning to the provided single file to avoid treating it as a directory.
includeFiles = [path.basename(sourceFileInput)];
scannedFiles = [];
}
if (!includeFiles.length) {
includeFiles = INCLUDE_PATTERNS;
}
// If publicApiOnly is set, parse the public API exports first
if (Configuration.mainData.publicApiOnly) {
await this.processPublicApi(
Configuration.mainData.publicApiOnly,
cwd,
);
}
const files = await glob(includeFiles, {
cwd: sourceFolder || cwd,
ignore: excludeFiles,
absolute: true,
});
files.forEach((file: string) => {
if (
path.extname(file) === ".ts" ||
path.extname(file) === ".tsx"
) {
logger.debug("Including", file);
scannedFiles.push(file);
} else {
logger.warn("Excluding", file);
}
});
super.setFiles(scannedFiles);
if (
programOptions.coverageTest ||
programOptions.coverageTestPerFile
) {
logger.info("Run documentation coverage test");
super.testCoverage();
} else {
super.generate();
}
}
} else {
logger.error(
"tsconfig.json file was not found, please use -p flag",
);
outputHelp();
}
}
}
/**
* Process public API exports from dist folder or API markdown files
*/
private async processPublicApi(
distPath: string,
sourceRoot: string,
): Promise<void> {
logger.info("Processing public API exports");
try {
// First, try to parse API markdown files from the source root
logger.info("Checking for *.api.md files in source root");
const apiMarkdownExports =
await parseApiMarkdownExports(sourceRoot);
if (
apiMarkdownExports.apiMdFiles.size > 0 &&
apiMarkdownExports.symbolToFiles.size > 0
) {
logger.info(
`Found ${apiMarkdownExports.apiMdFiles.size} relevant *.api.md file(s) with ${apiMarkdownExports.symbolToFiles.size} symbol(s)`,
);
// Map symbols from API markdown files directly to source files
const symbolToSourceFiles = new Map<string, Set<string>>();
for (const [symbolName] of apiMarkdownExports.symbolToFiles) {
const sourceFiles = new Set<string>();
// Find the corresponding source file for this symbol
const sourceFile = this.findSourceFileForSymbol(
symbolName,
sourceRoot,
);
if (sourceFile) {
sourceFiles.add(sourceFile);
}
if (sourceFiles.size > 0) {
symbolToSourceFiles.set(symbolName, sourceFiles);
logger.debug(
`Public API symbol: ${symbolName} -> ${Array.from(sourceFiles).join(", ")}`,
);
}
}
// Store in configuration
Configuration.mainData.publicApiExports = symbolToSourceFiles;
logger.info(
`Loaded ${symbolToSourceFiles.size} public API symbol(s) from ${apiMarkdownExports.apiMdFiles.size} *.api.md file(s) (using API Markdown parser)`,
);
} else {
// Fall back to index.d.ts parsing
logger.info(
"No relevant *.api.md files found, falling back to index.d.ts parsing",
);
const publicApiExports = await parsePublicApi(distPath);
if (publicApiExports.symbolToFiles.size === 0) {
logger.warn(
"No public API exports found in dist folder. Documentation will be empty.",
);
return;
}
// Create source path mapper
const mapper = createSourcePathMapper(distPath, sourceRoot);
// Map symbols to source files and build the allowed exports map
const symbolToSourceFiles = new Map<string, Set<string>>();
for (const [
symbolName,
declFiles,
] of publicApiExports.symbolToFiles) {
const sourceFiles = new Set<string>();
for (const declFile of declFiles) {
const sourceFile = mapper.mapDistToSource(declFile);
if (sourceFile) {
sourceFiles.add(sourceFile);
}
}
if (sourceFiles.size > 0) {
symbolToSourceFiles.set(symbolName, sourceFiles);
logger.debug(
`Public API symbol: ${symbolName} -> ${Array.from(sourceFiles).join(", ")}`,
);
}
}
// Store in configuration
Configuration.mainData.publicApiExports = symbolToSourceFiles;
logger.info(
`Loaded ${symbolToSourceFiles.size} public API symbol(s) from ${publicApiExports.indexFiles.size} index.d.ts file(s) (using index.d.ts parser)`,
);
}
} catch (error) {
logger.error("Error processing public API:", error);
throw error;
}
}
/**
* Find the source file for a given symbol by searching through the source files
*/
private findSourceFileForSymbol(
symbolName: string,
sourceRoot: string,
): string | null {
// Try to find the symbol in source files
// This is a simplified approach - look for files that contain the symbol export
const sourceFolder = sourceRoot;
try {
const files = globSync(path.join(sourceFolder, "**/*.ts"), {
ignore: ["**/node_modules/**", "**/*.spec.ts", "**/*.d.ts"],
});
for (const file of files) {
const content = fs.readFileSync(file, "utf-8");
// Look for export patterns that match the symbol name
const patterns = [
`export class ${symbolName}`,
`export interface ${symbolName}`,
`export const ${symbolName}`,
`export function ${symbolName}`,
`export type ${symbolName}`,
`export enum ${symbolName}`,
`export { ${symbolName}`,
`export default ${symbolName}`,
];
for (const pattern of patterns) {
if (content.includes(pattern)) {
return file;
}
}
}
} catch (error) {
logger.debug(`Error searching for symbol ${symbolName}: ${error}`);
}
return null;
}
}