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]);
+ });
+});