Skip to content

Instantly share code, notes, and snippets.

@Gerrit0
Last active November 15, 2019 00:19
Show Gist options
  • Save Gerrit0/3674e0a86e5e8ddf0d8aed1964766dcb to your computer and use it in GitHub Desktop.
Save Gerrit0/3674e0a86e5e8ddf0d8aed1964766dcb to your computer and use it in GitHub Desktop.
TypeDoc restructure notes

This is a first attempt at a high level restructure of TypeDoc. There are almost certainly issues with the proposed design that I'm missing or haven't resolved yet. Some of these include

  1. How should users modify the JSON output? #930
  2. A lot of events are used by TypeDoc's current design, this offers a lot of flexibility for plugins to attach to an event and modify an object at any part of its lifecycle, but makes debugging painful since they are also heavily used internally. Which events should be retained, and which should be removed?
  3. Does it make sense for readers to declare their options in the constructor? It is nice to tie the code that uses the option and the code that declares it together, but this could cause problems when using the api programatically.
  4. Should some readers exist by default? (TsConfig, TypeDoc)

Goals in this first pass:

  1. Define the primary interfaces required to write a CLI
  2. Remove the Component based architecture & inheritence nightmare that resulted from it.
import * as ts from 'typescript';
// Will be same / similar to current implementation
type ProjectReflection = { projectReflection: true };
export interface ConverterResult {
/**
* An array containing all errors generated by the TypeScript compiler.
*/
errors: ReadonlyArray<ts.Diagnostic>;
/**
* The resulting project reflection.
*/
project: ProjectReflection;
}
type Logger = {
info(message: string): void;
write(message: string): void;
error(message: string | Error | ts.Diagnostic): void
};
// Same as current interfaces for now.
interface NodeConverter {}
interface TypeConverter {}
// New / significantly changed
// Plugins do `export function load(app)` with this signature.
type PluginLoadFunction = (app: Application) => void;
// Can be declaration-merged by plugins to add more options
// Supported types: string, string[], number, boolean
interface OptionValueMap {
project: string;
options: string;
plugins: string[];
help: boolean;
files: string[];
// more...
}
enum OptionGroups {
input,
output,
config,
other
}
interface OptionDeclaration<K extends keyof OptionValueMap = keyof OptionValueMap> {
name: K;
default: OptionValueMap[K];
help: string;
group?: OptionGroups;
}
type OptionReadError = string // Probably should be something with more info
interface OptionReader {
read(declarations: OptionDeclaration[]): { options: Partial<OptionValueMap>, errors: OptionReadError[] };
}
class Options {
private optionMap: Partial<OptionValueMap> = {};
private options: OptionDeclaration[] = []
private readers: OptionReader[] = []
addReader(reader: OptionReader): void {
if (!this.readers.includes(reader)) {
this.readers.push(reader);
}
}
removeReader(reader: OptionReader): void {
const index = this.readers.indexOf(reader);
if (index !== -1) {
this.readers.splice(index, 1);
}
}
read(): OptionReadError[] {
const readErrors: OptionReadError[] = []
for (const reader of this.readers) {
const { options, errors } = reader.read(this.options);
readErrors.push(...errors);
this.optionMap = { ...this.optionMap, ...options };
}
return readErrors;
}
declareOption(option: OptionDeclaration): void {
const declaration = this.options.find(d => d.name === option.name);
if (declaration) {
throw new Error(`An option with name '${name}' already exists.`);
}
this.options.push(option);
}
getOption<K extends keyof OptionValueMap>(option: K): OptionValueMap[K] {
const declaration = this.options.find(d => d.name === option);
if (!declaration) {
throw new Error(`The option '${option}' does not exist.`);
}
return option in this.optionMap ? this.optionMap[option] : declaration.default;
}
setOption<K extends keyof OptionValueMap>(option: K, value: OptionValueMap[K]): void {
this.optionMap[option] = value;
}
getHelpOutput(): string {
return [
'Usage: typedoc --out ./docs ./src',
'',
'Input',
...this.printGroup(OptionGroups.input),
'',
'Output',
...this.printGroup(OptionGroups.output),
'',
'Config',
...this.printGroup(OptionGroups.config),
'',
'Other',
...this.printGroup(OptionGroups.other)
].join('\n')
}
private printGroup(group: OptionGroups): string[] {
return this.options
.filter(opt => opt.group === group || group === OptionGroups.other)
.map(({ name, help }) => ` --${name} ${help}`);
}
}
type GeneratorConstructor = new (app: Application) => OutputGenerator
interface OutputGenerator {
/**
* Returns true if [[generate]] should be called.
*/
enabled(): boolean;
generate(project: ProjectReflection): void;
}
class Output {
private generators: OutputGenerator[] = [];
addGenerator(generator: OutputGenerator): void {
if (!this.generators.includes(generator)) {
this.generators.push(generator);
}
}
removeGenerator(generator: OutputGenerator): void {
const index = this.generators.indexOf(generator);
if (index !== -1) {
this.generators.splice(index, 1);
}
}
/**
* To keep previous behavior, erroring if no output is produced.
*/
willCreateOutput(): boolean {
return this.generators.some(gen => gen.enabled());
}
generate(project: ProjectReflection) {
for (const gen of this.generators.filter(gen => gen.enabled())) {
gen.generate(project);
}
}
}
interface Converter {
addNodeConverter(converter: NodeConverter): void;
removeNodeConverter(converter: NodeConverter): void;
addTypeConverter(converter: TypeConverter): void;
removeTypeConverter(converter: NodeConverter): void;
convert(files: string[], options?: ts.CreateProgramOptions): ConverterResult;
}
class Application {
readonly options = new Options();
readonly output = new Output();
readonly converter: Converter;
logger: Logger;
constructor() {
this.options.declareOption({
name: 'plugins',
help: 'Specify the plugins to load.',
default: []
});
this.options.declareOption({
name: 'files',
help: 'Specify the input files',
default: [],
group: OptionGroups.input
});
}
/**
* Loads the plugins currently specified by the plugins option.
*/
loadPlugins() {
for (const plugin of this.options.getOption('plugins')) {
// Basically what the PluginHost does now.
try {
const instance = require(plugin);
instance.load(this);
this.logger.info(`Loaded plugin ${plugin}`);
} catch (error) {
this.logger.error(`The plugin ${plugin} could not be loaded.`);
this.logger.error(error);
}
}
}
convert(options?: ts.CompilerOptions): ConverterResult {
return this.converter.convert(this.options.getOption('files'));
}
generateOutput(project: ProjectReflection) {
this.output.generate(project);
}
}
// Read cli arguments
class ArgvReader implements OptionReader {
read(declarations: OptionDeclaration[]): { options: Partial<OptionValueMap>; errors: string[]; } {
return { // Todo, pass off to yargs or similar?
options: {},
errors: []
}
}
}
// Read tsconfig.json, pass off to typescript?
class TsConfigReader extends ArgvReader {
constructor(options: Options) {
super();
options.declareOption({
name: 'project',
help: 'The tsconfig.json file to read for options',
default: 'tsconfig.json',
group: OptionGroups.config
});
}
};
// Read typedoc.js / typedoc.json
class TypeDocReader extends ArgvReader {
constructor(options: Options) {
super();
options.declareOption({
name: 'options',
help: 'Specify a js or json option file that should be loaded.',
default: 'typedoc.js',
group: OptionGroups.config
});
}
};
function runCli(): never {
const app = new Application();
const argvReader = new ArgvReader();
// First, so tsconfig reader and typedoc readers take --project, --config from argv
app.options.addReader(argvReader);
app.options.addReader(new TsConfigReader(app.options));
app.options.addReader(new TypeDocReader(app.options));
// Not included yet.
// app.output.addGenerator(new HtmlGenerator(app));
// app.output.addGenerator(new JsonGenerator(app));
app.options.read(); // Ignore errors until we have loaded plugins.
app.loadPlugins();
// Put argv reader last as it should override tsconfig/typedoc options
app.options.removeReader(argvReader);
app.options.addReader(argvReader);
// Re-read options, report errors this time
const optionErrors = app.options.read();
optionErrors.forEach(err => app.logger.error(err));
if (app.options.getOption('help')) {
app.logger.write(app.options.getHelpOutput());
return process.exit(0);
}
if (!app.output.willCreateOutput()) {
app.logger.error('No output mode was specified.');
return process.exit(1);
}
const { errors, project } = app.convert();
if (errors.length) {
errors.forEach(err => app.logger.error(err));
return process.exit(2);
}
app.generateOutput(project);
return process.exit(0);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment