feat(theme-classic, plugin-docs): sidebar item level-specific className + allow customization (#5642)

* Initial work

* Complete function

* Avoid duplication

* More dedupe

* Make everything constants

* Change casing & docs
This commit is contained in:
Joshua Chen 2021-10-07 22:59:02 +08:00 committed by GitHub
parent f6ec757aa0
commit eaacb0e98a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 79 additions and 11 deletions

View file

@ -29,6 +29,7 @@ const DocFrontMatterSchema = Joi.object<DocFrontMatter>({
slug: Joi.string(), slug: Joi.string(),
sidebar_label: Joi.string(), sidebar_label: Joi.string(),
sidebar_position: Joi.number(), sidebar_position: Joi.number(),
sidebar_class_name: Joi.string(),
tags: FrontMatterTagsSchema, tags: FrontMatterTagsSchema,
pagination_label: Joi.string(), pagination_label: Joi.string(),
custom_edit_url: URISchema.allow('', null), custom_edit_url: URISchema.allow('', null),

View file

@ -30,6 +30,7 @@ declare module '@docusaurus/plugin-content-docs-types' {
}; };
type PropsSidebarItemBase = { type PropsSidebarItemBase = {
className?: string;
customProps?: Record<string, unknown>; customProps?: Record<string, unknown>;
}; };

View file

@ -47,6 +47,7 @@ Available document ids are:
type: 'link', type: 'link',
label: sidebarLabel || item.label || title, label: sidebarLabel || item.label || title,
href: permalink, href: permalink,
className: item.className,
customProps: item.customProps, customProps: item.customProps,
}; };
}; };

View file

@ -30,6 +30,7 @@ export type CategoryMetadatasFile = {
position?: number; position?: number;
collapsed?: boolean; collapsed?: boolean;
collapsible?: boolean; collapsible?: boolean;
className?: string;
// TODO should we allow "items" here? how would this work? would an "autogenerated" type be allowed? // TODO should we allow "items" here? how would this work? would an "autogenerated" type be allowed?
// This mkdocs plugin do something like that: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin/ // This mkdocs plugin do something like that: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin/
@ -44,6 +45,7 @@ const CategoryMetadatasFileSchema = Joi.object<CategoryMetadatasFile>({
position: Joi.number(), position: Joi.number(),
collapsed: Joi.boolean(), collapsed: Joi.boolean(),
collapsible: Joi.boolean(), collapsible: Joi.boolean(),
className: Joi.string(),
}); });
// TODO I now believe we should read all the category metadata files ahead of time: we may need this metadata to customize docs metadata // TODO I now believe we should read all the category metadata files ahead of time: we may need this metadata to customize docs metadata
@ -177,6 +179,9 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
...(doc.frontMatter.sidebar_label && { ...(doc.frontMatter.sidebar_label && {
label: doc.frontMatter.sidebar_label, label: doc.frontMatter.sidebar_label,
}), }),
...(doc.frontMatter.sidebar_class_name && {
className: doc.frontMatter.sidebar_class_name,
}),
...(typeof doc.sidebarPosition !== 'undefined' && { ...(typeof doc.sidebarPosition !== 'undefined' && {
position: doc.sidebarPosition, position: doc.sidebarPosition,
}), }),
@ -205,6 +210,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
const collapsible = const collapsible =
categoryMetadatas?.collapsible ?? options.sidebarCollapsible; categoryMetadatas?.collapsible ?? options.sidebarCollapsible;
const collapsed = categoryMetadatas?.collapsed ?? options.sidebarCollapsed; const collapsed = categoryMetadatas?.collapsed ?? options.sidebarCollapsed;
const className = categoryMetadatas?.className;
return { return {
type: 'category', type: 'category',
@ -213,6 +219,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
collapsed, collapsed,
collapsible, collapsible,
...(typeof position !== 'undefined' && {position}), ...(typeof position !== 'undefined' && {position}),
...(typeof className !== 'undefined' && {className}),
}; };
} }
@ -311,5 +318,5 @@ function sortSidebarItems(
['asc'], ['asc'],
); );
return sortedSidebarItems.map(({position: _removed, ...item}) => item); return sortedSidebarItems.map(({position, ...item}) => item);
} }

View file

