diff --git a/CHANGELOG.md b/CHANGELOG.md
index aaf4a5418..3ae93df9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
### Bug Fixes
- Constructed references to enum types will be properly linked with `@interface`, #2508.
+- Reduced rendered docs size by writing icons to a referenced SVG asset, #2505.
+ For TypeDoc's docs, this reduced the rendered documentation size by ~30%.
+- The HTML docs now attempt to reduce repaints caused by dynamically loading the navigation, #2491.
+- When navigating to a link that contains an anchor, the page will now be properly highlighted in the page navigation.
## v0.25.9 (2024-02-26)
diff --git a/src/lib/output/plugins/IconsPlugin.tsx b/src/lib/output/plugins/IconsPlugin.tsx
new file mode 100644
index 000000000..93fe66674
--- /dev/null
+++ b/src/lib/output/plugins/IconsPlugin.tsx
@@ -0,0 +1,60 @@
+import { Component, RendererComponent } from "../components";
+import { RendererEvent } from "../events";
+import { writeFile } from "../../utils/fs";
+import { DefaultTheme } from "../themes/default/DefaultTheme";
+import { join } from "path";
+import { JSX, renderElement } from "../../utils";
+
+/**
+ * Plugin which is responsible for creating an icons.js file that embeds the icon SVGs
+ * within the page on page load to reduce page sizes.
+ */
+@Component({ name: "icons" })
+export class IconsPlugin extends RendererComponent {
+ iconHtml?: string;
+
+ override initialize() {
+ this.listenTo(this.owner, {
+ [RendererEvent.BEGIN]: this.onBeginRender,
+ });
+ }
+
+ private onBeginRender(_event: RendererEvent) {
+ if (this.owner.theme instanceof DefaultTheme) {
+ this.owner.postRenderAsyncJobs.push((event) => this.onRenderEnd(event));
+ }
+ }
+
+ private async onRenderEnd(event: RendererEvent) {
+ const children: JSX.Element[] = [];
+ const icons = (this.owner.theme as DefaultTheme).icons;
+
+ for (const [name, icon] of Object.entries(icons)) {
+ children.push({icon.call(icons).children});
+ }
+
+ const svg = renderElement();
+ const js = [
+ "(function(svg) {",
+ " svg.innerHTML = `" + renderElement(<>{children}>).replaceAll("`", "\\`") + "`;",
+ " svg.style.display = 'none';",
+ " if (location.protocol === 'file:') {",
+ " if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', updateUseElements);",
+ " else updateUseElements()",
+ " function updateUseElements() {",
+ " document.querySelectorAll('use').forEach(el => {",
+ " if (el.getAttribute('href').includes('#icon-')) {",
+ " el.setAttribute('href', el.getAttribute('href').replace(/.*#/, '#'));",
+ " }",
+ " });",
+ " }",
+ " }",
+ "})(document.body.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg')))",
+ ].join("\n");
+
+ const svgPath = join(event.outputDirectory, "assets/icons.svg");
+ const jsPath = join(event.outputDirectory, "assets/icons.js");
+
+ await Promise.all([writeFile(svgPath, svg), writeFile(jsPath, js)]);
+ }
+}
diff --git a/src/lib/output/plugins/index.ts b/src/lib/output/plugins/index.ts
index 62a995442..b2435aaaa 100644
--- a/src/lib/output/plugins/index.ts
+++ b/src/lib/output/plugins/index.ts
@@ -1,5 +1,6 @@
export { MarkedPlugin } from "../themes/MarkedPlugin";
export { AssetsPlugin } from "./AssetsPlugin";
+export { IconsPlugin } from "./IconsPlugin";
export { JavascriptIndexPlugin } from "./JavascriptIndexPlugin";
export { NavigationPlugin } from "./NavigationPlugin";
export { SitemapPlugin } from "./SitemapPlugin";
diff --git a/src/lib/output/themes/default/DefaultTheme.tsx b/src/lib/output/themes/default/DefaultTheme.tsx
index 192ae6a13..972fa19a5 100644
--- a/src/lib/output/themes/default/DefaultTheme.tsx
+++ b/src/lib/output/themes/default/DefaultTheme.tsx
@@ -17,6 +17,7 @@ import type { MarkedPlugin } from "../../plugins";
import { DefaultThemeRenderContext } from "./DefaultThemeRenderContext";
import { JSX } from "../../../utils";
import { classNames, getDisplayName, getHierarchyRoots, toStyleClass } from "../lib";
+import { icons } from "./partials/icon";
/**
* Defines a mapping of a {@link Models.Kind} to a template file.
@@ -56,6 +57,21 @@ export class DefaultTheme extends Theme {
/** @internal */
markedPlugin: MarkedPlugin;
+ /**
+ * The icons which will actually be rendered. The source of truth lives on the theme, and
+ * the {@link DefaultThemeRenderContext.icons} member will produce references to these.
+ *
+ * These icons will be written twice. Once to an `icons.svg` file in the assets directory
+ * which will be referenced by icons on the context, and once to an `icons.js` file so that
+ * references to the icons can be dynamically embedded within the page for use by the search
+ * dropdown and when loading the page on `file://` urls.
+ *
+ * Custom themes may overwrite this entire object or individual properties on it to customize
+ * the icons used within the page, however TypeDoc currently assumes that all icons are svg
+ * elements, so custom themes must also use svg elements.
+ */
+ icons = { ...icons };
+
getRenderContext(pageEvent: PageEvent) {
return new DefaultThemeRenderContext(this, pageEvent, this.application.options);
}
diff --git a/src/lib/output/themes/default/DefaultThemeRenderContext.ts b/src/lib/output/themes/default/DefaultThemeRenderContext.ts
index 347815930..0e663f377 100644
--- a/src/lib/output/themes/default/DefaultThemeRenderContext.ts
+++ b/src/lib/output/themes/default/DefaultThemeRenderContext.ts
@@ -5,7 +5,7 @@ import {
DeclarationReflection,
Reflection,
} from "../../../models";
-import type { JSX, NeverIfInternal, Options } from "../../../utils";
+import { JSX, NeverIfInternal, Options } from "../../../utils";
import type { DefaultTheme } from "./DefaultTheme";
import { defaultLayout } from "./layouts/default";
import { index } from "./partials";
@@ -53,7 +53,6 @@ function bind(fn: (f: F, ...a: L) => R, first: F) {
}
export class DefaultThemeRenderContext {
- private _iconsCache: JSX.Element;
private _refIcons: typeof icons;
options: Options;
@@ -63,24 +62,25 @@ export class DefaultThemeRenderContext {
options: Options,
) {
this.options = options;
-
- const { refs, cache } = buildRefIcons(icons);
- this._refIcons = refs;
- this._iconsCache = cache;
+ this._refIcons = buildRefIcons(icons, this);
}
+ /**
+ * @deprecated Will be removed in 0.26, no longer required.
+ */
iconsCache(): JSX.Element {
- return this._iconsCache;
+ return JSX.createElement(JSX.Fragment, null);
}
+ /**
+ * Icons available for use within the page.
+ *
+ * Note: This creates a reference to icons declared by {@link DefaultTheme.icons},
+ * to customize icons, that object must be modified instead.
+ */
get icons(): Readonly {
return this._refIcons;
}
- set icons(value: Readonly) {
- const { refs, cache } = buildRefIcons(value);
- this._refIcons = refs;
- this._iconsCache = cache;
- }
hook = (name: keyof RendererHooks) =>
this.theme.owner.hooks.emit(name, this);
diff --git a/src/lib/output/themes/default/assets/typedoc/Application.ts b/src/lib/output/themes/default/assets/typedoc/Application.ts
index 0c53725b4..498029c52 100644
--- a/src/lib/output/themes/default/assets/typedoc/Application.ts
+++ b/src/lib/output/themes/default/assets/typedoc/Application.ts
@@ -31,18 +31,18 @@ export function registerComponent(
*/
export class Application {
alwaysVisibleMember: HTMLElement | null = null;
-
- /**
- * Create a new Application instance.
- */
constructor() {
this.createComponents(document.body);
- this.ensureActivePageVisible();
this.ensureFocusedElementVisible();
this.listenForCodeCopies();
window.addEventListener("hashchange", () =>
this.ensureFocusedElementVisible(),
);
+
+ // We're on a *really* slow network connection.
+ if (!document.body.style.display) {
+ this.scrollToHash();
+ }
}
/**
@@ -63,6 +63,24 @@ export class Application {
this.ensureFocusedElementVisible();
}
+ public showPage() {
+ if (!document.body.style.display) return;
+ document.body.style.removeProperty("display");
+ this.scrollToHash();
+ }
+
+ public scrollToHash() {
+ // Because we hid the entire page until the navigation loaded or we hit a timeout,
+ // we have to manually resolve the url hash here.
+ if (location.hash) {
+ const reflAnchor = document.getElementById(
+ location.hash.substring(1),
+ );
+ if (!reflAnchor) return;
+ reflAnchor.scrollIntoView({ behavior: "instant", block: "start" });
+ }
+ }
+
public ensureActivePageVisible() {
const pageLink = document.querySelector(".tsd-navigation .current");
let iter = pageLink?.parentElement;
@@ -74,7 +92,7 @@ export class Application {
iter = iter.parentElement;
}
- if (pageLink) {
+ if (pageLink && !pageLink.checkVisibility()) {
const top =
pageLink.getBoundingClientRect().top -
document.documentElement.clientHeight / 4;
diff --git a/src/lib/output/themes/default/assets/typedoc/Navigation.ts b/src/lib/output/themes/default/assets/typedoc/Navigation.ts
index 9d93fe7f6..87eae39df 100644
--- a/src/lib/output/themes/default/assets/typedoc/Navigation.ts
+++ b/src/lib/output/themes/default/assets/typedoc/Navigation.ts
@@ -41,6 +41,7 @@ async function buildNav() {
}
window.app.createComponents(container);
+ window.app.showPage();
window.app.ensureActivePageVisible();
}
@@ -93,7 +94,7 @@ function addNavText(
if (classes) {
a.className = classes;
}
- if (location.href === a.href) {
+ if (location.pathname === a.pathname) {
a.classList.add("current");
}
if (el.kind) {
diff --git a/src/lib/output/themes/default/layouts/default.tsx b/src/lib/output/themes/default/layouts/default.tsx
index b7dedee11..c87d4c7df 100644
--- a/src/lib/output/themes/default/layouts/default.tsx
+++ b/src/lib/output/themes/default/layouts/default.tsx
@@ -29,6 +29,7 @@ export const defaultLayout = (
)}
+
{context.hook("head.end")}
@@ -36,7 +37,14 @@ export const defaultLayout = (
{context.hook("body.begin")}
{context.toolbar(props)}
@@ -66,7 +74,6 @@ export const defaultLayout = (
{context.analytics()}
- {context.iconsCache()}
{context.hook("body.end")}