Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Decouple Renderer class from HTML output #2597

Closed
Gerrit0 opened this issue Jun 17, 2024 · 2 comments
Closed

Decouple Renderer class from HTML output #2597

Gerrit0 opened this issue Jun 17, 2024 · 2 comments
Milestone

Comments

@Gerrit0
Copy link
Collaborator

Gerrit0 commented Jun 17, 2024

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 optional theme 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)

@manticorp
Copy link

manticorp commented Oct 23, 2024

This sounds awesome, and a great way of implementing it.

Personally, I would have themes as part of the output options for flexibility. There might be other options relating to specific output types as well.

All options in each output could just be passed to the renderer.

That way you could have something like:

{
    "outputs": [
        {
            "type": "HTML",
            "theme": "pastel",
            "path": "../docs/html"
        },
        {
            "type": "markdown",
            "theme": "github",
            "path": "../githubpages"
        },
        {
            "type": "markdown",
            "theme": "npm",
            "path": "../docs"
        },
        {
            "type": "someplugin/renderer",
            "theme": "fire",
            "path": "../firedocs"
        },
        {
            "type": "json",
            "spaces": 2,
            "path": "../docs/docs.json"
        }
    ]
}

The alternative would be having multiple typedoc config files, which I guess is also acceptable, but not very DRY

Gerrit0 added a commit that referenced this issue Nov 1, 2024
@Gerrit0
Copy link
Collaborator Author

Gerrit0 commented Nov 1, 2024

I ended up deciding that just theme was too limiting and allowing an options property which will accept any options that TypeDoc can use.

This isn't perfect as it means that TypeDoc will allow setting options there which will have no effect (options which are handled during conversion) but the additional flexibility is worth it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants