From 8c707b3d95cffc4fccff6e9b347b71306bdb0889 Mon Sep 17 00:00:00 2001 From: Gerrit Birkeland Date: Sat, 6 Nov 2021 18:06:08 -0600 Subject: [PATCH] Add support for hooks within templates Resolves #1773. --- CHANGELOG.md | 5 + internal-docs/custom-themes.md | 38 +++++- src/index.ts | 3 +- src/lib/output/index.ts | 1 + src/lib/output/renderer.ts | 46 ++++++- .../default/DefaultThemeRenderContext.ts | 4 + .../output/themes/default/layouts/default.tsx | 14 +- src/lib/utils/array.ts | 17 +++ src/lib/utils/hooks.ts | 128 ++++++++++++++++++ src/lib/utils/index.ts | 2 + src/lib/utils/options/options.ts | 2 + src/test/utils/array.test.ts | 37 +++++ src/test/utils/hooks.test.ts | 115 ++++++++++++++++ 13 files changed, 396 insertions(+), 16 deletions(-) create mode 100644 src/lib/utils/hooks.ts create mode 100644 src/test/utils/hooks.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dacb1bce..50fbfd7b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Unreleased +### Features + +- Added hooks which can be used to inject HTML without completely replacing a template, #1773. + See the documentation in [custom-themes.md](https://github.com/TypeStrong/typedoc/blob/master/internal-docs/custom-themes.md) for details. + ### Bug Fixes - Actually fixed `@category` tag incorrectly appearing on function types if used on a type alias, #1745. diff --git a/internal-docs/custom-themes.md b/internal-docs/custom-themes.md index cae416c12..ab5bb545f 100644 --- a/internal-docs/custom-themes.md +++ b/internal-docs/custom-themes.md @@ -30,14 +30,18 @@ class MyThemeContext extends DefaultThemeRenderContext { const site = this.options.getValue("gaSite"); + const script = ` +(function() { + var _owa = document.createElement('script'); _owa.type = 'text/javascript'; + _owa.async = true; _owa.src = '${site}' + '/modules/base/js/owa.tracker-combined-min.js'; + var _owa_s = document.getElementsByTagName('script')[0]; _owa_s.parentNode.insertBefore(_owa, + _owa_s); +}()); +`.trim(); + return ( ); }; @@ -59,10 +63,30 @@ export function load(app: Application) { } ``` +## Hooks (v0.22.8+) + +When rendering themes, TypeDoc's default theme will call several functions to allow plugins to inject HTML +into a page without completely overwriting a theme. Hooks live on the parent `Renderer` and may be called +by child themes which overwrite a helper with a custom implementation. As an example, the following plugin +will cause a popup on every page when loaded. + +```tsx +import { Application, JSX } from "typedoc"; +export function load(app: Application) { + app.renderer.hooks.on("head.end", () => ( + + )); +} +``` + +For documentation on the available hooks, see the [RendererHooks](https://typedoc.org/api/interfaces/RendererHooks.html) +documentation on the website. + ## Future Work The following is not currently supported by TypeDoc, but is planned on being included in a future version. -- Support for injecting HTML without completely overwriting a template. - Support for pre-render and post-render async actions for copying files, preparing the output directory, etc. In the meantime, listen to `RendererEvent.BEGIN` or `RendererEvent.END` and perform processing there. diff --git a/src/index.ts b/src/index.ts index 0ef74aeec..fb6dc1db4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ export { RendererEvent, MarkdownEvent, } from "./lib/output"; -export type { RenderTemplate } from "./lib/output"; +export type { RenderTemplate, RendererHooks } from "./lib/output"; export { ArgumentsReader, @@ -30,6 +30,7 @@ export { TSConfigReader, TypeDocReader, EntryPointStrategy, + EventHooks, } from "./lib/utils"; export type { diff --git a/src/lib/output/index.ts b/src/lib/output/index.ts index 4af737b53..a3ebcad44 100644 --- a/src/lib/output/index.ts +++ b/src/lib/output/index.ts @@ -2,6 +2,7 @@ export { PageEvent, RendererEvent, MarkdownEvent } from "./events"; export { UrlMapping } from "./models/UrlMapping"; export type { RenderTemplate } from "./models/UrlMapping"; export { Renderer } from "./renderer"; +export type { RendererHooks } from "./renderer"; export { Theme } from "./theme"; export { DefaultTheme } from "./themes/default/DefaultTheme"; export { DefaultThemeRenderContext } from "./themes/default/DefaultThemeRenderContext"; diff --git a/src/lib/output/renderer.ts b/src/lib/output/renderer.ts index c29bfba61..6235dfc09 100644 --- a/src/lib/output/renderer.ts +++ b/src/lib/output/renderer.ts @@ -19,10 +19,38 @@ import { remove, writeFileSync } from "../utils/fs"; import { DefaultTheme } from "./themes/default/DefaultTheme"; import { RendererComponent } from "./components"; import { Component, ChildableComponent } from "../utils/component"; -import { BindOption } from "../utils"; +import { BindOption, EventHooks } from "../utils"; import { loadHighlighter } from "../utils/highlighter"; import type { Theme as ShikiTheme } from "shiki"; import { Reflection } from "../models"; +import type { JsxElement } from "../utils/jsx.elements"; +import type { DefaultThemeRenderContext } from "./themes/default/DefaultThemeRenderContext"; + +/** + * Describes the hooks available to inject output in the default theme. + * If the available hooks don't let you put something where you'd like, please open an issue! + */ +export interface RendererHooks { + /** + * Applied immediately after the opening `` tag. + */ + "head.begin": [DefaultThemeRenderContext]; + + /** + * Applied immediately before the closing `` tag. + */ + "head.end": [DefaultThemeRenderContext]; + + /** + * Applied immediately after the opening `` tag. + */ + "body.begin": [DefaultThemeRenderContext]; + + /** + * Applied immediately before the closing `` tag. + */ + "body.end": [DefaultThemeRenderContext]; +} /** * The renderer processes a {@link ProjectReflection} using a {@link Theme} instance and writes @@ -80,6 +108,16 @@ export class Renderer extends ChildableComponent< */ theme?: Theme; + /** + * Hooks which will be called when rendering pages. + * Note: + * - Hooks added during output will be discarded at the end of rendering. + * - Hooks added during a page render will be discarded at the end of that page's render. + * + * See {@link RendererHooks} for a description of each available hook, and when it will be called. + */ + hooks = new EventHooks(); + /** @internal */ @BindOption("theme") themeName!: string; @@ -195,6 +233,7 @@ export class Renderer extends ChildableComponent< project: ProjectReflection, outputDirectory: string ): Promise { + const momento = this.hooks.saveMomento(); const start = Date.now(); await loadHighlighter(this.lightTheme, this.darkTheme); this.application.logger.verbose( @@ -225,6 +264,7 @@ export class Renderer extends ChildableComponent< } this.theme = void 0; + this.hooks.restoreMomento(momento); } /** @@ -234,8 +274,10 @@ export class Renderer extends ChildableComponent< * @return TRUE if the page has been saved to disc, otherwise FALSE. */ private renderDocument(page: PageEvent): boolean { + const momento = this.hooks.saveMomento(); this.trigger(PageEvent.BEGIN, page); if (page.isDefaultPrevented) { + this.hooks.restoreMomento(momento); return false; } @@ -246,6 +288,8 @@ export class Renderer extends ChildableComponent< } this.trigger(PageEvent.END, page); + this.hooks.restoreMomento(momento); + if (page.isDefaultPrevented) { return false; } diff --git a/src/lib/output/themes/default/DefaultThemeRenderContext.ts b/src/lib/output/themes/default/DefaultThemeRenderContext.ts index 1669efd5a..e600006ff 100644 --- a/src/lib/output/themes/default/DefaultThemeRenderContext.ts +++ b/src/lib/output/themes/default/DefaultThemeRenderContext.ts @@ -1,4 +1,5 @@ import type * as ts from "typescript"; +import type { RendererHooks } from "../.."; import type { Reflection } from "../../../models"; import type { Options } from "../../../utils"; import type { DefaultTheme } from "./DefaultTheme"; @@ -39,6 +40,9 @@ export class DefaultThemeRenderContext { this.options = options; } + hook = (name: keyof RendererHooks) => + this.theme.owner.hooks.emit(name, this); + /** Avoid this in favor of urlTo if possible */ relativeURL = (url: string | undefined) => { return url ? this.theme.markedPlugin.getRelativeUrl(url) : url; diff --git a/src/lib/output/themes/default/layouts/default.tsx b/src/lib/output/themes/default/layouts/default.tsx index 4e12bdf9a..84450cc59 100644 --- a/src/lib/output/themes/default/layouts/default.tsx +++ b/src/lib/output/themes/default/layouts/default.tsx @@ -6,16 +6,13 @@ import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; export const defaultLayout = (context: DefaultThemeRenderContext, props: PageEvent) => ( + {context.hook("head.begin")} - {props.model.name === props.project.name ? ( - props.project.name - ) : ( - <> - {props.model.name} | {props.project.name} - </> - )} + {props.model.name === props.project.name + ? props.project.name + : `${props.model.name} | ${props.project.name}`} @@ -26,8 +23,10 @@ export const defaultLayout = (context: DefaultThemeRenderContext, props: PageEve )} + {context.hook("head.end")} + {context.hook("body.begin")} @@ -46,6 +45,7 @@ export const defaultLayout = (context: DefaultThemeRenderContext, props: PageEve {context.analytics()} + {context.hook("body.end")} ); diff --git a/src/lib/utils/array.ts b/src/lib/utils/array.ts index 6a0f1102a..d95ceefdc 100644 --- a/src/lib/utils/array.ts +++ b/src/lib/utils/array.ts @@ -3,6 +3,7 @@ * the item will be inserted later will be placed earlier in the array. * @param arr modified by inserting item. * @param item + * @deprecated this is confusing, it sorts with lower priority being placed earlier. Prefer insertOrderSorted, which is nearly the same. */ export function insertPrioritySorted( arr: T[], @@ -13,6 +14,22 @@ export function insertPrioritySorted( return arr; } +/** + * Inserts an item into an array sorted by order. If two items have the same order, + * the item inserted later will be placed later in the array. + * The array will be sorted with lower order being placed sooner. + * @param arr modified by inserting item. + * @param item + */ +export function insertOrderSorted( + arr: T[], + item: T +): T[] { + const index = binaryFindPartition(arr, (v) => v.order > item.order); + arr.splice(index === -1 ? arr.length : index, 0, item); + return arr; +} + /** * Performs a binary search of a given array, returning the index of the first item * for which `partition` returns true. Returns the -1 if there are no items in `arr` diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts new file mode 100644 index 000000000..042172ed0 --- /dev/null +++ b/src/lib/utils/hooks.ts @@ -0,0 +1,128 @@ +import { insertOrderSorted } from "./array"; + +const momentos = new WeakMap< + EventHooksMomento, + Map +>(); + +type EventHooksMomento, _R> = { + __eventHooksMomentoBrand: never; +}; + +/** + * Event emitter which allows listeners to return a value. + * + * This is beneficial for the themes since it allows plugins to modify the HTML output + * without doing unsafe text replacement. + * + * This class is functionally nearly identical to the {@link EventEmitter} class with + * two exceptions. + * 1. The {@link EventEmitter} class only `await`s return values from its listeners, it + * does not return them to the emitter. + * 2. This class requires listeners to by synchronous, unless `R` is specified as to be + * a promise or other deferred type. + * + * @example + * ```ts + * const x = new EventHooks<{ a: [string] }, string>() + * x.on('a', a => a.repeat(123)) // ok, returns a string + * x.on('b', console.log) // error, 'b' is not assignable to 'a' + * x.on('a' a => 1) // error, returns a number but expected a string + * ``` + */ +export class EventHooks, R> { + // Function is *usually* not a good type to use, but here it lets us specify stricter + // contracts in the methods while not casting everywhere this is used. + private _listeners = new Map< + keyof T, + { listener: Function; once?: boolean; order: number }[] + >(); + + /** + * Starts listening to an event. + * @param event the event to listen to. + * @param listener function to be called when an this event is emitted. + * @param order optional order to insert this hook with. + */ + on( + event: K, + listener: (...args: T[K]) => R, + order = 0 + ): void { + const list = (this._listeners.get(event) || []).slice(); + insertOrderSorted(list, { listener, order }); + this._listeners.set(event, list); + } + + /** + * Listens to a single occurrence of an event. + * @param event the event to listen to. + * @param listener function to be called when an this event is emitted. + * @param order optional order to insert this hook with. + */ + once( + event: K, + listener: (...args: T[K]) => R, + order = 0 + ): void { + const list = (this._listeners.get(event) || []).slice(); + insertOrderSorted(list, { listener, once: true, order }); + this._listeners.set(event, list); + } + + /** + * Stops listening to an event. + * @param event the event to stop listening to. + * @param listener the function to remove from the listener array. + */ + off(event: K, listener: (...args: T[K]) => R): void { + const listeners = this._listeners.get(event); + if (listeners) { + const index = listeners.findIndex((lo) => lo.listener === listener); + if (index > -1) { + listeners.splice(index, 1); + } + } + } + + /** + * Emits an event to all currently subscribed listeners. + * @param event the event to emit. + * @param args any arguments required for the event. + */ + emit(event: K, ...args: T[K]): R[] { + const listeners = this._listeners.get(event)?.slice() || []; + this._listeners.set( + event, + listeners.filter(({ once }) => !once) + ); + return listeners.map(({ listener }) => listener(...args)); + } + + saveMomento(): EventHooksMomento { + const momento = {} as EventHooksMomento; + const save = new Map< + keyof T, + { listener: Function; once?: boolean; order: number }[] + >(); + + for (const [key, val] of this._listeners) { + save.set(key, [...val]); + } + + momentos.set(momento, save); + return momento; + } + + restoreMomento(momento: EventHooksMomento): void { + const saved = momentos.get(momento); + if (saved) { + this._listeners.clear(); + for (const [key, val] of saved) { + this._listeners.set(key, [...val]); + } + } else { + throw new Error("Momento not found."); + } + } +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 0788f2ea3..edfdb9284 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -52,6 +52,8 @@ export { discoverNpmPlugins, loadPlugins } from "./plugins"; export { sortReflections } from "./sort"; export type { SortStrategy } from "./sort"; +export { EventHooks } from "./hooks"; + export * from "./entry-point"; import * as JSX from "./jsx"; diff --git a/src/lib/utils/options/options.ts b/src/lib/utils/options/options.ts index 08103a2c7..61b18a2d6 100644 --- a/src/lib/utils/options/options.ts +++ b/src/lib/utils/options/options.ts @@ -28,6 +28,8 @@ export interface OptionsReader { * Note that to preserve expected behavior, the argv reader must have both the lowest * priority so that it may set the location of config files used by other readers and * the highest priority so that it can override settings from lower priority readers. + * + * Note: In 0.23. `priority` will be renamed to `order`, with the same meaning */ priority: number; diff --git a/src/test/utils/array.test.ts b/src/test/utils/array.test.ts index 51fb71866..c60b556fe 100644 --- a/src/test/utils/array.test.ts +++ b/src/test/utils/array.test.ts @@ -1,6 +1,7 @@ import { deepStrictEqual as equal, doesNotThrow } from "assert"; import { binaryFindPartition, + insertOrderSorted, insertPrioritySorted, removeIfPresent, } from "../../lib/utils/array"; @@ -42,6 +43,42 @@ describe("Array utils", () => { }); }); + describe("insertOrderSorted", () => { + const item1 = { order: 1 }; + const item2 = { order: 2 }; + const item3 = { order: 3 }; + const item4 = { order: 4 }; + + it("works with an empty array", () => { + equal(insertOrderSorted([], item1), [item1]); + }); + + it("inserts at the start", () => { + equal(insertOrderSorted([item2], item1), [item1, item2]); + }); + + it("inserts in the middle", () => { + equal(insertOrderSorted([item1, item3], item2), [ + item1, + item2, + item3, + ]); + }); + + it("inserts at the end", () => { + equal(insertOrderSorted([item2, item3], item4), [ + item2, + item3, + item4, + ]); + }); + + it("inserts new items last", () => { + const item0 = { order: 1, last: true }; + equal(insertOrderSorted([item1], item0), [item1, item0]); + }); + }); + describe("binaryFindPartition", () => { const always = () => true; diff --git a/src/test/utils/hooks.test.ts b/src/test/utils/hooks.test.ts new file mode 100644 index 000000000..180b44624 --- /dev/null +++ b/src/test/utils/hooks.test.ts @@ -0,0 +1,115 @@ +import { deepStrictEqual as equal } from "assert"; +import { EventHooks } from "../../lib/utils/hooks"; + +describe("EventHooks", () => { + it("Works in simple cases", () => { + const emitter = new EventHooks<{ a: [] }, void>(); + + let calls = 0; + emitter.on("a", () => { + calls++; + }); + equal(calls, 0); + + emitter.emit("a"); + equal(calls, 1); + emitter.emit("a"); + equal(calls, 2); + }); + + it("Works with once", () => { + const emitter = new EventHooks<{ a: [] }, void>(); + + let calls = 0; + emitter.once("a", () => { + calls++; + }); + equal(calls, 0); + + emitter.emit("a"); + equal(calls, 1); + emitter.emit("a"); + equal(calls, 1); + }); + + it("Allows removing listeners", () => { + const emitter = new EventHooks<{ a: [] }, void>(); + + let calls = 0; + const listener = () => { + calls++; + }; + emitter.once("a", listener); + emitter.off("a", listener); + equal(calls, 0); + + emitter.emit("a"); + equal(calls, 0); + }); + + it("Works correctly with missing listeners", () => { + const emitter = new EventHooks<{ a: [] }, void>(); + + let calls = 0; + const listener = () => { + calls++; + }; + emitter.on("a", () => { + calls++; + }); + emitter.off("a", listener); + + emitter.emit("a"); + equal(calls, 1); + }); + + it("Works if a listener is removed while emitting", () => { + const emitter = new EventHooks<{ a: [] }, void>(); + + let calls = 0; + emitter.on("a", function rem() { + calls++; + emitter.off("a", rem); + }); + emitter.on("a", () => { + calls++; + }); + equal(calls, 0); + + emitter.emit("a"); + equal(calls, 2); + emitter.emit("a"); + equal(calls, 3); + }); + + it("Collects the results of listeners", () => { + const emitter = new EventHooks<{ a: [] }, number>(); + + emitter.on("a", () => 1); + emitter.on("a", () => 2); + + equal(emitter.emit("a"), [1, 2]); + }); + + it("Calls listeners according to their order", () => { + const emitter = new EventHooks<{ a: [] }, number>(); + + emitter.on("a", () => 1, 100); + emitter.on("a", () => 2); + + equal(emitter.emit("a"), [2, 1]); + }); + + it("Has a working momento mechanism", () => { + const emitter = new EventHooks<{ a: [] }, number>(); + + emitter.on("a", () => 1); + const momento = emitter.saveMomento(); + emitter.on("a", () => 2); + + equal(emitter.emit("a"), [1, 2]); + + emitter.restoreMomento(momento); + equal(emitter.emit("a"), [1]); + }); +});