@ -40,6 +40,7 @@ type SidebarItemCategoryJSON = SidebarItemBase & {
items: SidebarItemJSON[]; items: SidebarItemJSON[];
collapsed?: boolean; collapsed?: boolean;
collapsible?: boolean; collapsible?: boolean;
className?: string;
}; };
type SidebarItemAutogeneratedJSON = SidebarItemBase & { type SidebarItemAutogeneratedJSON = SidebarItemBase & {
@ -100,8 +101,7 @@ function assertItem<K extends string>(
keys: K[], keys: K[],
): asserts item is Record<K, unknown> { ): asserts item is Record<K, unknown> {
const unknownKeys = Object.keys(item).filter( const unknownKeys = Object.keys(item).filter(
// @ts-expect-error: key is always string (key) => !keys.includes(key as K) && key !== 'type',
(key) => !keys.includes(key as string) && key !== 'type',
); );
if (unknownKeys.length) { if (unknownKeys.length) {
@ -121,6 +121,7 @@ function assertIsCategory(
'label', 'label',
'collapsed', 'collapsed',
'collapsible', 'collapsible',
'className',
'customProps', 'customProps',
]); ]);
if (typeof item.label !== 'string') { if (typeof item.label !== 'string') {
@ -150,6 +151,14 @@ function assertIsCategory(
`Error loading ${JSON.stringify(item)}: "collapsible" must be a boolean.`, `Error loading ${JSON.stringify(item)}: "collapsible" must be a boolean.`,
); );
} }
if (
typeof item.className !== 'undefined' &&
typeof item.className !== 'string'
) {
throw new Error(
`Error loading ${JSON.stringify(item)}: "className" must be a string.`,
);
}
} }
function assertIsAutogenerated( function assertIsAutogenerated(
@ -173,24 +182,33 @@ function assertIsAutogenerated(
function assertIsDoc( function assertIsDoc(
item: Record<string, unknown>, item: Record<string, unknown>,
): asserts item is SidebarItemDoc { ): asserts item is SidebarItemDoc {
assertItem(item, ['id', 'label', 'customProps']); assertItem(item, ['id', 'label', 'className', 'customProps']);
if (typeof item.id !== 'string') { if (typeof item.id !== 'string') {
throw new Error( throw new Error(
`Error loading ${JSON.stringify(item)}: "id" must be a string.`, `Error loading ${JSON.stringify(item)}: "id" must be a string.`,
); );
} }
if (item.label && typeof item.label !== 'string') { if (typeof item.label !== 'undefined' && typeof item.label !== 'string') {
throw new Error( throw new Error(
`Error loading ${JSON.stringify(item)}: "label" must be a string.`, `Error loading ${JSON.stringify(item)}: "label" must be a string.`,
); );
} }
if (
typeof item.className !== 'undefined' &&
typeof item.className !== 'string'
) {
throw new Error(
`Error loading ${JSON.stringify(item)}: "className" must be a string.`,
);
}
} }
function assertIsLink( function assertIsLink(
item: Record<string, unknown>, item: Record<string, unknown>,
): asserts item is SidebarItemLink { ): asserts item is SidebarItemLink {
assertItem(item, ['href', 'label', 'customProps']); assertItem(item, ['href', 'label', 'className', 'customProps']);
if (typeof item.href !== 'string') { if (typeof item.href !== 'string') {
throw new Error( throw new Error(
`Error loading ${JSON.stringify(item)}: "href" must be a string.`, `Error loading ${JSON.stringify(item)}: "href" must be a string.`,
@ -201,6 +219,14 @@ function assertIsLink(
`Error loading ${JSON.stringify(item)}: "label" must be a string.`, `Error loading ${JSON.stringify(item)}: "label" must be a string.`,
); );
} }
if (
typeof item.className !== 'undefined' &&
typeof item.className !== 'string'
) {
throw new Error(
`Error loading ${JSON.stringify(item)}: "className" must be a string.`,
);
}
} }
/** /**

View file

@ -105,6 +105,7 @@ export type PluginOptions = MetadataOptions &
}; };
export type SidebarItemBase = { export type SidebarItemBase = {
className?: string;
customProps?: Record<string, unknown>; customProps?: Record<string, unknown>;
}; };
@ -217,6 +218,7 @@ export type DocFrontMatter = {
slug?: string; slug?: string;
sidebar_label?: string; sidebar_label?: string;
sidebar_position?: number; sidebar_position?: number;
sidebar_class_name?: string;
pagination_label?: string; pagination_label?: string;
custom_edit_url?: string | null; custom_edit_url?: string | null;
parse_number_prefixes?: boolean; parse_number_prefixes?: boolean;

View file

@ -80,7 +80,7 @@ function DocSidebarDesktop({path, sidebar, onCollapse, isHidden}: Props) {
!isAnnouncementBarClosed && showAnnouncementBar, !isAnnouncementBarClosed && showAnnouncementBar,
})}> })}>
<ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}> <ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}>
<DocSidebarItems items={sidebar} activePath={path} /> <DocSidebarItems items={sidebar} activePath={path} level={1} />
</ul> </ul>
</nav> </nav>
{hideableSidebar && <HideableSidebarButton onClick={onCollapse} />} {hideableSidebar && <HideableSidebarButton onClick={onCollapse} />}
@ -99,6 +99,7 @@ const DocSidebarMobileSecondaryMenu: MobileSecondaryMenuComponent<Props> = ({
items={sidebar} items={sidebar}
activePath={path} activePath={path}
onItemClick={() => toggleSidebar()} onItemClick={() => toggleSidebar()}
level={1}
/> />
</ul> </ul>
); );

View file

@ -100,9 +100,10 @@ function DocSidebarItemCategory({
item, item,
onItemClick, onItemClick,
activePath, activePath,
level,
...props ...props
}: Props & {item: PropSidebarItemCategory}) { }: Props & {item: PropSidebarItemCategory}) {
const {items, label, collapsible} = item; const {items, label, collapsible, className} = item;
const isActive = isActiveSidebarItem(item, activePath); const isActive = isActiveSidebarItem(item, activePath);
@ -123,10 +124,12 @@ function DocSidebarItemCategory({
<li <li
className={clsx( className={clsx(
ThemeClassNames.docs.docSidebarItemCategory, ThemeClassNames.docs.docSidebarItemCategory,
ThemeClassNames.docs.docSidebarItemCategoryLevel(level),
'menu__list-item', 'menu__list-item',
{ {
'menu__list-item--collapsed': collapsed, 'menu__list-item--collapsed': collapsed,
}, },
className,
)}> )}>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
<a <a
@ -154,6 +157,7 @@ function DocSidebarItemCategory({
tabIndex={collapsed ? -1 : 0} tabIndex={collapsed ? -1 : 0}
onItemClick={onItemClick} onItemClick={onItemClick}
activePath={activePath} activePath={activePath}
level={level + 1}
/> />
</Collapsible> </Collapsible>
</li> </li>
@ -164,15 +168,18 @@ function DocSidebarItemLink({
item, item,
onItemClick, onItemClick,
activePath, activePath,
level,
...props ...props
}: Props & {item: PropSidebarItemLink}) { }: Props & {item: PropSidebarItemLink}) {
const {href, label} = item; const {href, label, className} = item;
const isActive = isActiveSidebarItem(item, activePath); const isActive = isActiveSidebarItem(item, activePath);
return ( return (
<li <li
className={clsx( className={clsx(
ThemeClassNames.docs.docSidebarItemLink, ThemeClassNames.docs.docSidebarItemLink,
ThemeClassNames.docs.docSidebarItemLinkLevel(level),
'menu__list-item', 'menu__list-item',
className,
)} )}
key={label}> key={label}>
<Link <Link

View file

@ -140,6 +140,7 @@ declare module '@theme/DocSidebarItem' {
type DocSidebarPropsBase = { type DocSidebarPropsBase = {
readonly activePath: string; readonly activePath: string;
readonly onItemClick?: () => void; readonly onItemClick?: () => void;
readonly level: number;
readonly tabIndex?: number; readonly tabIndex?: number;
}; };

View file

@ -50,9 +50,13 @@ export const ThemeClassNames = {
docSidebarMenu: 'theme-doc-sidebar-menu', docSidebarMenu: 'theme-doc-sidebar-menu',
docSidebarItemCategory: 'theme-doc-sidebar-item-category', docSidebarItemCategory: 'theme-doc-sidebar-item-category',
docSidebarItemLink: 'theme-doc-sidebar-item-link', docSidebarItemLink: 'theme-doc-sidebar-item-link',
docSidebarItemCategoryLevel: (level: number) =>
`theme-doc-sidebar-item-category-level-${level}` as const,
docSidebarItemLinkLevel: (level: number) =>
`theme-doc-sidebar-item-link-level-${level}` as const,
// TODO add other stable classNames here // TODO add other stable classNames here
}, },
blog: { blog: {
// TODO add other stable classNames here // TODO add other stable classNames here
}, },
}; } as const;

View file

@ -10,6 +10,7 @@ module.exports = {
{ {
type: 'doc', type: 'doc',
id: 'index', id: 'index',
className: 'red',
label: 'Index', label: 'Index',
}, },
{ {
@ -23,9 +24,16 @@ module.exports = {
label: 'Huge sidebar category', label: 'Huge sidebar category',
items: generateHugeSidebarItems(4), items: generateHugeSidebarItems(4),
}, },
{
type: 'link',
label: 'External link',
href: 'https://github.com/facebook/docusaurus',
className: 'red',
},
{ {
type: 'category', type: 'category',
label: 'TOC tests', label: 'TOC tests',
className: 'red',
items: [ items: [
{ {
type: 'autogenerated', type: 'autogenerated',

View file

@ -247,6 +247,7 @@ Accepted fields:
| `pagination_label` | `string` | `sidebar_label` or `title` | The text used in the document next/previous buttons for this document. | | `pagination_label` | `string` | `sidebar_label` or `title` | The text used in the document next/previous buttons for this document. |
| `sidebar_label` | `string` | `title` | The text shown in the document sidebar for this document. | | `sidebar_label` | `string` | `title` | The text shown in the document sidebar for this document. |
| `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadatas](/docs/sidebar#autogenerated-sidebar-metadatas). | | `sidebar_position` | `number` | Default ordering | Controls the position of a doc inside the generated sidebar slice when using `autogenerated` sidebar items. See also [Autogenerated sidebar metadatas](/docs/sidebar#autogenerated-sidebar-metadatas). |
| `sidebar_class_name` | `string` | `undefined` | Gives the corresponding sidebar label a special class name when using autogenerated sidebars. |
| `hide_title` | `boolean` | `false` | Whether to hide the title at the top of the doc. It only hides a title declared through the frontmatter, and have no effect on a Markdown title at the top of your document. | | `hide_title` | `boolean` | `false` | Whether to hide the title at the top of the doc. It only hides a title declared through the frontmatter, and have no effect on a Markdown title at the top of your document. |
| `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. | | `hide_table_of_contents` | `boolean` | `false` | Whether to hide the table of contents to the right. |
| `toc_min_heading_level` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. | | `toc_min_heading_level` | `number` | `2` | The minimum heading level shown in the table of contents. Must be between 2 and 6 and lower or equal to the max value. |

View file

@ -173,6 +173,7 @@ type SidebarItemDoc =
type: 'doc'; type: 'doc';
id: string; id: string;
label: string; // Sidebar label text label: string; // Sidebar label text
className?: string; // Class name for sidebar label
} }
// Shorthand syntax // Shorthand syntax
@ -244,6 +245,7 @@ type SidebarItemLink = {
type: 'link'; type: 'link';
label: string; label: string;
href: string; href: string;
className?: string;
}; };
``` ```
@ -282,6 +284,7 @@ type SidebarItemCategory = {
type: 'category'; type: 'category';
label: string; // Sidebar label text. label: string; // Sidebar label text.
items: SidebarItem[]; // Array of sidebar items. items: SidebarItem[]; // Array of sidebar items.
className?: string;
// Category options: // Category options:
collapsible: boolean; // Set the category to be collapsible collapsible: boolean; // Set the category to be collapsible
@ -519,7 +522,8 @@ This is the easy tutorial!
```json title="docs/tutorials/_category_.json" ```json title="docs/tutorials/_category_.json"
{ {
"label": "Tutorial", "label": "Tutorial",
"position": 3 "position": 3,
"className": "red"
} }
``` ```

View file

@ -146,3 +146,7 @@ div[class^='announcementBar_'] {
); );
font-weight: bold; font-weight: bold;
} }
.red > a {
color: red;
}