diff --git a/example/src/classes/Customer.ts b/example/src/classes/Customer.ts index afe1122a3..1c42329e7 100644 --- a/example/src/classes/Customer.ts +++ b/example/src/classes/Customer.ts @@ -2,6 +2,8 @@ * An abstract base class for the customer entity in our application. * * Notice how TypeDoc shows the inheritance hierarchy for our class. + * + * @category Model */ export abstract class Customer { /** A public readonly property. */ diff --git a/example/src/reactComponents.tsx b/example/src/reactComponents.tsx index 2806a0571..6df57b24e 100644 --- a/example/src/reactComponents.tsx +++ b/example/src/reactComponents.tsx @@ -36,6 +36,8 @@ export interface CardAProps { * This is our recommended way to define React components as it makes your code * more readable. The minor drawback is you must click the `CardAProps` link to * see the component's props. + * + * @category Component */ export function CardA({ children, variant = "primary" }: PropsWithChildren): ReactElement { return
{children}
; @@ -66,6 +68,8 @@ export function CardA({ children, variant = "primary" }: PropsWithChildren): ReactElement { return
; diff --git a/example/typedoc.json b/example/typedoc.json index d9dd2ca10..5c68ffca2 100644 --- a/example/typedoc.json +++ b/example/typedoc.json @@ -2,5 +2,13 @@ "$schema": "https://typedoc.org/schema.json", "entryPoints": ["./src"], "sort": ["source-order"], - "media": "media" + "media": "media", + "categorizeByGroup": false, + "searchCategoryBoosts": { + "Component": 2, + "Model": 1.2 + }, + "searchGroupBoosts": { + "Class": 1.5 + } } diff --git a/src/lib/converter/context.ts b/src/lib/converter/context.ts index 63a90f498..22db86884 100644 --- a/src/lib/converter/context.ts +++ b/src/lib/converter/context.ts @@ -14,6 +14,7 @@ import type { Converter } from "./converter"; import { isNamedNode } from "./utils/nodes"; import { ConverterEvents } from "./converter-events"; import { resolveAliasedSymbol } from "./utils/symbols"; +import type { SearchConfig } from "../utils/options/declaration"; /** * The context describes the current state the converter is in. @@ -118,6 +119,17 @@ export class Context { return this.converter.application.options.getCompilerOptions(); } + getSearchOptions(): SearchConfig { + return { + searchCategoryBoosts: this.converter.application.options.getValue( + "searchCategoryBoosts" + ) as SearchConfig["searchCategoryBoosts"], + searchGroupBoosts: this.converter.application.options.getValue( + "searchGroupBoosts" + ) as SearchConfig["searchGroupBoosts"], + }; + } + /** * Return the type declaration of the given node. * diff --git a/src/lib/converter/plugins/CategoryPlugin.ts b/src/lib/converter/plugins/CategoryPlugin.ts index 2a2276753..d04982163 100644 --- a/src/lib/converter/plugins/CategoryPlugin.ts +++ b/src/lib/converter/plugins/CategoryPlugin.ts @@ -4,12 +4,12 @@ import { DeclarationReflection, CommentTag, } from "../../models"; -import { ReflectionCategory } from "../../models/ReflectionCategory"; +import { ReflectionCategory } from "../../models"; import { Component, ConverterComponent } from "../components"; import { Converter } from "../converter"; import type { Context } from "../context"; import { BindOption } from "../../utils"; -import type { Comment } from "../../models/comments/index"; +import type { Comment } from "../../models"; /** * A handler that sorts and categorizes the found reflections in the resolving phase. @@ -66,9 +66,12 @@ export class CategoryPlugin extends ConverterComponent { * @param context The context object describing the current state the converter is in. * @param reflection The reflection that is currently resolved. */ - private onResolve(_context: Context, reflection: Reflection) { + private onResolve(context: Context, reflection: Reflection) { if (reflection instanceof ContainerReflection) { - this.categorize(reflection); + this.categorize( + reflection, + context.getSearchOptions()?.searchCategoryBoosts ?? {} + ); } } @@ -79,18 +82,27 @@ export class CategoryPlugin extends ConverterComponent { */ private onEndResolve(context: Context) { const project = context.project; - this.categorize(project); + this.categorize( + project, + context.getSearchOptions()?.searchCategoryBoosts ?? {} + ); } - private categorize(obj: ContainerReflection) { + private categorize( + obj: ContainerReflection, + categorySearchBoosts: { [key: string]: number } + ) { if (this.categorizeByGroup) { - this.groupCategorize(obj); + this.groupCategorize(obj, categorySearchBoosts); } else { - this.lumpCategorize(obj); + CategoryPlugin.lumpCategorize(obj, categorySearchBoosts); } } - private groupCategorize(obj: ContainerReflection) { + private groupCategorize( + obj: ContainerReflection, + categorySearchBoosts: { [key: string]: number } + ) { if (!obj.groups || obj.groups.length === 0) { return; } @@ -98,7 +110,8 @@ export class CategoryPlugin extends ConverterComponent { if (group.categories) return; group.categories = CategoryPlugin.getReflectionCategories( - group.children + group.children, + categorySearchBoosts ); if (group.categories && group.categories.length > 1) { group.categories.sort(CategoryPlugin.sortCatCallback); @@ -112,11 +125,17 @@ export class CategoryPlugin extends ConverterComponent { }); } - private lumpCategorize(obj: ContainerReflection) { + static lumpCategorize( + obj: ContainerReflection, + categorySearchBoosts: { [key: string]: number } + ) { if (!obj.children || obj.children.length === 0 || obj.categories) { return; } - obj.categories = CategoryPlugin.getReflectionCategories(obj.children); + obj.categories = CategoryPlugin.getReflectionCategories( + obj.children, + categorySearchBoosts + ); if (obj.categories && obj.categories.length > 1) { obj.categories.sort(CategoryPlugin.sortCatCallback); } else if ( @@ -132,10 +151,13 @@ export class CategoryPlugin extends ConverterComponent { * Create a categorized representation of the given list of reflections. * * @param reflections The reflections that should be categorized. + * @param categorySearchBoosts A user-supplied map of category titles, for computing a + * relevance boost to be used when searching * @returns An array containing all children of the given reflection categorized */ static getReflectionCategories( - reflections: DeclarationReflection[] + reflections: DeclarationReflection[], + categorySearchBoosts: { [key: string]: number } ): ReflectionCategory[] { const categories: ReflectionCategory[] = []; let defaultCat: ReflectionCategory | undefined; @@ -154,11 +176,18 @@ export class CategoryPlugin extends ConverterComponent { categories.push(defaultCat); } } + defaultCat.children.push(child); return; } for (const childCat of childCategories) { let category = categories.find((cat) => cat.title === childCat); + + const catBoost = categorySearchBoosts[category?.title ?? -1]; + if (catBoost != undefined) { + child.relevanceBoost = + (child.relevanceBoost ?? 1) * catBoost; + } if (category) { category.children.push(child); continue; diff --git a/src/lib/models/reflections/container.ts b/src/lib/models/reflections/container.ts index ae4455fcd..ea7570e54 100644 --- a/src/lib/models/reflections/container.ts +++ b/src/lib/models/reflections/container.ts @@ -20,6 +20,12 @@ export class ContainerReflection extends Reflection { */ categories?: ReflectionCategory[]; + /** + * A precomputed boost derived from the searchCategoryBoosts typedoc.json setting, to be used when + * boosting search relevance scores at runtime. + */ + relevanceBoost?: number; + /** * Return a list of all children of a certain kind. * diff --git a/src/lib/output/plugins/JavascriptIndexPlugin.ts b/src/lib/output/plugins/JavascriptIndexPlugin.ts index 9775b9d23..91dededb8 100644 --- a/src/lib/output/plugins/JavascriptIndexPlugin.ts +++ b/src/lib/output/plugins/JavascriptIndexPlugin.ts @@ -5,8 +5,8 @@ import { DeclarationReflection, ProjectReflection, ReflectionKind, -} from "../../models/reflections/index"; -import { GroupPlugin } from "../../converter/plugins/GroupPlugin"; +} from "../../models"; +import { GroupPlugin } from "../../converter/plugins"; import { Component, RendererComponent } from "../components"; import { RendererEvent } from "../events"; import { writeFileSync } from "../../utils"; @@ -42,6 +42,11 @@ export class JavascriptIndexPlugin extends RendererComponent { const rows: any[] = []; const kinds: { [K in ReflectionKind]?: string } = {}; + const kindBoosts = + (this.application.options.getValue("searchGroupBoosts") as { + [key: string]: number; + }) ?? {}; + for (const reflection of event.project.getReflectionsByKind( ReflectionKind.All )) { @@ -59,10 +64,22 @@ export class JavascriptIndexPlugin extends RendererComponent { } let parent = reflection.parent; + let boost = reflection.relevanceBoost ?? 1; if (parent instanceof ProjectReflection) { parent = undefined; } + if (!kinds[reflection.kind]) { + kinds[reflection.kind] = GroupPlugin.getKindSingular( + reflection.kind + ); + + const kindBoost = kindBoosts[kinds[reflection.kind] ?? ""]; + if (kindBoost != undefined) { + boost *= kindBoost; + } + } + const row: any = { id: rows.length, kind: reflection.kind, @@ -71,14 +88,12 @@ export class JavascriptIndexPlugin extends RendererComponent { classes: reflection.cssClasses, }; - if (parent) { - row.parent = parent.getFullName(); + if (boost !== 1) { + row.boost = boost; } - if (!kinds[reflection.kind]) { - kinds[reflection.kind] = GroupPlugin.getKindSingular( - reflection.kind - ); + if (parent) { + row.parent = parent.getFullName(); } rows.push(row); @@ -100,6 +115,7 @@ export class JavascriptIndexPlugin extends RendererComponent { "assets", "search.js" ); + const jsonData = JSON.stringify({ kinds, rows, diff --git a/src/lib/output/themes/default/assets/typedoc/components/Search.ts b/src/lib/output/themes/default/assets/typedoc/components/Search.ts index 95147f5e2..07de070cb 100644 --- a/src/lib/output/themes/default/assets/typedoc/components/Search.ts +++ b/src/lib/output/themes/default/assets/typedoc/components/Search.ts @@ -6,8 +6,9 @@ interface IDocument { kind: number; name: string; url: string; - classes: string; + classes?: string; parent?: string; + boost?: number; } interface IData { @@ -154,7 +155,26 @@ function updateResults( // Perform a wildcard search // Set empty `res` to prevent getting random results with wildcard search // when the `searchText` is empty. - const res = searchText ? state.index.search(`*${searchText}*`) : []; + let res = searchText ? state.index.search(`*${searchText}*`) : []; + + for (let i = 0; i < res.length; i++) { + const item = res[i]; + const row = state.data.rows[Number(item.ref)]; + let boost = 1; + + // boost by exact match on name + if (row.name.toLowerCase().startsWith(searchText.toLowerCase())) { + boost *= + 1 + 1 / (Math.abs(row.name.length - searchText.length) * 10); + } + + // boost by relevanceBoost + boost *= row.boost ?? 1; + + item.score *= boost; + } + + res.sort((a, b) => b.score - a.score); for (let i = 0, c = Math.min(10, res.length); i < c; i++) { const row = state.data.rows[Number(res[i].ref)]; @@ -169,7 +189,7 @@ function updateResults( } const item = document.createElement("li"); - item.classList.value = row.classes; + item.classList.value = row.classes ?? ""; const anchor = document.createElement("a"); anchor.href = state.base + row.url; @@ -199,11 +219,11 @@ function setCurrentResult(results: HTMLElement, dir: number) { // current with the arrow keys. if (dir === 1) { do { - rel = rel.nextElementSibling; + rel = rel.nextElementSibling ?? undefined; } while (rel instanceof HTMLElement && rel.offsetParent == null); } else { do { - rel = rel.previousElementSibling; + rel = rel.previousElementSibling ?? undefined; } while (rel instanceof HTMLElement && rel.offsetParent == null); } diff --git a/src/lib/utils/options/declaration.ts b/src/lib/utils/options/declaration.ts index 5f3728207..99a0be810 100644 --- a/src/lib/utils/options/declaration.ts +++ b/src/lib/utils/options/declaration.ts @@ -3,7 +3,7 @@ import type { LogLevel } from "../loggers"; import type { SortStrategy } from "../sort"; import { isAbsolute, join, resolve } from "path"; import type { EntryPointStrategy } from "../entry-point"; -import type { ReflectionKind } from "../../models/reflections/kind"; +import { ReflectionKind } from "../../models/reflections/kind"; export const EmitStrategy = { true: true, // Alias for both, for backwards compatibility until 0.23 @@ -50,6 +50,12 @@ export type TypeDocOptionValues = { : TypeDocOptionMap[K][keyof TypeDocOptionMap[K]]; }; +const Kinds = Object.values(ReflectionKind); +export interface SearchConfig { + searchGroupBoosts?: { [key: typeof Kinds[number]]: number }; + searchCategoryBoosts?: { [key: string]: number }; +} + /** * Describes all TypeDoc options. Used internally to provide better types when fetching options. * External consumers should likely use {@link TypeDocOptions} instead. @@ -107,6 +113,8 @@ export interface TypeDocOptionMap { version: boolean; showConfig: boolean; plugin: string[]; + searchCategoryBoosts: unknown; + searchGroupBoosts: unknown; logger: unknown; // string | Function logLevel: typeof LogLevel; markedOptions: unknown; diff --git a/src/lib/utils/options/sources/typedoc.ts b/src/lib/utils/options/sources/typedoc.ts index 78fd34d84..e62192f5c 100644 --- a/src/lib/utils/options/sources/typedoc.ts +++ b/src/lib/utils/options/sources/typedoc.ts @@ -70,6 +70,56 @@ export function addTypeDocOptions(options: Pick) { help: "Ignore protected variables and methods.", type: ParameterType.Boolean, }); + options.addDeclaration({ + name: "searchCategoryBoosts", + help: "Configure search to give a relevance boost to selected categories", + type: ParameterType.Mixed, + validate(value) { + if (!isObject(value)) { + throw new Error( + "The 'searchCategoryBoosts' option must be a non-array object." + ); + } + + if (Object.values(value).some((x) => typeof x !== "number")) { + throw new Error( + "All values of 'searchCategoryBoosts' must be numbers." + ); + } + }, + }); + options.addDeclaration({ + name: "searchGroupBoosts", + help: 'Configure search to give a relevance boost to selected kinds (eg "class")', + type: ParameterType.Mixed, + validate(value: unknown) { + if (!isObject(value)) { + throw new Error( + "The 'searchGroupBoosts' option must be a non-array object." + ); + } + + const validValues = Object.values(ReflectionKind) + .filter((v) => typeof v === "string") + .map((v) => v.toString()); + + for (const kindName in value) { + if (validValues.indexOf(kindName) < 0) { + throw new Error( + `'${kindName}' is an invalid value for 'searchGroupBoosts'. Must be one of: ${validValues.join( + ", " + )}` + ); + } + } + + if (Object.values(value).some((x) => typeof x !== "number")) { + throw new Error( + "All values of 'searchGroupBoosts' must be numbers." + ); + } + }, + }); options.addDeclaration({ name: "disableSources", help: "Disable setting the source of a reflection when documenting it.", @@ -316,11 +366,7 @@ export function addTypeDocOptions(options: Pick) { help: "Specify the options passed to Marked, the Markdown parser used by TypeDoc.", type: ParameterType.Mixed, validate(value) { - if ( - typeof value !== "object" || - Array.isArray(value) || - value == null - ) { + if (!isObject(value)) { throw new Error( "The 'markedOptions' option must be a non-array object." ); @@ -332,11 +378,7 @@ export function addTypeDocOptions(options: Pick) { help: "Selectively override the TypeScript compiler options used by TypeDoc.", type: ParameterType.Mixed, validate(value) { - if ( - typeof value !== "object" || - Array.isArray(value) || - value == null - ) { + if (!isObject(value)) { throw new Error( "The 'compilerOptions' option must be a non-array object." ); @@ -404,3 +446,7 @@ export function addTypeDocOptions(options: Pick) { }, }); } + +function isObject(x: unknown): x is Record { + return !!x && typeof x === "object" && !Array.isArray(x); +} diff --git a/src/lib/utils/sort.ts b/src/lib/utils/sort.ts index d06afe635..d012a137f 100644 --- a/src/lib/utils/sort.ts +++ b/src/lib/utils/sort.ts @@ -5,7 +5,7 @@ import { ReflectionKind } from "../models/reflections/kind"; import type { DeclarationReflection } from "../models/reflections/declaration"; -import { LiteralType } from "../models"; +import { LiteralType } from "../models/types"; export const SORT_STRATEGIES = [ "source-order", diff --git a/src/test/utils/options/default-options.test.ts b/src/test/utils/options/default-options.test.ts index 5a2a23fac..750aac1c2 100644 --- a/src/test/utils/options/default-options.test.ts +++ b/src/test/utils/options/default-options.test.ts @@ -76,4 +76,36 @@ describe("Default Options", () => { ); }); }); + + describe("searchCategoryBoosts", () => { + it("Should disallow non-objects", () => { + throws(() => opts.setValue("searchCategoryBoosts", null as never)); + }); + + it("Should disallow non-numbers", () => { + throws(() => + opts.setValue("searchCategoryBoosts", { + cat: true, + }) + ); + }); + }); + + describe("searchGroupBoosts", () => { + it("Should disallow non-objects", () => { + throws(() => opts.setValue("searchGroupBoosts", null as never)); + }); + + it("Should disallow invalid kind names", () => { + throws(() => opts.setValue("searchGroupBoosts", { Enum2: 123 })); + }); + + it("Should disallow non-numbers", () => { + throws(() => opts.setValue("searchGroupBoosts", { Enum: true })); + }); + + it("Should allow groups", () => { + doesNotThrow(() => opts.setValue("searchGroupBoosts", { Enum: 5 })); + }); + }); }); diff --git a/tsconfig.json b/tsconfig.json index 96b7610d4..21a96d728 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,29 +3,22 @@ "module": "CommonJS", "lib": ["es2019", "es2020.promise", "es2020.bigint", "es2020.string"], "target": "es2019", - // Add our `ts` internal types "typeRoots": ["node_modules/@types", "src/lib/types"], "types": ["node", "glob", "lunr", "marked", "minimatch", "mocha"], - // Speed up dev compilation time "incremental": true, "tsBuildInfoFile": "node_modules/.cache/.tsbuildinfo", - "experimentalDecorators": true, - "strict": true, "alwaysStrict": true, - // For tests "resolveJsonModule": true, - // Linting "noUnusedLocals": true, "noUnusedParameters": true, "forceConsistentCasingInFileNames": true, "importsNotUsedAsValues": "error", - // Library "preserveConstEnums": true, "declaration": true, @@ -33,12 +26,10 @@ "isolatedModules": true, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, - // Output "outDir": "dist/", "rootDir": "src/", "newLine": "LF", - "jsx": "react", "jsxFactory": "JSX.createElement", "jsxFragmentFactory": "JSX.Fragment"