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({children}); + 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")} diff --git a/src/lib/output/themes/default/partials/icon.tsx b/src/lib/output/themes/default/partials/icon.tsx index dc26cec98..c899cf5cb 100644 --- a/src/lib/output/themes/default/partials/icon.tsx +++ b/src/lib/output/themes/default/partials/icon.tsx @@ -1,6 +1,7 @@ import assert from "assert"; import { ReflectionKind } from "../../../../models"; import { JSX } from "../../../../utils"; +import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; const kindIcon = (letterPath: JSX.Element, color: string, circular = false) => ( @@ -18,9 +19,11 @@ const kindIcon = (letterPath: JSX.Element, color: string, circular = false) => ( ); -export function buildRefIcons JSX.Element>>(icons: T): { refs: T; cache: JSX.Element } { +export function buildRefIcons JSX.Element>>( + icons: T, + context: DefaultThemeRenderContext, +): T { const refs: Record JSX.Element> = {}; - const children: JSX.Element[] = []; for (const [name, builder] of Object.entries(icons)) { const jsx = builder.call(icons); @@ -32,19 +35,15 @@ export function buildRefIcons JSX.Element>>(icons continue; } - children.push({jsx.children}); const ref = ( - + ); refs[name] = () => ref; } - return { - refs: refs as T, - cache: {children}, - }; + return refs as T; } export const icons: Record< diff --git a/src/lib/output/themes/default/partials/navigation.tsx b/src/lib/output/themes/default/partials/navigation.tsx index edac2d18a..8e47f22ed 100644 --- a/src/lib/output/themes/default/partials/navigation.tsx +++ b/src/lib/output/themes/default/partials/navigation.tsx @@ -2,11 +2,8 @@ import { Reflection, ReflectionKind } from "../../../../models"; import { JSX } from "../../../../utils"; import type { PageEvent } from "../../../events"; import { camelToTitleCase, classNames, getDisplayName, wbr } from "../../lib"; -import type { NavigationElement } from "../DefaultTheme"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; -const MAX_EMBEDDED_NAV_SIZE = 20; - export function sidebar(context: DefaultThemeRenderContext, props: PageEvent) { return ( <> @@ -100,57 +97,6 @@ export function settings(context: DefaultThemeRenderContext) { } export const navigation = function navigation(context: DefaultThemeRenderContext, props: PageEvent) { - const nav = context.getNavigation(); - - let elements = 0; - function link(el: NavigationElement, path: string[] = []) { - if (elements > MAX_EMBEDDED_NAV_SIZE) { - return <>; - } - - if (el.path) { - ++elements; - return ( -
  • - - {el.kind && context.icons[el.kind]()} - {el.text} - -
  • - ); - } - - // Top level element is a group/category, recurse so that we don't have a half-broken - // navigation tree for people with JS turned off. - if (el.children) { - ++elements; - const fullPath = [...path, el.text]; - - return ( -
    - - {context.icons.chevronDown()} - {el.text} - -
    -
      {el.children.map((c) => link(c, fullPath))}
    -
    -
    - ); - } - - return ( -
  • - {el.text} -
  • - ); - } - - const navEl = nav.map((el) => link(el)); - return ( );