diff --git a/CHANGELOG.md b/CHANGELOG.md index 4224c42f1..e1f65e0e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Added support for the `@class` tag. When added to a comment on a variable or function, TypeDoc will convert the member as a class, #2479. Note: This should only be used on symbols which actually represent a class, but are not declared as a class for some reason. +## Features + +- Added support for `@groupDescription` and `@categoryDescription` to provide a description of groups and categories, #2494. + ## Bug Fixes - Fixed an issue where a namespace would not be created for merged function-namespaces which are declared as variables, #2478. diff --git a/example/src/classes/CancellablePromise.ts b/example/src/classes/CancellablePromise.ts index 53057b4ed..12427f9a5 100644 --- a/example/src/classes/CancellablePromise.ts +++ b/example/src/classes/CancellablePromise.ts @@ -42,6 +42,12 @@ function isPromiseWithCancel(value: unknown): value is PromiseWithCancel { * [real-cancellable-promise](https://github.com/srmagura/real-cancellable-promise). * * @typeParam T what the `CancellablePromise` resolves to + * + * @groupDescription Methods + * Descriptions can be added for groups with `@groupDescription`, which will show up in + * the index where groups are listed. This works for both manually created groups which + * are created with `@group`, and implicit groups like the `Methods` group that this + * description is attached to. */ export class CancellablePromise { /** diff --git a/example/src/index.ts b/example/src/index.ts index f1e900adb..9d7d76b88 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -1,3 +1,9 @@ +/** + * @packageDocumentation + * @categoryDescription Component + * React Components -- This description is added with the `@categoryDescription` tag + * on the entry point in src/index.ts + */ export * from "./functions"; export * from "./variables"; export * from "./types"; diff --git a/src/index.ts b/src/index.ts index 7bad26177..1cb656319 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,13 @@ export { EventDispatcher, Event } from "./lib/utils/events"; export { resetReflectionID } from "./lib/models/reflections/abstract"; /** * All symbols documented under the Models namespace are also available in the root import. + * + * @categoryDescription Types + * Describes a TypeScript type. + * + * @categoryDescription Reflections + * Describes a documentation entry. The root entry is a {@link ProjectReflection} + * and contains {@link DeclarationReflection} instances. */ export * as Models from "./lib/models"; /** diff --git a/src/lib/converter/plugins/CategoryPlugin.ts b/src/lib/converter/plugins/CategoryPlugin.ts index d91de7dc0..106b053a6 100644 --- a/src/lib/converter/plugins/CategoryPlugin.ts +++ b/src/lib/converter/plugins/CategoryPlugin.ts @@ -7,7 +7,7 @@ import { ReflectionCategory } from "../../models"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import type { Context } from "../context"; -import { Option, getSortFunction } from "../../utils"; +import { Option, getSortFunction, removeIf } from "../../utils"; /** * A handler that sorts and categorizes the found reflections in the resolving phase. @@ -113,7 +113,10 @@ export class CategoryPlugin extends ConverterComponent { obj.groups.forEach((group) => { if (group.categories) return; - group.categories = this.getReflectionCategories(group.children); + group.categories = this.getReflectionCategories( + obj, + group.children, + ); if (group.categories && group.categories.length > 1) { group.categories.sort(CategoryPlugin.sortCatCallback); } else if ( @@ -130,7 +133,7 @@ export class CategoryPlugin extends ConverterComponent { if (!obj.children || obj.children.length === 0 || obj.categories) { return; } - obj.categories = this.getReflectionCategories(obj.children); + obj.categories = this.getReflectionCategories(obj, obj.children); if (obj.categories && obj.categories.length > 1) { obj.categories.sort(CategoryPlugin.sortCatCallback); } else if ( @@ -151,6 +154,7 @@ export class CategoryPlugin extends ConverterComponent { * @returns An array containing all children of the given reflection categorized */ private getReflectionCategories( + parent: ContainerReflection, reflections: DeclarationReflection[], ): ReflectionCategory[] { const categories = new Map(); @@ -174,6 +178,27 @@ export class CategoryPlugin extends ConverterComponent { } } + if (parent.comment) { + removeIf(parent.comment.blockTags, (tag) => { + if (tag.tag === "@categoryDescription") { + const { header, body } = Comment.splitPartsToHeaderAndBody( + tag.content, + ); + const cat = categories.get(header); + if (cat) { + cat.description = body; + } else { + this.application.logger.warn( + `Comment for ${parent.getFriendlyFullName()} includes @categoryDescription for "${header}", but no child is placed in that category.`, + ); + } + + return true; + } + return false; + }); + } + for (const cat of categories.values()) { this.sortFunction(cat.children); } diff --git a/src/lib/converter/plugins/GroupPlugin.ts b/src/lib/converter/plugins/GroupPlugin.ts index f92cefd32..b1e997987 100644 --- a/src/lib/converter/plugins/GroupPlugin.ts +++ b/src/lib/converter/plugins/GroupPlugin.ts @@ -101,7 +101,10 @@ export class GroupPlugin extends ConverterComponent { ) { this.sortFunction(reflection.children); } - reflection.groups = this.getReflectionGroups(reflection.children); + reflection.groups = this.getReflectionGroups( + reflection, + reflection.children, + ); } } @@ -162,6 +165,7 @@ export class GroupPlugin extends ConverterComponent { * @returns An array containing all children of the given reflection grouped by their kind. */ getReflectionGroups( + parent: ContainerReflection, reflections: DeclarationReflection[], ): ReflectionGroup[] { const groups = new Map(); @@ -178,6 +182,27 @@ export class GroupPlugin extends ConverterComponent { } }); + if (parent.comment) { + removeIf(parent.comment.blockTags, (tag) => { + if (tag.tag === "@groupDescription") { + const { header, body } = Comment.splitPartsToHeaderAndBody( + tag.content, + ); + const cat = groups.get(header); + if (cat) { + cat.description = body; + } else { + this.application.logger.warn( + `Comment for ${parent.getFriendlyFullName()} includes @groupDescription for "${header}", but no child is placed in that group.`, + ); + } + + return true; + } + return false; + }); + } + return Array.from(groups.values()).sort(GroupPlugin.sortGroupCallback); } diff --git a/src/lib/converter/plugins/LinkResolverPlugin.ts b/src/lib/converter/plugins/LinkResolverPlugin.ts index 148118161..85ba48b88 100644 --- a/src/lib/converter/plugins/LinkResolverPlugin.ts +++ b/src/lib/converter/plugins/LinkResolverPlugin.ts @@ -2,7 +2,13 @@ import { Component, ConverterComponent } from "../components"; import type { Context, ExternalResolveResult } from "../../converter"; import { ConverterEvents } from "../converter-events"; import { Option, ValidationOptions } from "../../utils"; -import { DeclarationReflection, ProjectReflection } from "../../models"; +import { + ContainerReflection, + DeclarationReflection, + ProjectReflection, + Reflection, + ReflectionCategory, +} from "../../models"; import { discoverAllReferenceTypes } from "../../utils/reflections"; import { ApplicationEvents } from "../../application-events"; @@ -45,6 +51,31 @@ export class LinkResolverPlugin extends ConverterComponent { reflection, ); } + + if (reflection instanceof ContainerReflection) { + if (reflection.groups) { + for (const group of reflection.groups) { + if (group.description) { + group.description = this.owner.resolveLinks( + group.description, + reflection, + ); + } + + if (group.categories) { + for (const cat of group.categories) { + this.resolveCategoryLinks(cat, reflection); + } + } + } + } + + if (reflection.categories) { + for (const cat of reflection.categories) { + this.resolveCategoryLinks(cat, reflection); + } + } + } } if (project.readme) { @@ -75,4 +106,16 @@ export class LinkResolverPlugin extends ConverterComponent { } } } + + private resolveCategoryLinks( + category: ReflectionCategory, + owner: Reflection, + ) { + if (category.description) { + category.description = this.owner.resolveLinks( + category.description, + owner, + ); + } + } } diff --git a/src/lib/models/ReflectionCategory.ts b/src/lib/models/ReflectionCategory.ts index 42351f60e..fea87de0d 100644 --- a/src/lib/models/ReflectionCategory.ts +++ b/src/lib/models/ReflectionCategory.ts @@ -1,4 +1,8 @@ -import type { DeclarationReflection } from "."; +import { + Comment, + type CommentDisplayPart, + type DeclarationReflection, +} from "."; import type { Serializer, JSONOutput, Deserializer } from "../serialization"; /** @@ -14,6 +18,11 @@ export class ReflectionCategory { */ title: string; + /** + * The user specified description, if any, set with `@categoryDescription` + */ + description?: CommentDisplayPart[]; + /** * All reflections of this category. */ @@ -35,9 +44,12 @@ export class ReflectionCategory { return this.children.every((child) => child.hasOwnDocument); } - toObject(_serializer: Serializer): JSONOutput.ReflectionCategory { + toObject(serializer: Serializer): JSONOutput.ReflectionCategory { return { title: this.title, + description: this.description + ? Comment.serializeDisplayParts(serializer, this.description) + : undefined, children: this.children.length > 0 ? this.children.map((child) => child.id) @@ -46,6 +58,13 @@ export class ReflectionCategory { } fromObject(de: Deserializer, obj: JSONOutput.ReflectionCategory) { + if (obj.description) { + this.description = Comment.deserializeDisplayParts( + de, + obj.description, + ); + } + if (obj.children) { de.defer((project) => { for (const childId of obj.children || []) { diff --git a/src/lib/models/ReflectionGroup.ts b/src/lib/models/ReflectionGroup.ts index eb3479b3e..300b13e32 100644 --- a/src/lib/models/ReflectionGroup.ts +++ b/src/lib/models/ReflectionGroup.ts @@ -1,5 +1,10 @@ import { ReflectionCategory } from "./ReflectionCategory"; -import type { DeclarationReflection, Reflection } from "."; +import { + Comment, + type CommentDisplayPart, + type DeclarationReflection, + type Reflection, +} from "."; import type { Serializer, JSONOutput, Deserializer } from "../serialization"; /** @@ -15,6 +20,11 @@ export class ReflectionGroup { */ title: string; + /** + * User specified description via `@groupDescription`, if specified. + */ + description?: CommentDisplayPart[]; + /** * All reflections of this group. */ @@ -48,6 +58,9 @@ export class ReflectionGroup { toObject(serializer: Serializer): JSONOutput.ReflectionGroup { return { title: this.title, + description: this.description + ? Comment.serializeDisplayParts(serializer, this.description) + : undefined, children: this.children.length > 0 ? this.children.map((child) => child.id) @@ -57,6 +70,13 @@ export class ReflectionGroup { } fromObject(de: Deserializer, obj: JSONOutput.ReflectionGroup) { + if (obj.description) { + this.description = Comment.deserializeDisplayParts( + de, + obj.description, + ); + } + if (obj.categories) { this.categories = obj.categories.map((catObj) => { const cat = new ReflectionCategory(catObj.title); diff --git a/src/lib/models/comments/comment.ts b/src/lib/models/comments/comment.ts index abefe74dd..26096448d 100644 --- a/src/lib/models/comments/comment.ts +++ b/src/lib/models/comments/comment.ts @@ -194,7 +194,7 @@ export class Comment { /** * Helper utility to clone {@link Comment.summary} or {@link CommentTag.content} */ - static cloneDisplayParts(parts: CommentDisplayPart[]) { + static cloneDisplayParts(parts: readonly CommentDisplayPart[]) { return parts.map((p) => ({ ...p })); } @@ -304,6 +304,61 @@ export class Comment { return result; } + /** + * Splits the provided parts into a header (first line, as a string) + * and body (remaining lines). If the header line contains inline tags + * they will be serialized to a string. + */ + static splitPartsToHeaderAndBody(parts: readonly CommentDisplayPart[]): { + header: string; + body: CommentDisplayPart[]; + } { + let index = parts.findIndex((part): boolean => { + switch (part.kind) { + case "text": + case "code": + return part.text.includes("\n"); + case "inline-tag": + return false; + } + }); + + if (index === -1) { + return { + header: Comment.combineDisplayParts(parts), + body: [], + }; + } + + // Do not split a code block, stop the header at the end of the previous block + if (parts[index].kind === "code") { + --index; + } + + if (index === -1) { + return { header: "", body: Comment.cloneDisplayParts(parts) }; + } + + let header = Comment.combineDisplayParts(parts.slice(0, index)); + const split = parts[index].text.indexOf("\n"); + + let body: CommentDisplayPart[]; + if (split === -1) { + header += parts[index].text; + body = Comment.cloneDisplayParts(parts.slice(index + 1)); + } else { + header += parts[index].text.substring(0, split); + body = Comment.cloneDisplayParts(parts.slice(index)); + body[0].text = body[0].text.substring(split + 1); + } + + if (!body[0].text) { + body.shift(); + } + + return { header: header.trim(), body }; + } + /** * The content of the comment which is not associated with a block tag. */ diff --git a/src/lib/output/themes/default/partials/index.tsx b/src/lib/output/themes/default/partials/index.tsx index 654ff27b9..a68058234 100644 --- a/src/lib/output/themes/default/partials/index.tsx +++ b/src/lib/output/themes/default/partials/index.tsx @@ -1,16 +1,21 @@ import { classNames, renderName } from "../../lib"; import type { DefaultThemeRenderContext } from "../DefaultThemeRenderContext"; -import { JSX } from "../../../../utils"; -import type { ContainerReflection, ReflectionCategory } from "../../../../models"; +import { JSX, Raw } from "../../../../utils"; +import type { ContainerReflection, ReflectionCategory, ReflectionGroup } from "../../../../models"; function renderCategory( - { urlTo, icons, getReflectionClasses }: DefaultThemeRenderContext, - item: ReflectionCategory, + { urlTo, icons, getReflectionClasses, markdown }: DefaultThemeRenderContext, + item: ReflectionCategory | ReflectionGroup, prependName = "", ) { return (

