Skip to content

Commit

Permalink
Improve doc HTML output
Browse files Browse the repository at this point in the history
Closes #2491
Closes #2505

This technically contains a breaking change as icons can no longer be overwritten via DefaultThemeRenderContext, but no published theme uses this, so I've decided not to care.
  • Loading branch information
Gerrit0 committed Mar 3, 2024
1 parent 88d787c commit 9751e31
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 85 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Expand Up @@ -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)

Expand Down
60 changes: 60 additions & 0 deletions 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(<g id={`icon-${name}`}>{icon.call(icons).children}</g>);
}

const svg = renderElement(<svg xmlns="http://www.w3.org/2000/svg">{children}</svg>);
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)]);
}
}
1 change: 1 addition & 0 deletions 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";
16 changes: 16 additions & 0 deletions src/lib/output/themes/default/DefaultTheme.tsx
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Reflection>) {
return new DefaultThemeRenderContext(this, pageEvent, this.application.options);
}
Expand Down
24 changes: 12 additions & 12 deletions src/lib/output/themes/default/DefaultThemeRenderContext.ts
Expand Up @@ -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";
Expand Down Expand Up @@ -53,7 +53,6 @@ function bind<F, L extends any[], R>(fn: (f: F, ...a: L) => R, first: F) {
}

export class DefaultThemeRenderContext {
private _iconsCache: JSX.Element;
private _refIcons: typeof icons;
options: Options;

Expand All @@ -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<typeof icons> {
return this._refIcons;
}
set icons(value: Readonly<typeof icons>) {
const { refs, cache } = buildRefIcons(value);
this._refIcons = refs;
this._iconsCache = cache;
}

hook = (name: keyof RendererHooks) =>
this.theme.owner.hooks.emit(name, this);
Expand Down
30 changes: 24 additions & 6 deletions src/lib/output/themes/default/assets/typedoc/Application.ts
Expand Up @@ -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();
}
}

/**
Expand All @@ -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;
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/output/themes/default/assets/typedoc/Navigation.ts
Expand Up @@ -41,6 +41,7 @@ async function buildNav() {
}

window.app.createComponents(container);
window.app.showPage();
window.app.ensureActivePageVisible();
}

Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 9 additions & 2 deletions src/lib/output/themes/default/layouts/default.tsx
Expand Up @@ -29,14 +29,22 @@ export const defaultLayout = (
<link rel="stylesheet" href={context.relativeURL("assets/custom.css", true)} />
)}
<script defer src={context.relativeURL("assets/main.js", true)}></script>
<script async src={context.relativeURL("assets/icons.js", true)} id="tsd-icons-script"></script>
<script async src={context.relativeURL("assets/search.js", true)} id="tsd-search-script"></script>
<script async src={context.relativeURL("assets/navigation.js", true)} id="tsd-nav-script"></script>
{context.hook("head.end")}
</head>
<body>
{context.hook("body.begin")}
<script>
<Raw html='document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os"' />
<Raw html='document.documentElement.dataset.theme = localStorage.getItem("tsd-theme") || "os";' />
{/* Hide the entire page for up to 0.5 seconds so that if navigating between pages on a fast */}
{/* device the navigation pane doesn't appear to flash if it loads just after the page displays. */}
{/* This could still happen if we're unlucky, but from experimenting with Firefox's throttling */}
{/* settings, this appears to be a reasonable tradeoff between displaying page content without the */}
{/* navigation on exceptionally slow connections and not having the navigation obviously repaint. */}
<Raw html='document.body.style.display="none";' />
<Raw html='setTimeout(() => document.body.style.removeProperty("display"),500)' />
</script>
{context.toolbar(props)}

Expand Down Expand Up @@ -66,7 +74,6 @@ export const defaultLayout = (
<div class="overlay"></div>

{context.analytics()}
{context.iconsCache()}
{context.hook("body.end")}
</body>
</html>
Expand Down
15 changes: 7 additions & 8 deletions 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) => (
<svg class="tsd-kind-icon" viewBox="0 0 24 24">
Expand All @@ -18,9 +19,11 @@ const kindIcon = (letterPath: JSX.Element, color: string, circular = false) => (
</svg>
);

export function buildRefIcons<T extends Record<string, () => JSX.Element>>(icons: T): { refs: T; cache: JSX.Element } {
export function buildRefIcons<T extends Record<string, () => JSX.Element>>(
icons: T,
context: DefaultThemeRenderContext,
): T {
const refs: Record<string, () => JSX.Element> = {};
const children: JSX.Element[] = [];

for (const [name, builder] of Object.entries(icons)) {
const jsx = builder.call(icons);
Expand All @@ -32,19 +35,15 @@ export function buildRefIcons<T extends Record<string, () => JSX.Element>>(icons
continue;
}

children.push(<g id={`icon-${name}`}>{jsx.children}</g>);
const ref = (
<svg {...jsx.props} id={undefined}>
<use href={`#icon-${name}`} />
<use href={`${context.relativeURL("assets/icons.svg")}#icon-${name}`} />
</svg>
);
refs[name] = () => ref;
}

return {
refs: refs as T,
cache: <svg style={"display: none"}>{children}</svg>,
};
return refs as T;
}

export const icons: Record<
Expand Down
57 changes: 1 addition & 56 deletions src/lib/output/themes/default/partials/navigation.tsx
Expand Up @@ -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<Reflection>) {
return (
<>
Expand Down Expand Up @@ -100,66 +97,14 @@ export function settings(context: DefaultThemeRenderContext) {
}

export const navigation = function navigation(context: DefaultThemeRenderContext, props: PageEvent<Reflection>) {
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 (
<li>
<a
href={context.relativeURL(el.path)}
class={classNames({ current: props.model.url === el.path }, el.class)}
>
{el.kind && context.icons[el.kind]()}
{el.text}
</a>
</li>
);
}

// 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 (
<details class={classNames({ "tsd-index-accordion": true }, el.class)} data-key={fullPath.join("$")}>
<summary class="tsd-accordion-summary">
{context.icons.chevronDown()}
<span>{el.text}</span>
</summary>
<div class="tsd-accordion-details">
<ul class="tsd-nested-navigation">{el.children.map((c) => link(c, fullPath))}</ul>
</div>
</details>
);
}

return (
<li>
<span>{el.text}</span>
</li>
);
}

const navEl = nav.map((el) => link(el));

return (
<nav class="tsd-navigation">
<a href={context.urlTo(props.project)} class={classNames({ current: props.project === props.model })}>
{context.icons[ReflectionKind.Project]()}
<span>{getDisplayName(props.project)}</span>
</a>
<ul class="tsd-small-nested-navigation" id="tsd-nav-container" data-base={context.relativeURL("./")}>
{navEl}
{elements < MAX_EMBEDDED_NAV_SIZE || <li>Loading...</li>}
<li>Loading...</li>
</ul>
</nav>
);
Expand Down

0 comments on commit 9751e31

Please sign in to comment.