From b4ca5f60fb0fc7b4e6f8a7600a942628ca21564d Mon Sep 17 00:00:00 2001 From: MrFigg <36649520+mrfigg@users.noreply.github.com> Date: Mon, 14 Oct 2024 16:05:59 -0700 Subject: [PATCH 1/8] Implemented client side hierarchy --- example/src/classes/Customer.ts | 30 ++ src/lib/internationalization/translatable.ts | 6 + src/lib/output/plugins/HierarchyPlugin.ts | 59 +++ src/lib/output/plugins/index.ts | 1 + .../output/themes/default/DefaultTheme.tsx | 39 +- .../default/DefaultThemeRenderContext.ts | 18 +- .../output/themes/default/assets/bootstrap.ts | 2 + .../default/assets/typedoc/Hierarchy.ts | 339 ++++++++++++++++++ .../output/themes/default/layouts/default.tsx | 1 + .../themes/default/partials/hierarchy.tsx | 57 +-- .../themes/default/templates/hierarchy.tsx | 49 +-- .../themes/default/templates/reflection.tsx | 2 +- src/lib/utils/options/declaration.ts | 1 + src/lib/utils/options/sources/typedoc.ts | 7 + src/test/issues.c2.test.ts | 2 +- static/style.css | 55 ++- 16 files changed, 589 insertions(+), 79 deletions(-) create mode 100644 src/lib/output/plugins/HierarchyPlugin.ts create mode 100644 src/lib/output/themes/default/assets/typedoc/Hierarchy.ts diff --git a/example/src/classes/Customer.ts b/example/src/classes/Customer.ts index 94713cb9a..360e9c3c3 100644 --- a/example/src/classes/Customer.ts +++ b/example/src/classes/Customer.ts @@ -79,6 +79,8 @@ export abstract class Customer { /** * A class that extends {@link Customer | `Customer`}. + * + * Notice how TypeDoc shows the inheritance hierarchy for our class. */ export class DeliveryCustomer extends Customer { /** A property defined on the subclass. */ @@ -118,3 +120,31 @@ export class DeliveryCustomer extends Customer { return typeof this.preferredCourierId === "undefined"; } } + +/** + * A class that extends {@link Customer | `Customer`}. + * + * Notice how TypeDoc shows the inheritance hierarchy for our class. + */ +export class WalkInCustomer extends Customer { + /** A property defined on the subclass. */ + trustedCustomer?: boolean; + + /** A private property defined on the subclass. */ + private _ordersPlacedCount: number = 0; + + /** + * An example of overriding a public method. + */ + onOrderPlaced(): void { + super.onOrderPlaced(); + + this._ordersPlacedCount++; + + if ( + this._ordersPlacedCount > 10 && + typeof this.trustedCustomer === "undefined" + ) + this.trustedCustomer = true; + } +} diff --git a/src/lib/internationalization/translatable.ts b/src/lib/internationalization/translatable.ts index 52c014bcf..3cd045194 100644 --- a/src/lib/internationalization/translatable.ts +++ b/src/lib/internationalization/translatable.ts @@ -301,6 +301,8 @@ export const translatable = { "Branches of the navigation tree which should not be expanded", help_navigation: "Determines how the navigation sidebar is organized", help_headings: "Determines which optional headings are rendered", + help_includeHierarchySummary: + "If set, a reflections hierarchy summary will be rendered to a summary page. Defaults to `true`", help_visibilityFilters: "Specify the default visibility for builtin filters and additional filters according to modifier tags", help_searchCategoryBoosts: @@ -466,7 +468,11 @@ export const translatable = { theme_type_declaration: "Type declaration", theme_index: "Index", theme_hierarchy: "Hierarchy", + theme_hierarchy_summary: "Hierarchy Summary", theme_hierarchy_view_full: "view full", + theme_hierarchy_view_summary: "View Summary", + theme_hierarchy_expand: "Expand", + theme_hierarchy_collapse: "Collapse", theme_implemented_by: "Implemented by", theme_defined_in: "Defined in", theme_implementation_of: "Implementation of", diff --git a/src/lib/output/plugins/HierarchyPlugin.ts b/src/lib/output/plugins/HierarchyPlugin.ts new file mode 100644 index 000000000..58c167b84 --- /dev/null +++ b/src/lib/output/plugins/HierarchyPlugin.ts @@ -0,0 +1,59 @@ +import * as Path from "path"; +import { Component, RendererComponent } from "../components"; +import { RendererEvent } from "../events"; +import { writeFile } from "../../utils"; +import { DefaultTheme } from "../themes/default/DefaultTheme"; +import { gzip } from "zlib"; +import { promisify } from "util"; +import { type Reflection } from "../../models"; +import { UrlMapping } from ".."; + +const gzipP = promisify(gzip); + +@Component({ name: "hierarchy" }) +export class HierarchyPlugin extends RendererComponent { + override initialize() { + this.owner.on(RendererEvent.BEGIN, this.onRendererBegin.bind(this)); + } + + private onRendererBegin(_event: RendererEvent) { + if (!(this.owner.theme instanceof DefaultTheme)) { + return; + } + + this.owner.preRenderAsyncJobs.push((event) => + this.buildHierarchy(event), + ); + } + + private async buildHierarchy(event: RendererEvent) { + const theme = this.owner.theme as DefaultTheme; + + const [_, pageEvent] = event.createPageEvent( + new UrlMapping( + "assets/hierarchy.js", + event.project, + () => "", + ), + ); + + const context = theme.getRenderContext(pageEvent); + + const hierarchy = context.getHierarchy(); + + const hierarchyJs = Path.join( + event.outputDirectory, + "assets", + "hierarchy.js", + ); + + const gz = await gzipP(Buffer.from(JSON.stringify(hierarchy))); + + await writeFile( + hierarchyJs, + `window.hierarchyData = "data:application/octet-stream;base64,${gz.toString( + "base64", + )}"`, + ); + } +} diff --git a/src/lib/output/plugins/index.ts b/src/lib/output/plugins/index.ts index b2435aaaa..f818cae1c 100644 --- a/src/lib/output/plugins/index.ts +++ b/src/lib/output/plugins/index.ts @@ -3,4 +3,5 @@ export { AssetsPlugin } from "./AssetsPlugin"; export { IconsPlugin } from "./IconsPlugin"; export { JavascriptIndexPlugin } from "./JavascriptIndexPlugin"; export { NavigationPlugin } from "./NavigationPlugin"; +export { HierarchyPlugin } from "./HierarchyPlugin"; export { SitemapPlugin } from "./SitemapPlugin"; diff --git a/src/lib/output/themes/default/DefaultTheme.tsx b/src/lib/output/themes/default/DefaultTheme.tsx index 1a58fd514..efd069014 100644 --- a/src/lib/output/themes/default/DefaultTheme.tsx +++ b/src/lib/output/themes/default/DefaultTheme.tsx @@ -12,6 +12,8 @@ import { TypeParameterReflection, type DocumentReflection, ReferenceReflection, + type Type, + ReferenceType, } from "../../../models"; import { type RenderTemplate, UrlMapping } from "../../models/UrlMapping"; import type { PageEvent } from "../../events"; @@ -51,6 +53,15 @@ export interface NavigationElement { children?: NavigationElement[]; } +export interface HierarchyElement { + html: string; + text: string; + path: string; + kind: ReflectionKind; + parents?: ({ path: string } | { html: string; text: string })[]; + children?: ({ path: string } | { html: string; text: string })[]; +} + /** * Responsible for getting a unique anchor for elements within a page. */ @@ -125,7 +136,7 @@ export class DefaultTheme extends Theme { return this.getRenderContext(pageEvent).indexTemplate(pageEvent); }; hierarchyTemplate = (pageEvent: PageEvent) => { - return this.getRenderContext(pageEvent).hierarchyTemplate(pageEvent); + return this.getRenderContext(pageEvent).hierarchyTemplate(); }; defaultLayoutTemplate = (pageEvent: PageEvent, template: RenderTemplate>) => { return this.getRenderContext(pageEvent).defaultLayout(template, pageEvent); @@ -214,7 +225,7 @@ export class DefaultTheme extends Theme { urls.push(new UrlMapping("index.html", project, this.indexTemplate)); } - if (getHierarchyRoots(project).length) { + if (this.application.options.getValue("includeHierarchySummary") && getHierarchyRoots(project).length) { urls.push(new UrlMapping("hierarchy.html", project, this.hierarchyTemplate)); } @@ -467,6 +478,30 @@ export class DefaultTheme extends Theme { } } + buildHierarchy(project: ProjectReflection, renderType: (type: Type) => string): HierarchyElement[] { + return (project.getReflectionsByKind(ReflectionKind.ClassOrInterface) as DeclarationReflection[]) + .filter((reflection) => reflection.extendedTypes?.length || reflection.extendedBy?.length) + .map((reflection) => ({ + html: renderType( + ReferenceType.createResolvedReference(reflection.name, reflection, reflection.project), + ), + // Full name should be safe here, since this list only includes classes/interfaces. + text: reflection.getFullName(), + path: reflection.url!, + kind: reflection.kind, + parents: reflection.extendedTypes?.map((type) => + !(type instanceof ReferenceType) || !(type.reflection instanceof DeclarationReflection) + ? { html: renderType(type), text: type.toString() } + : { path: type.reflection.url! }, + ), + children: reflection.extendedBy?.map((type) => + !(type instanceof ReferenceType) || !(type.reflection instanceof DeclarationReflection) + ? { html: renderType(type), text: type.toString() } + : { path: type.reflection.url! }, + ), + })); + } + private sluggers = new Map(); getSlugger(reflection: Reflection): Slugger { diff --git a/src/lib/output/themes/default/DefaultThemeRenderContext.ts b/src/lib/output/themes/default/DefaultThemeRenderContext.ts index 2e0b423ff..eed9c5b6c 100644 --- a/src/lib/output/themes/default/DefaultThemeRenderContext.ts +++ b/src/lib/output/themes/default/DefaultThemeRenderContext.ts @@ -8,9 +8,10 @@ import type { CommentDisplayPart, DeclarationReflection, Reflection, + Type, } from "../../../models"; -import { type NeverIfInternal, type Options } from "../../../utils"; -import type { DefaultTheme } from "./DefaultTheme"; +import { JSX, type NeverIfInternal, type Options } from "../../../utils"; +import type { DefaultTheme, HierarchyElement } from "./DefaultTheme"; import { defaultLayout } from "./layouts/default"; import { index } from "./partials"; import { breadcrumb } from "./partials/breadcrumb"; @@ -107,6 +108,19 @@ export class DefaultThemeRenderContext { getNavigation = () => this.theme.getNavigation(this.page.project); + private _hierarchyCache: HierarchyElement[] | undefined; + + getHierarchy = () => { + if (this._hierarchyCache) { + return this._hierarchyCache; + } + + return (this._hierarchyCache = this.theme.buildHierarchy( + this.page.project, + (value: Type) => JSX.renderElement(type(this, value)), + )); + }; + getReflectionClasses = (refl: DeclarationReflection | DocumentReflection) => this.theme.getReflectionClasses(refl); diff --git a/src/lib/output/themes/default/assets/bootstrap.ts b/src/lib/output/themes/default/assets/bootstrap.ts index a7168939d..23b763b6c 100644 --- a/src/lib/output/themes/default/assets/bootstrap.ts +++ b/src/lib/output/themes/default/assets/bootstrap.ts @@ -5,6 +5,7 @@ import { Filter } from "./typedoc/components/Filter"; import { Accordion } from "./typedoc/components/Accordion"; import { initTheme } from "./typedoc/Theme"; import { initNav } from "./typedoc/Navigation"; +import { initHierarchy } from "./typedoc/Hierarchy"; registerComponent(Toggle, "a[data-toggle]"); registerComponent(Accordion, ".tsd-accordion"); @@ -24,3 +25,4 @@ Object.defineProperty(window, "app", { value: app }); initSearch(); initNav(); +initHierarchy(); diff --git a/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts b/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts new file mode 100644 index 000000000..5d6755649 --- /dev/null +++ b/src/lib/output/themes/default/assets/typedoc/Hierarchy.ts @@ -0,0 +1,339 @@ +export interface HierarchyElement { + html: string; + text: string; + class: string; + path?: string; + kind?: number; + parents?: HierarchyElement[]; + children?: HierarchyElement[]; + depth?: number; +} + +declare global { + interface Window { + // Base64 encoded data url, gzipped, JSON encoded HierarchyElement[] + hierarchyData?: string; + } +} + +export function initHierarchy() { + const script = document.getElementById("tsd-hierarchy-script"); + if (!script) return; + + script.addEventListener("load", buildHierarchy); + buildHierarchy(); +} + +async function buildHierarchy() { + const container = document.getElementById("tsd-hierarchy-container"); + if (!container || !window.hierarchyData) return; + + const res = await fetch(window.hierarchyData); + const data = await res.arrayBuffer(); + const json = new Blob([data]) + .stream() + .pipeThrough(new DecompressionStream("gzip")); + + const hierarchy = loadJson(await new Response(json).json()); + + let baseUrl = container.dataset.base!; + + if (!baseUrl.endsWith("/")) baseUrl += "/"; + + const targetPath = container.dataset.targetPath; + + const seeds = !targetPath + ? hierarchy.slice() + : hierarchy.filter((element) => { + if (element.path === targetPath) { + element.class += " tsd-hierarchy-target"; + + element.parents?.forEach( + (parent) => + (parent.class += " tsd-hierarchy-close-relative"), + ); + + element.children?.forEach( + (child) => + (child.class += " tsd-hierarchy-close-relative"), + ); + + return true; + } + + if ( + !element.parents?.some(({ path }) => path === targetPath) && + !element.children?.some(({ path }) => path === targetPath) + ) { + element.class += " tsd-hierarchy-distant-relative"; + + return false; + } + + return false; + }); + + const trees = getTrees(seeds, !targetPath); + + if (!trees.length) return; + + container + .querySelectorAll("ul.tsd-full-hierarchy") + ?.forEach((list) => list.remove()); + + trees.forEach((tree) => { + const list = buildList( + tree.filter((branch) => !branch.parents?.length), + baseUrl, + !targetPath, + )!; + + list.classList.add("tsd-full-hierarchy"); + + container.append(list); + }); + + if (!targetPath && window.location.hash) { + const anchor = document.getElementById( + window.location.hash.substring(1), + ); + + if (anchor && container.contains(anchor)) + window.scrollTo(0, anchor.offsetTop); + } +} + +function loadJson(hierarchy: HierarchyElement[]) { + const leaves: HierarchyElement[] = []; + + hierarchy.forEach((element) => { + element.class = "tsd-hierarchy-item"; + + element.parents = element.parents + ?.map((parent) => { + if (parent.path) + return hierarchy.find(({ path }) => path === parent.path); + + parent.class = "tsd-hierarchy-item"; + parent.kind = element.kind; + parent.children = [element]; + + leaves.push(parent); + + return parent; + }) + .filter((parent) => !!parent); + + element.children = element.children + ?.map((child) => { + if (child.path) + return hierarchy.find(({ path }) => path === child.path); + + child.class = "tsd-hierarchy-item"; + child.kind = element.kind; + child.parents = [element]; + + leaves.push(child); + + return child; + }) + .filter((child) => !!child); + }); + + hierarchy.push(...leaves); + + hierarchy.forEach((element) => delete element.depth); + + return hierarchy; +} + +function getTrees(seeds: HierarchyElement[], prune: boolean) { + const stack = seeds.slice(); + + const trees: HierarchyElement[][] = []; + + while (stack.length > 0) { + const seed = stack.shift()!; + + let tree = findBranches(seed); + + tree.forEach((branch) => { + const idx = stack.indexOf(branch); + + if (idx !== -1) stack.splice(idx, 1); + }); + + if (prune) { + tree = pruneBranches(tree); + + if (tree.length <= 1) continue; + } + + tree = sortBranches(tree); + + tree = growBranches(tree); + + trees.push(tree); + } + + return trees; +} + +function findBranches( + branch: HierarchyElement, + tree: HierarchyElement[] = [], + depth: number = 0, +) { + if (tree.includes(branch)) { + branch.depth = Math.min(branch.depth!, depth); + + return tree; + } + + tree.push(branch); + + branch.depth = depth; + + branch.parents?.forEach((parent) => findBranches(parent, tree, depth - 1)); + + branch.children?.forEach((child) => findBranches(child, tree, depth + 1)); + + return tree; +} + +function pruneBranches(tree: HierarchyElement[]) { + return tree.filter((branch) => { + if (branch.path) return true; + + branch.parents?.forEach((parent) => + parent.children!.splice(parent.children!.indexOf(branch), 1), + ); + + branch.children?.forEach((child) => + child.parents!.splice(child.parents!.indexOf(branch), 1), + ); + + return false; + }); +} + +function sortBranches(tree: HierarchyElement[]) { + tree = tree.slice(); + + tree.sort((a, b) => b.text.localeCompare(a.text)); + + tree.forEach((branch) => { + branch.parents?.sort((a, b) => a.text.localeCompare(b.text)); + + branch.children?.sort((a, b) => a.text.localeCompare(b.text)); + }); + + const reverseTree: HierarchyElement[] = []; + + const visited: Set = new Set(); + + function visit(branch: HierarchyElement) { + visited.add(branch); + + branch.children?.forEach( + (child) => !visited.has(child) && visit(child), + ); + + reverseTree.push(branch); + } + + tree.forEach((branch) => !visited.has(branch) && visit(branch)); + + return reverseTree.reverse(); +} + +function growBranches(tree: HierarchyElement[]) { + tree = tree.slice(); + + const roots = tree.filter((branch) => !branch.parents?.length); + + const minDepth = Math.min(...roots.map((branch) => branch.depth!)); + + roots.forEach((root) => { + while (root.depth! > minDepth) { + const newRoot = { + html: "", + text: "", + children: [root], + class: "tsd-hierarchy-spacer", + depth: root.depth! - 1, + }; + + root.parents = [newRoot]; + + tree.splice(tree.indexOf(root), 0, newRoot); + + root = newRoot; + } + }); + + return tree; +} + +function buildList( + branches: HierarchyElement[], + baseUrl: string, + summary: boolean, + reverse: boolean = true, + renderedBranches: Set = new Set(), +) { + if (branches.every((branch) => renderedBranches.has(branch))) + return undefined; + + const list = document.createElement("ul"); + list.classList.add("tsd-hierarchy"); + + if (reverse) branches = branches.slice().reverse(); + + branches.forEach((currentBranch) => { + if (renderedBranches.has(currentBranch)) return; + + renderedBranches.add(currentBranch); + + const item = document.createElement("li"); + + if (reverse) list.prepend(item); + else list.append(item); + + item.className = currentBranch.class; + + if (currentBranch.text) { + if (summary) { + item.innerHTML += ``; + + const anchor = document.createElement("a"); + anchor.href = baseUrl + currentBranch.path!; + item.append(anchor); + + anchor.innerHTML += ``; + anchor.innerHTML += `${currentBranch.text}`; + } else if (item.classList.contains("tsd-hierarchy-target")) { + item.innerHTML += `${currentBranch.text}`; + } else { + item.innerHTML += currentBranch.html.replace( + /(?<=]*(?<=\s)href=")(?![a-zA-Z]+:\/\/)([^"]*)(?="[^>]*>)/g, + `${baseUrl.replace(/\$/g, "$$$$")}$1`, + ); + } + } + + if (!currentBranch.children?.length) return; + + const childrenList = buildList( + currentBranch.children, + baseUrl, + summary, + false, + renderedBranches, + ); + + if (childrenList) item.append(childrenList); + }); + + return list; +} diff --git a/src/lib/output/themes/default/layouts/default.tsx b/src/lib/output/themes/default/layouts/default.tsx index ac8219ebb..a873f8f11 100644 --- a/src/lib/output/themes/default/layouts/default.tsx +++ b/src/lib/output/themes/default/layouts/default.tsx @@ -35,6 +35,7 @@ export const defaultLayout = ( + {context.hook("head.end", context)} diff --git a/src/lib/output/themes/default/partials/hierarchy.tsx b/src/lib/output/themes/default/partials/hierarchy.tsx index 5d13d14fa..d6eae87ea 100644 --- a/src/lib/output/themes/default/partials/hierarchy.tsx +++ b/src/lib/output/themes/default/partials/hierarchy.tsx @@ -1,6 +1,6 @@ import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; import { JSX } from "../../../../utils"; -import type { DeclarationHierarchy, Type } from "../../../../models"; +import type { DeclarationHierarchy, DeclarationReflection, Type } from "../../../../models"; const isLinkedReferenceType = (type: Type) => type.visit({ @@ -15,29 +15,42 @@ function hasAnyLinkedReferenceType(h: DeclarationHierarchy | undefined): boolean return hasAnyLinkedReferenceType(h.next); } -export function hierarchy(context: DefaultThemeRenderContext, props: DeclarationHierarchy | undefined) { - if (!props) return; - - const fullLink = hasAnyLinkedReferenceType(props) ? ( - <> - {" "} - ( - - {context.i18n.theme_hierarchy_view_full()} - - ) - - ) : ( - <> - ); +export function hierarchy(context: DefaultThemeRenderContext, props: DeclarationReflection) { + if (!props.typeHierarchy) return; + + const summaryLink = + context.options.getValue("includeHierarchySummary") && hasAnyLinkedReferenceType(props.typeHierarchy) ? ( + <> + {" "} + ( + + {context.i18n.theme_hierarchy_view_summary()} + + ) + + ) : ( + <> + ); return ( -
+
+ +

{context.i18n.theme_hierarchy()} - {fullLink} + {summaryLink}{" "} +

- {hierarchyList(context, props)} + + {hierarchyList(context, props.typeHierarchy)}
); } @@ -46,8 +59,10 @@ function hierarchyList(context: DefaultThemeRenderContext, props: DeclarationHie return (