{prependName ? `${prependName} - ${item.title}` : item.title}

+ {item.description && ( +
+ +
+ )}
{item.children.map((item) => ( <> diff --git a/src/lib/serialization/schema.ts b/src/lib/serialization/schema.ts index 1cf7f34ee..ce4f19e83 100644 --- a/src/lib/serialization/schema.ts +++ b/src/lib/serialization/schema.ts @@ -103,11 +103,12 @@ export interface ReflectionSymbolId { } export interface ReflectionGroup - extends S { + extends S { children?: M.ReflectionGroup["children"][number]["id"][]; } -export interface ReflectionCategory extends S { +export interface ReflectionCategory + extends S { children?: M.ReflectionCategory["children"][number]["id"][]; } diff --git a/src/lib/utils/events.ts b/src/lib/utils/events.ts index d53d55099..102785738 100644 --- a/src/lib/utils/events.ts +++ b/src/lib/utils/events.ts @@ -7,6 +7,8 @@ // The Events object is a typesafe conversion of Backbones Events object: // https://github.com/jashkenas/backbone/blob/05fde9e201f7e2137796663081105cd6dad12a98/backbone.js#L119-L374 +// Priority: Higher number makes the listener be called earlier. + const uniqueId = (function () { const prefixes: Record = Object.create(null); return function (prefix: string) { diff --git a/src/lib/utils/options/tsdoc-defaults.ts b/src/lib/utils/options/tsdoc-defaults.ts index c562cf438..9ae30e874 100644 --- a/src/lib/utils/options/tsdoc-defaults.ts +++ b/src/lib/utils/options/tsdoc-defaults.ts @@ -1,4 +1,4 @@ -// If updating these lists, also see .config/typedoc-default.tsdoc.json +// If updating these lists, also update tsdoc.json export const tsdocBlockTags = [ "@deprecated", @@ -16,7 +16,9 @@ export const blockTags = [ "@module", "@inheritDoc", "@group", + "@groupDescription", "@category", + "@categoryDescription", // Alias for @typeParam "@template", // Because TypeScript is important! diff --git a/src/test/behavior.c2.test.ts b/src/test/behavior.c2.test.ts index c72cd45a8..9ce77f850 100644 --- a/src/test/behavior.c2.test.ts +++ b/src/test/behavior.c2.test.ts @@ -518,6 +518,13 @@ describe("Behavior Tests", () => { "With Spaces", ]); + equal( + project.groups?.map((g) => + Comment.combineDisplayParts(g.description), + ), + ["Variables desc", "A description", "", "With spaces desc"], + ); + equal( project.groups.map((g) => g.children), [[D], [A, B], [B], [C]], @@ -539,6 +546,12 @@ describe("Behavior Tests", () => { const project = convert("categoryInheritance"); const cls = query(project, "Cls"); equal(cls.categories?.map((g) => g.title), ["Cat", "Other"]); + equal( + cls.categories?.map((g) => + Comment.combineDisplayParts(g.description), + ), + ["Cat desc", ""], + ); equal( cls.categories.map((g) => g.children), [[query(project, "Cls.prop")], [query(project, "Cls.constructor")]], diff --git a/src/test/converter/comment/comment.ts b/src/test/converter/comment/comment.ts index c6a3d2d9a..8f84e9263 100644 --- a/src/test/converter/comment/comment.ts +++ b/src/test/converter/comment/comment.ts @@ -28,6 +28,9 @@ import "./comment2"; * @todo something * * @type {Data} will also be removed + * + * @groupDescription Methods + * Methods description! */ export class CommentedClass { /** diff --git a/src/test/converter/comment/specs.json b/src/test/converter/comment/specs.json index 67458dcbd..81789794b 100644 --- a/src/test/converter/comment/specs.json +++ b/src/test/converter/comment/specs.json @@ -85,9 +85,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 36, + "line": 39, "character": 4, - "url": "typedoc://comment.ts#L36" + "url": "typedoc://comment.ts#L39" } ], "type": { @@ -104,9 +104,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 76, + "line": 79, "character": 4, - "url": "typedoc://comment.ts#L76" + "url": "typedoc://comment.ts#L79" } ], "signatures": [ @@ -127,9 +127,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 76, + "line": 79, "character": 4, - "url": "typedoc://comment.ts#L76" + "url": "typedoc://comment.ts#L79" } ], "parameters": [ @@ -179,6 +179,12 @@ }, { "title": "Methods", + "description": [ + { + "kind": "text", + "text": "Methods description!" + } + ], "children": [ 19 ] @@ -187,9 +193,9 @@ "sources": [ { "fileName": "comment.ts", - "line": 32, + "line": 35, "character": 13, - "url": "typedoc://comment.ts#L32" + "url": "typedoc://comment.ts#L35" } ] } diff --git a/src/test/converter2/behavior/categoryInheritance.ts b/src/test/converter2/behavior/categoryInheritance.ts index ad31e6272..c41431405 100644 --- a/src/test/converter2/behavior/categoryInheritance.ts +++ b/src/test/converter2/behavior/categoryInheritance.ts @@ -1,3 +1,7 @@ +/** + * @categoryDescription Cat + * Cat desc + */ export interface Int { /** @category Cat */ prop: string; diff --git a/src/test/converter2/behavior/groupTag.ts b/src/test/converter2/behavior/groupTag.ts index f6a636732..f698649b0 100644 --- a/src/test/converter2/behavior/groupTag.ts +++ b/src/test/converter2/behavior/groupTag.ts @@ -1,3 +1,13 @@ +/** + * @groupDescription Variables + * Variables desc + * @groupDescription A + * A description + * @groupDescription With Spaces + * With spaces desc + * @module + */ + /** * @group A */ diff --git a/src/test/models/comment.test.ts b/src/test/models/comment.test.ts new file mode 100644 index 000000000..3cbdb735a --- /dev/null +++ b/src/test/models/comment.test.ts @@ -0,0 +1,86 @@ +import { deepStrictEqual as equal } from "assert"; +import { Comment, CommentDisplayPart } from "../../index"; + +describe("Comment.combineDisplayParts", () => { + it("Handles text and code", () => { + const parts: CommentDisplayPart[] = [ + { kind: "text", text: "a" }, + { kind: "code", text: "`b`" }, + ]; + equal(Comment.combineDisplayParts(parts), "a`b`"); + }); + + it("Handles inline tags", () => { + const parts: CommentDisplayPart[] = [ + { kind: "inline-tag", text: "`b`", tag: "@test" }, + ]; + equal(Comment.combineDisplayParts(parts), "{@test `b`}"); + }); +}); + +describe("Comment.splitPartsToHeaderAndBody", () => { + it("Handles a simple case", () => { + const parts: CommentDisplayPart[] = [{ kind: "text", text: "a\nb" }]; + + equal(Comment.splitPartsToHeaderAndBody(parts), { + header: "a", + body: [{ kind: "text", text: "b" }], + }); + }); + + it("Refuses to split a code block", () => { + const parts: CommentDisplayPart[] = [{ kind: "code", text: "`a\nb`" }]; + + equal(Comment.splitPartsToHeaderAndBody(parts), { + header: "", + body: [{ kind: "code", text: "`a\nb`" }], + }); + }); + + it("Handles a newline in a code block after text", () => { + const parts: CommentDisplayPart[] = [ + { kind: "text", text: "Header" }, + { kind: "code", text: "`a\nb`" }, + ]; + + equal(Comment.splitPartsToHeaderAndBody(parts), { + header: "Header", + body: [{ kind: "code", text: "`a\nb`" }], + }); + }); + + it("Handles header consisting of multiple display parts", () => { + const parts: CommentDisplayPart[] = [ + { kind: "text", text: "Header" }, + { kind: "text", text: " more " }, + { kind: "text", text: "text\nbody" }, + ]; + + equal(Comment.splitPartsToHeaderAndBody(parts), { + header: "Header more text", + body: [{ kind: "text", text: "body" }], + }); + }); + + it("Handles empty body", () => { + const parts: CommentDisplayPart[] = [ + { kind: "text", text: "Header\n" }, + ]; + + equal(Comment.splitPartsToHeaderAndBody(parts), { + header: "Header", + body: [], + }); + }); + + it("Trims the header text", () => { + const parts: CommentDisplayPart[] = [ + { kind: "text", text: "Header \n" }, + ]; + + equal(Comment.splitPartsToHeaderAndBody(parts), { + header: "Header", + body: [], + }); + }); +}); diff --git a/tsdoc.json b/tsdoc.json index b9c6d049e..5e5590ec3 100644 --- a/tsdoc.json +++ b/tsdoc.json @@ -31,11 +31,21 @@ "syntaxKind": "block", "allowMultiple": true }, + { + "tagName": "@groupDescription", + "syntaxKind": "block", + "allowMultiple": true + }, { "tagName": "@category", "syntaxKind": "block", "allowMultiple": true }, + { + "tagName": "@categoryDescription", + "syntaxKind": "block", + "allowMultiple": true + }, { "tagName": "@hidden", "syntaxKind": "modifier"