Description
TypeDoc was originally built with the idea of HTML rendering, without consideration for other output types. Later on, TypeDoc gained support for rendering a project to JSON, and later still rendering to markdown (via typedoc-plugin-markdown). It would be neat if TypeDoc could be easily extended via plugins to render documentation in other formats (e.g. LaTeX -> PDF) without hacks.
Today, the markdown plugin hijacks the renderer to support markdown, which prevents users with the plugin activated from producing both HTML and markdown, but has the benefit of letting users just add the plugin, and suddenly get markdown where their HTML was previously being generated. Furthermore, despite markdown rendering not using it, this method still requires that users of it pay the 250+ms price of loading syntax highlighting.
The way it seems that this ought to work is that TypeDoc's renderer shouldn't care about HTML/JSON/Markdown/Tex/whatever, but instead deal with an interface which applies to all of them.
For a first cut at this, I propose:
export interface OutputOptions {
type: string; // defined output type, html, json, etc.
path: string;
}
export class Renderer extends EventDispatcher<RendererEvents> {
/**
* Define a new output that can be used to render a project to disc.
*/
defineOutput(
name: string,
output: new (app: Application) => Output<MinimalDocument, {}>,
): void;
/**
* Render the given project reflection to all user configured outputs.
*/
async writeOutputs(project: ProjectReflection): Promise<void>;
/**
* Render the given project with the provided output options.
*/
async writeOutput(
project: ProjectReflection,
output: OutputOptions,
): Promise<void>;
}
export interface MinimalDocument {
/** Path relative to the user specified output directory */
filename: string;
}
/**
* Base class of all output types.
*
* 0-N outputs may be enabled by the user. When enabled, the {@link Renderer} will construct
* and instance of the requested class and use it to write a project to disc. The output class
* will then be deleted; in watch mode, this means the class may be constructed many times.
*
* The renderer will first call {@link Output.getDocuments} which will be used to list the files
* to be written. Each document returned will be passed to the {@link Output.render} function
* to render to a string which will be written to disc.
*
* The {@link Output.render} function is responsible for turning a document into a string which
* will be written to disc.
*/
export abstract class Output<
TDocument extends MinimalDocument,
TEvents extends Record<keyof TEvents, unknown[]> = {},
> extends EventDispatcher<TEvents> {
/**
* Will be called once before any calls to {@link render}.
*/
async setup(_app: Application): Promise<void> {}
/**
* Will be called once after all calls to {@link render}.
*/
async teardown(_app: Application): Promise<void> {}
/**
* Called once after {@link setup} to get the documents which should be passed to {@link render}.
* The filenames of all returned documents should be
*/
abstract getDocuments(project: ProjectReflection): TDocument[];
/**
* Renders the provided page to a string, which will be written to disk by the {@link Renderer}
* This will be called for each document returned by {@link getDocuments}.
*/
abstract render(
document: TDocument,
): string | Buffer | Promise<string | Buffer>;
}
TypeDoc's CLI will move from calling app.generateJson
/ app.generateDocs
to calling app.renderer.writeOutputs
.
The --out
option will be replaced with a --html
option. The --html
and --json
options will be added as "output shortcuts" for CLI convenience:
options.addDeclaration({
name: "json",
help: "Specify the location and filename a JSON file describing the project is written to.",
type: ParameterType.Path,
hint: ParameterHint.File,
});
options.addOutputShortcut("json", (path) => ({ type: "json", path }));
But users desiring more flexibility can use the new outputs
option, even to render multiple times to different paths!
{
"outputs": [
{
"type": "html",
"path": "../docs"
},
{
"type": "json",
"path": "../docs/docs.json"
}
]
}
The good:
- What rendering means is completely hidden from the root application now
- TypeDoc can be easily extended with
- It's now completely possible to render with many different types of output at once
The bad:
- Plugins which want to add some content to the rendered HTML page now have to do a bit of a dance to add the hook at the right point.
export function load(app: Application) { app.renderer.on(Renderer.EVENT_BEGIN, event => { if (event.output instanceof HtmlOutput) { event.output.hooks.on("head.begin", () => <script>alert(1)</script>); } }); } // vs export function load(app: Application) { app.renderer.hooks.on("head.begin", () => <script>alert(1)</script>); }
- Themes are put into a weird place with this. Does
OutputOptions
need to have an optionaltheme
key too? That'd be unfortunate if so, as some outputs (e.g. JSON) likely won't ever have themes. Is each theme a new output type? I guess that works, a minimal html theme could define--htmlMinimal
as an output shortcut... it's kind of weird that minor html theme tweaks require a brand new output, but implementing it is still just extending two classes, so... - JSON output is still special, because it can be deserialized back into a model, I think this is okay, just slightly weird.
- It breaks all the existing plugins which touch rendering (eh, the markdown plugin is the only one that's really heavily used)
I did some prototyping of this nine (nine?! what? how?!) months ago over on the output-rework branch. Now that 0.26 is about to release, I'm looking at it again now for 0.27, which I plan on being a smaller release, hopefully mostly focused on some rendering enhancements and this. I'll probably look at getting that rebased on master next weekend.
Ref: #2288 (comment)