Skip to content

Categorize sidebar #2255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

### Features

- Categories and groups can now be shown in the navigation, added `--navigation.includeCategories`
and `--navigation.includeGroups` to control this behavior. The `--categorizeByGroup` option also
effects this behavior. If `categorizeByGroup` is set (the default) and `navigation.includeGroups` is
_not_ set, the value of `navigation.includeCategories` will be effectively ignored since categories
will be created only within groups, #1532.
- Added support for discovering a "module" comment on global files, #2165.
- Added copy code to clipboard button, #2153.
- Function `@returns` blocks will now be rendered with the return type, #2180.
Expand Down
88 changes: 67 additions & 21 deletions src/lib/output/themes/default/partials/navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { DeclarationReflection, ProjectReflection, Reflection, ReflectionKind } from "../../../../models";
import {
DeclarationReflection,
ProjectReflection,
Reflection,
ReflectionCategory,
ReflectionGroup,
ReflectionKind,
} from "../../../../models";
import { JSX } from "../../../../utils";
import type { PageEvent } from "../../../events";
import { camelToTitleCase, classNames, getDisplayName, wbr } from "../../lib";
Expand Down Expand Up @@ -98,61 +105,100 @@ export function settings(context: DefaultThemeRenderContext) {
);
}

type NavigationElement = ReflectionCategory | ReflectionGroup | DeclarationReflection;

function getNavigationElements(
parent: NavigationElement | ProjectReflection,
opts: { includeCategories: boolean; includeGroups: boolean }
): NavigationElement[] {
if (parent instanceof ReflectionCategory) {
return parent.children;
}

if (parent instanceof ReflectionGroup) {
if (opts.includeCategories && parent.categories) {
return parent.categories;
}
return parent.children;
}

if (!parent.kindOf(ReflectionKind.SomeModule | ReflectionKind.Project)) {
return [];
}

if (parent.categories && opts.includeCategories) {
return parent.categories;
}

if (parent.groups && opts.includeGroups) {
return parent.groups;
}

return parent.children || [];
}

