From e45d83acf94eab48055b80dd5c707ac54a0cf776 Mon Sep 17 00:00:00 2001 From: Gerrit Birkeland Date: Sat, 3 May 2025 09:51:37 -0600 Subject: [PATCH] WIP: Prototype of #2893 support This isn't possible to do without breaking changes in the default theme. --- src/lib/models/ReflectionGroup.ts | 16 +- src/lib/output/renderer.ts | 2 + src/lib/output/router.ts | 197 +++++++++++++----- .../output/themes/default/DefaultTheme.tsx | 25 ++- 4 files changed, 177 insertions(+), 63 deletions(-) diff --git a/src/lib/models/ReflectionGroup.ts b/src/lib/models/ReflectionGroup.ts index 616732e4a..2e81f3d96 100644 --- a/src/lib/models/ReflectionGroup.ts +++ b/src/lib/models/ReflectionGroup.ts @@ -16,6 +16,13 @@ export class ReflectionGroup { */ title: string; + /** + * Alias of {@link title} to make `ReflectionGroup` compatible with the {@link RouterTarget} type. + */ + get name() { + return this.title; + } + /** * User specified description via `@groupDescription`, if specified. */ @@ -31,15 +38,20 @@ export class ReflectionGroup { */ categories?: ReflectionCategory[]; + /** @deprecated to be removed in 0.29 */ + get owningReflection() { + return this.parent; + } + /** * Create a new ReflectionGroup instance. * * @param title The title of this group. - * @param owningReflection The reflection containing this group, useful for changing rendering based on a comment on a reflection. + * @param parent The reflection containing this group, useful for changing rendering based on a comment on a reflection. */ constructor( title: string, - readonly owningReflection: Reflection, + readonly parent: Reflection, ) { this.title = title; } diff --git a/src/lib/output/renderer.ts b/src/lib/output/renderer.ts index 564608112..1498a69df 100644 --- a/src/lib/output/renderer.ts +++ b/src/lib/output/renderer.ts @@ -34,6 +34,7 @@ import { GroupRouter, KindDirRouter, KindRouter, + KindRouterWithGroupPages, type PageDefinition, type Router, StructureDirRouter, @@ -173,6 +174,7 @@ export class Renderer extends AbstractComponent { ["structure-dir", StructureDirRouter], ["group", GroupRouter], ["category", CategoryRouter], + ["kind-with-groups", KindRouterWithGroupPages], ]); private themes = new Map Theme>([ diff --git a/src/lib/output/router.ts b/src/lib/output/router.ts index f9b6e1711..73c1ff0ae 100644 --- a/src/lib/output/router.ts +++ b/src/lib/output/router.ts @@ -1,7 +1,13 @@ import type { Application } from "../application.js"; import { CategoryPlugin } from "../converter/plugins/CategoryPlugin.js"; import { GroupPlugin } from "../converter/plugins/GroupPlugin.js"; -import { type DeclarationReflection, ProjectReflection, Reflection, ReflectionKind } from "../models/index.js"; +import { + type DeclarationReflection, + ProjectReflection, + Reflection, + ReflectionGroup, + ReflectionKind, +} from "../models/index.js"; import { createNormalizedUrl } from "#node-utils"; import { Option, type TypeDocOptionMap } from "../utils/index.js"; import { Slugger } from "./themes/default/Slugger.js"; @@ -19,6 +25,7 @@ export const PageKind = { Reflection: "reflection", Document: "document", Hierarchy: "hierarchy", + Group: "group", } as const; export type PageKind = (typeof PageKind)[keyof typeof PageKind] | string & {}; @@ -146,7 +153,7 @@ export abstract class BaseRouter implements Router { * and automatically introduce a unique identifier to the URL to resolve * them. */ - protected abstract getIdealBaseName(reflection: RouterTarget): string; + protected abstract getIdealBaseName(target: RouterTarget): string; buildPages(project: ProjectReflection): PageDefinition[] { this.usedFileNames = new Set(); @@ -302,6 +309,10 @@ export abstract class BaseRouter implements Router { * that reflection will not have their own document. */ protected getPageKind(target: RouterTarget): PageKind | undefined { + if (target instanceof ReflectionGroup) { + return PageKind.Group; + } + if (!(target instanceof Reflection)) { return undefined; } @@ -458,16 +469,20 @@ export class KindRouter extends BaseRouter { [ReflectionKind.Document, "documents"], ]); - protected override getIdealBaseName(reflection: Reflection): string { - const dir = this.directories.get(reflection.kind)!; - const parts = [createNormalizedUrl(reflection.name)]; - while (reflection.parent && !reflection.parent.isProject()) { - reflection = reflection.parent; - parts.unshift(createNormalizedUrl(reflection.name)); + protected override getIdealBaseName(target: RouterTarget): string { + if (target instanceof Reflection) { + const dir = this.directories.get(target.kind)!; + const parts = [createNormalizedUrl(target.name)]; + while (target.parent && !target.parent.isProject()) { + target = target.parent; + parts.unshift(createNormalizedUrl(target.name)); + } + + const baseName = parts.join("."); + return `${dir}/${baseName}`; } - const baseName = parts.join("."); - return `${dir}/${baseName}`; + throw new Error("KindRouter does not support non-reflection URL targets"); } } @@ -482,7 +497,7 @@ export class KindDirRouter extends KindRouter { } protected override buildChildPages( - reflection: Reflection, + reflection: RouterTarget, outPages: PageDefinition[], ): void { this.extension = `/index.html`; @@ -503,26 +518,30 @@ export class KindDirRouter extends KindRouter { * @group Routers */ export class StructureRouter extends BaseRouter { - protected override getIdealBaseName(reflection: Reflection): string { - // Special case: Modules allow slashes in their name. We actually want - // to allow that here to mirror file structures. - const parts = [...reflection.name.split("/").map(createNormalizedUrl)]; - while (reflection.parent && !reflection.parent.isProject()) { - reflection = reflection.parent; - parts.unshift( - ...reflection.name.split("/").map(createNormalizedUrl), - ); - } + protected override getIdealBaseName(target: RouterTarget): string { + if (target instanceof Reflection) { + // Special case: Modules allow slashes in their name. We actually want + // to allow that here to mirror file structures. + const parts = [...target.name.split("/").map(createNormalizedUrl)]; + while (target.parent && !target.parent.isProject()) { + target = target.parent; + parts.unshift( + ...target.name.split("/").map(createNormalizedUrl), + ); + } - // This should only happen if someone tries to break things with @module - // I don't think it will ever occur in normal usage. - if (parts.includes("..")) { - throw new Error( - "structure router cannot be used with a project that has a name containing '..'", - ); + // This should only happen if someone tries to break things with @module + // I don't think it will ever occur in normal usage. + if (parts.includes("..")) { + throw new Error( + "structure router cannot be used with a project that has a name containing '..'", + ); + } + + return parts.join("/"); } - return parts.join("/"); + throw new Error("StructureRouter does not support non-reflection URL targets"); } } @@ -537,11 +556,11 @@ export class StructureDirRouter extends StructureRouter { } protected override buildChildPages( - reflection: Reflection, + target: RouterTarget, outPages: PageDefinition[], ): void { this.extension = `/index.html`; - return super.buildChildPages(reflection, outPages); + return super.buildChildPages(target, outPages); } override getFullUrl(refl: Reflection): string { @@ -576,19 +595,23 @@ export class GroupRouter extends BaseRouter { ); } - protected override getIdealBaseName(reflection: Reflection): string { - const group = this.getGroup(reflection) - .split("/") - .map(createNormalizedUrl) - .join("/"); - const parts = [createNormalizedUrl(reflection.name)]; - while (reflection.parent && !reflection.parent.isProject()) { - reflection = reflection.parent; - parts.unshift(createNormalizedUrl(reflection.name)); + protected override getIdealBaseName(target: RouterTarget): string { + if (target instanceof Reflection) { + const group = this.getGroup(target) + .split("/") + .map(createNormalizedUrl) + .join("/"); + const parts = [createNormalizedUrl(target.name)]; + while (target.parent && !target.parent.isProject()) { + target = target.parent; + parts.unshift(createNormalizedUrl(target.name)); + } + + const baseName = parts.join("."); + return `${group}/${baseName}`; } - const baseName = parts.join("."); - return `${group}/${baseName}`; + throw new Error("GroupRouter does not support non-Reflection router targets"); } } @@ -613,18 +636,88 @@ export class CategoryRouter extends BaseRouter { ); } - protected override getIdealBaseName(reflection: Reflection): string { - const cat = this.getCategory(reflection) - .split("/") - .map(createNormalizedUrl) - .join("/"); - const parts = [createNormalizedUrl(reflection.name)]; - while (reflection.parent && !reflection.parent.isProject()) { - reflection = reflection.parent; - parts.unshift(createNormalizedUrl(reflection.name)); + protected override getIdealBaseName(target: RouterTarget): string { + if (target instanceof Reflection) { + const cat = this.getCategory(target) + .split("/") + .map(createNormalizedUrl) + .join("/"); + const parts = [createNormalizedUrl(target.name)]; + while (target.parent && !target.parent.isProject()) { + target = target.parent; + parts.unshift(createNormalizedUrl(target.name)); + } + + const baseName = parts.join("."); + return `${cat}/${baseName}`; + } + + throw new Error(`CategoryRouter does not support non-reflection targets`); + } +} + +/** + * Router which places reflections in folders according to their kind, + * and creates pages for groups within modules + * @group Routers + */ +export class KindRouterWithGroupPages extends KindRouter { + protected override buildChildPages( + target: RouterTarget, + outPages: PageDefinition[], + ): void { + const kind = this.getPageKind(target); + if (kind) { + const idealName = this.getIdealBaseName(target); + const actualName = this.getFileName(idealName); + this.fullUrls.set(target, actualName); + this.sluggers.set( + target, + new Slugger(this.sluggerConfiguration), + ); + + outPages.push({ + kind, + model: target, + url: actualName, + }); + + if (target instanceof ReflectionGroup) { + for (const child of target.children) { + this.buildChildPages(child, outPages); + } + } else if (target instanceof Reflection) { + if ( + target.kindOf(ReflectionKind.SomeModule | ReflectionKind.Project) && + (target as DeclarationReflection).groups + ) { + for (const group of (target as DeclarationReflection).groups!) { + this.buildChildPages(group, outPages); + } + } else { + target.traverse((child) => { + this.buildChildPages(child, outPages); + return true; + }); + } + } + } else { + this.buildAnchors(target, target.parent!); + } + } + + protected override getIdealBaseName(target: RouterTarget): string { + if (target instanceof ReflectionGroup) { + const parts = [createNormalizedUrl(target.name)]; + while (target.parent && target.parent.parent) { + target = target.parent; + parts.unshift(createNormalizedUrl(target.name)); + } + + const baseName = parts.join("."); + return `groups/${baseName}`; } - const baseName = parts.join("."); - return `${cat}/${baseName}`; + return super.getIdealBaseName(target); } } diff --git a/src/lib/output/themes/default/DefaultTheme.tsx b/src/lib/output/themes/default/DefaultTheme.tsx index 3c699240a..04e6d6ce2 100644 --- a/src/lib/output/themes/default/DefaultTheme.tsx +++ b/src/lib/output/themes/default/DefaultTheme.tsx @@ -91,6 +91,9 @@ export class DefaultTheme extends Theme { hierarchyTemplate = (pageEvent: PageEvent) => { return this.getRenderContext(pageEvent).hierarchyTemplate(pageEvent); }; + groupTemplate = (pageEvent: PageEvent) => { + return this.getRenderContext(pageEvent).hierarchyTemplate(pageEvent); + }; defaultLayoutTemplate = (pageEvent: PageEvent, template: RenderTemplate>) => { return this.getRenderContext(pageEvent).defaultLayout(template, pageEvent); }; @@ -107,6 +110,17 @@ export class DefaultTheme extends Theme { return reflection.kind; } + /** + * This mechanism is somewhat likely to change in the future + */ + templateMapping: Record) => JSX.Element> = { + [PageKind.Index]: this.indexTemplate, + [PageKind.Document]: this.documentTemplate, + [PageKind.Hierarchy]: this.hierarchyTemplate, + [PageKind.Reflection]: this.reflectionTemplate, + [PageKind.Group]: this.groupTemplate, + }; + /** * Create a new DefaultTheme instance. * @@ -120,14 +134,7 @@ export class DefaultTheme extends Theme { } render(page: PageEvent): string { - const templateMapping: Record) => JSX.Element> = { - [PageKind.Index]: this.indexTemplate, - [PageKind.Document]: this.documentTemplate, - [PageKind.Hierarchy]: this.hierarchyTemplate, - [PageKind.Reflection]: this.reflectionTemplate, - }; - - const template = templateMapping[page.pageKind]; + const template = this.templateMapping[page.pageKind]; if (!template) { throw new Error(`TypeDoc's DefaultTheme does not support the page kind ${page.pageKind}`); @@ -223,7 +230,7 @@ export class DefaultTheme extends Theme { } if (parent instanceof ReflectionGroup) { - if (shouldShowCategories(parent.owningReflection, opts) && parent.categories) { + if (shouldShowCategories(parent.parent, opts) && parent.categories) { return filterMap(parent.categories, toNavigation); } return filterMap(parent.children, toNavigation);