export function navigation(context: DefaultThemeRenderContext, props: PageEvent<Reflection>) {
const opts = context.options.getValue("navigation");
// Create the navigation for the current page
// Recurse to children if the parent is some kind of module

return (
<nav class="tsd-navigation">
{link(props.project)}
{createNavElement(props.project)}
<ul class="tsd-small-nested-navigation">
{props.project.children?.map((c) => (
<li>{links(c)}</li>
{getNavigationElements(props.project, opts).map((c) => (
<li>{links(c, [])}</li>
))}
</ul>
</nav>
);

function links(mod: DeclarationReflection) {
const children = (mod.kindOf(ReflectionKind.SomeModule | ReflectionKind.Project) && mod.children) || [];

function links(mod: NavigationElement, parents: string[]) {
const nameClasses = classNames(
{ deprecated: mod.isDeprecated() },
mod.isProject() ? void 0 : context.getReflectionClasses(mod)
{ deprecated: mod instanceof Reflection && mod.isDeprecated() },
!(mod instanceof Reflection) || mod.isProject() ? void 0 : context.getReflectionClasses(mod)
);

const children = getNavigationElements(mod, opts);

if (!children.length) {
return link(mod, nameClasses);
return createNavElement(mod, nameClasses);
}

return (
<details
class={classNames({ "tsd-index-accordion": true }, nameClasses)}
open={inPath(mod)}
data-key={mod.getFullName()}
open={mod instanceof Reflection && inPath(mod)}
data-key={mod instanceof Reflection ? mod.getFullName() : [...parents, mod.title].join("$")}
>
<summary class="tsd-accordion-summary">
{context.icons.chevronDown()}
{link(mod)}
{createNavElement(mod)}
</summary>
<div class="tsd-accordion-details">
<ul class="tsd-nested-navigation">
{children.map((c) => (
<li>{links(c)}</li>
<li>
{links(c, mod instanceof Reflection ? [mod.getFullName()] : [...parents, mod.title])}
</li>
))}
</ul>
</div>
</details>
);
}

function link(child: DeclarationReflection | ProjectReflection, nameClasses?: string) {
return (
<a href={context.urlTo(child)} class={classNames({ current: child === props.model }, nameClasses)}>
{context.icons[child.kind]()}
<span>{wbr(getDisplayName(child))}</span>
</a>
);
function createNavElement(child: NavigationElement | ProjectReflection, nameClasses?: string) {
if (child instanceof Reflection) {
return (
<a href={context.urlTo(child)} class={classNames({ current: child === props.model }, nameClasses)}>
{context.icons[child.kind]()}
<span>{wbr(getDisplayName(child))}</span>
</a>
);
}

return <span>{child.title}</span>;
}

function inPath(mod: DeclarationReflection | ProjectReflection) {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/utils/options/declaration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ export interface TypeDocOptionMap {
titleLink: string;
navigationLinks: ManuallyValidatedOption<Record<string, string>>;
sidebarLinks: ManuallyValidatedOption<Record<string, string>>;
navigation: {
includeCategories: boolean;
includeGroups: boolean;
};
visibilityFilters: ManuallyValidatedOption<{
protected?: boolean;
private?: boolean;
Expand Down
164 changes: 87 additions & 77 deletions src/lib/utils/options/sources/typedoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,92 @@ export function addTypeDocOptions(options: Pick<Options, "addDeclaration">) {
},
});

options.addDeclaration({
name: "navigation",
help: "Determines how the navigation sidebar is organized.",
type: ParameterType.Flags,
defaults: {
includeCategories: false,
includeGroups: false,
},
});

options.addDeclaration({
name: "visibilityFilters",
help: "Specify the default visibility for builtin filters and additional filters according to modifier tags.",
type: ParameterType.Mixed,
configFileOnly: true,
defaultValue: {
protected: false,
private: false,
inherited: true,
external: false,
},
validate(value) {
const knownKeys = ["protected", "private", "inherited", "external"];
if (!value || typeof value !== "object") {
throw new Error("visibilityFilters must be an object.");
}

for (const [key, val] of Object.entries(value)) {
if (!key.startsWith("@") && !knownKeys.includes(key)) {
throw new Error(
`visibilityFilters can only include the following non-@ keys: ${knownKeys.join(
", "
)}`
);
}

if (typeof val !== "boolean") {
throw new Error(
`All values of visibilityFilters must be booleans.`
);
}
}
},
});

options.addDeclaration({
name: "searchCategoryBoosts",
help: "Configure search to give a relevance boost to selected categories",
type: ParameterType.Mixed,
configFileOnly: true,
defaultValue: {},
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,
configFileOnly: true,
defaultValue: {},
validate(value: unknown) {
if (!isObject(value)) {
throw new Error(
"The 'searchGroupBoosts' option must be a non-array object."
);
}

if (Object.values(value).some((x) => typeof x !== "number")) {
throw new Error(
"All values of 'searchGroupBoosts' must be numbers."
);
}
},
});

///////////////////////////
///// Comment Options /////
///////////////////////////
Expand Down Expand Up @@ -510,7 +596,7 @@ export function addTypeDocOptions(options: Pick<Options, "addDeclaration">) {
name: "categorizeByGroup",
help: "Specify whether categorization will be done at the group level.",
type: ParameterType.Boolean,
defaultValue: true,
defaultValue: true, // 0.25, change this to false.
});
options.addDeclaration({
name: "defaultCategory",
Expand Down Expand Up @@ -594,82 +680,6 @@ export function addTypeDocOptions(options: Pick<Options, "addDeclaration">) {
},
});

options.addDeclaration({
name: "visibilityFilters",
help: "Specify the default visibility for builtin filters and additional filters according to modifier tags.",
type: ParameterType.Mixed,
configFileOnly: true,
defaultValue: {
protected: false,
private: false,
inherited: true,
external: false,
},
validate(value) {
const knownKeys = ["protected", "private", "inherited", "external"];
if (!value || typeof value !== "object") {
throw new Error("visibilityFilters must be an object.");
}

for (const [key, val] of Object.entries(value)) {
if (!key.startsWith("@") && !knownKeys.includes(key)) {
throw new Error(
`visibilityFilters can only include the following non-@ keys: ${knownKeys.join(
", "
)}`
);
}

if (typeof val !== "boolean") {
throw new Error(
`All values of visibilityFilters must be booleans.`
);
}
}
},
});

options.addDeclaration({
name: "searchCategoryBoosts",
help: "Configure search to give a relevance boost to selected categories",
type: ParameterType.Mixed,
configFileOnly: true,
defaultValue: {},
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,
configFileOnly: true,
defaultValue: {},
validate(value: unknown) {
if (!isObject(value)) {
throw new Error(
"The 'searchGroupBoosts' option must be a non-array object."
);
}

if (Object.values(value).some((x) => typeof x !== "number")) {
throw new Error(
"All values of 'searchGroupBoosts' must be numbers."
);
}
},
});

///////////////////////////
///// General Options /////
///////////////////////////
Expand Down
Loading