mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-28 17:57:48 +02:00
feat(blog): group sidebar items by year (themeConfig.blog.sidebar.groupByYear
) (#10252)
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
This commit is contained in:
parent
10830ce25c
commit
aab1f4868b
21 changed files with 547 additions and 85 deletions
|
@ -5,7 +5,9 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {toTagsProp} from '../props';
|
import {fromPartial} from '@total-typescript/shoehorn';
|
||||||
|
import {toBlogSidebarProp, toTagsProp} from '../props';
|
||||||
|
import type {BlogPost} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
describe('toTagsProp', () => {
|
describe('toTagsProp', () => {
|
||||||
type Tags = Parameters<typeof toTagsProp>[0]['blogTags'];
|
type Tags = Parameters<typeof toTagsProp>[0]['blogTags'];
|
||||||
|
@ -68,3 +70,59 @@ describe('toTagsProp', () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toBlogSidebarProp', () => {
|
||||||
|
it('creates sidebar prop', () => {
|
||||||
|
const blogPosts: BlogPost[] = [
|
||||||
|
fromPartial({
|
||||||
|
id: '1',
|
||||||
|
metadata: {
|
||||||
|
title: 'title 1',
|
||||||
|
permalink: '/blog/blog-1',
|
||||||
|
unlisted: false,
|
||||||
|
date: '2021-01-01',
|
||||||
|
frontMatter: {toto: 42},
|
||||||
|
authors: [{name: 'author'}],
|
||||||
|
source: 'xyz',
|
||||||
|
hasTruncateMarker: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
fromPartial({
|
||||||
|
id: '2',
|
||||||
|
metadata: {
|
||||||
|
title: 'title 2',
|
||||||
|
permalink: '/blog/blog-2',
|
||||||
|
unlisted: true,
|
||||||
|
date: '2024-01-01',
|
||||||
|
frontMatter: {hello: 'world'},
|
||||||
|
tags: [{label: 'tag1', permalink: '/tag1', inline: false}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebarProp = toBlogSidebarProp({
|
||||||
|
blogSidebarTitle: 'sidebar title',
|
||||||
|
blogPosts,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sidebarProp).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"date": "2021-01-01",
|
||||||
|
"permalink": "/blog/blog-1",
|
||||||
|
"title": "title 1",
|
||||||
|
"unlisted": false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2024-01-01",
|
||||||
|
"permalink": "/blog/blog-2",
|
||||||
|
"title": "title 2",
|
||||||
|
"unlisted": true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"title": "sidebar title",
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -473,6 +473,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
|
||||||
title: string;
|
title: string;
|
||||||
permalink: string;
|
permalink: string;
|
||||||
unlisted: boolean;
|
unlisted: boolean;
|
||||||
|
date: Date | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BlogSidebar = {
|
export type BlogSidebar = {
|
||||||
|
|
|
@ -5,7 +5,12 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
import type {TagsListItem, TagModule} from '@docusaurus/utils';
|
import type {TagsListItem, TagModule} from '@docusaurus/utils';
|
||||||
import type {BlogTag, BlogTags} from '@docusaurus/plugin-content-blog';
|
import type {
|
||||||
|
BlogPost,
|
||||||
|
BlogSidebar,
|
||||||
|
BlogTag,
|
||||||
|
BlogTags,
|
||||||
|
} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
export function toTagsProp({blogTags}: {blogTags: BlogTags}): TagsListItem[] {
|
export function toTagsProp({blogTags}: {blogTags: BlogTags}): TagsListItem[] {
|
||||||
return Object.values(blogTags)
|
return Object.values(blogTags)
|
||||||
|
@ -34,3 +39,21 @@ export function toTagProp({
|
||||||
unlisted: tag.unlisted,
|
unlisted: tag.unlisted,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toBlogSidebarProp({
|
||||||
|
blogSidebarTitle,
|
||||||
|
blogPosts,
|
||||||
|
}: {
|
||||||
|
blogSidebarTitle: string;
|
||||||
|
blogPosts: BlogPost[];
|
||||||
|
}): BlogSidebar {
|
||||||
|
return {
|
||||||
|
title: blogSidebarTitle,
|
||||||
|
items: blogPosts.map((blogPost) => ({
|
||||||
|
title: blogPost.metadata.title,
|
||||||
|
permalink: blogPost.metadata.permalink,
|
||||||
|
unlisted: blogPost.metadata.unlisted,
|
||||||
|
date: blogPost.metadata.date,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
} from '@docusaurus/utils';
|
} from '@docusaurus/utils';
|
||||||
import {shouldBeListed} from './blogUtils';
|
import {shouldBeListed} from './blogUtils';
|
||||||
|
|
||||||
import {toTagProp, toTagsProp} from './props';
|
import {toBlogSidebarProp, toTagProp, toTagsProp} from './props';
|
||||||
import type {
|
import type {
|
||||||
PluginContentLoadedActions,
|
PluginContentLoadedActions,
|
||||||
RouteConfig,
|
RouteConfig,
|
||||||
|
@ -26,7 +26,6 @@ import type {
|
||||||
BlogContent,
|
BlogContent,
|
||||||
PluginOptions,
|
PluginOptions,
|
||||||
BlogPost,
|
BlogPost,
|
||||||
BlogSidebar,
|
|
||||||
} from '@docusaurus/plugin-content-blog';
|
} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
type CreateAllRoutesParam = {
|
type CreateAllRoutesParam = {
|
||||||
|
@ -88,17 +87,13 @@ export async function buildAllRoutes({
|
||||||
: blogPosts.slice(0, options.blogSidebarCount);
|
: blogPosts.slice(0, options.blogSidebarCount);
|
||||||
|
|
||||||
async function createSidebarModule() {
|
async function createSidebarModule() {
|
||||||
const sidebar: BlogSidebar = {
|
const sidebarProp = toBlogSidebarProp({
|
||||||
title: blogSidebarTitle,
|
blogSidebarTitle,
|
||||||
items: sidebarBlogPosts.map((blogPost) => ({
|
blogPosts: sidebarBlogPosts,
|
||||||
title: blogPost.metadata.title,
|
});
|
||||||
permalink: blogPost.metadata.permalink,
|
|
||||||
unlisted: blogPost.metadata.unlisted,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const modulePath = await createData(
|
const modulePath = await createData(
|
||||||
`blog-post-list-prop-${pluginId}.json`,
|
`blog-post-list-prop-${pluginId}.json`,
|
||||||
sidebar,
|
sidebarProp,
|
||||||
);
|
);
|
||||||
return aliasedSource(modulePath);
|
return aliasedSource(modulePath);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
normalizeThemeConfig,
|
normalizeThemeConfig,
|
||||||
|
@ -32,6 +32,10 @@ function testValidateOptions(options: Options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('themeConfig', () => {
|
describe('themeConfig', () => {
|
||||||
|
it('accepts empty theme config', () => {
|
||||||
|
expect(testValidateThemeConfig({})).toEqual(DEFAULT_CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts valid theme config', () => {
|
it('accepts valid theme config', () => {
|
||||||
const userConfig = {
|
const userConfig = {
|
||||||
prism: {
|
prism: {
|
||||||
|
@ -54,6 +58,11 @@ describe('themeConfig', () => {
|
||||||
autoCollapseCategories: false,
|
autoCollapseCategories: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
blog: {
|
||||||
|
sidebar: {
|
||||||
|
groupByYear: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
announcementBar: {
|
announcementBar: {
|
||||||
id: 'supports',
|
id: 'supports',
|
||||||
content: 'pls support',
|
content: 'pls support',
|
||||||
|
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
} from '@docusaurus/types';
|
} from '@docusaurus/types';
|
||||||
|
|
||||||
const defaultPrismTheme = themes.palenight;
|
const defaultPrismTheme = themes.palenight;
|
||||||
|
|
||||||
const DEFAULT_DOCS_CONFIG: ThemeConfig['docs'] = {
|
const DEFAULT_DOCS_CONFIG: ThemeConfig['docs'] = {
|
||||||
versionPersistence: 'localStorage',
|
versionPersistence: 'localStorage',
|
||||||
sidebar: {
|
sidebar: {
|
||||||
|
@ -22,11 +23,12 @@ const DEFAULT_DOCS_CONFIG: ThemeConfig['docs'] = {
|
||||||
autoCollapseCategories: false,
|
autoCollapseCategories: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const DocsSchema = Joi.object({
|
|
||||||
|
const DocsSchema = Joi.object<ThemeConfig['docs']>({
|
||||||
versionPersistence: Joi.string()
|
versionPersistence: Joi.string()
|
||||||
.equal('localStorage', 'none')
|
.equal('localStorage', 'none')
|
||||||
.default(DEFAULT_DOCS_CONFIG.versionPersistence),
|
.default(DEFAULT_DOCS_CONFIG.versionPersistence),
|
||||||
sidebar: Joi.object({
|
sidebar: Joi.object<ThemeConfig['docs']['sidebar']>({
|
||||||
hideable: Joi.bool().default(DEFAULT_DOCS_CONFIG.sidebar.hideable),
|
hideable: Joi.bool().default(DEFAULT_DOCS_CONFIG.sidebar.hideable),
|
||||||
autoCollapseCategories: Joi.bool().default(
|
autoCollapseCategories: Joi.bool().default(
|
||||||
DEFAULT_DOCS_CONFIG.sidebar.autoCollapseCategories,
|
DEFAULT_DOCS_CONFIG.sidebar.autoCollapseCategories,
|
||||||
|
@ -34,6 +36,18 @@ const DocsSchema = Joi.object({
|
||||||
}).default(DEFAULT_DOCS_CONFIG.sidebar),
|
}).default(DEFAULT_DOCS_CONFIG.sidebar),
|
||||||
}).default(DEFAULT_DOCS_CONFIG);
|
}).default(DEFAULT_DOCS_CONFIG);
|
||||||
|
|
||||||
|
const DEFAULT_BLOG_CONFIG: ThemeConfig['blog'] = {
|
||||||
|
sidebar: {
|
||||||
|
groupByYear: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const BlogSchema = Joi.object<ThemeConfig['blog']>({
|
||||||
|
sidebar: Joi.object<ThemeConfig['blog']['sidebar']>({
|
||||||
|
groupByYear: Joi.bool().default(DEFAULT_BLOG_CONFIG.sidebar.groupByYear),
|
||||||
|
}).default(DEFAULT_BLOG_CONFIG.sidebar),
|
||||||
|
}).default(DEFAULT_BLOG_CONFIG);
|
||||||
|
|
||||||
const DEFAULT_COLOR_MODE_CONFIG: ThemeConfig['colorMode'] = {
|
const DEFAULT_COLOR_MODE_CONFIG: ThemeConfig['colorMode'] = {
|
||||||
defaultMode: 'light',
|
defaultMode: 'light',
|
||||||
disableSwitch: false,
|
disableSwitch: false,
|
||||||
|
@ -43,6 +57,7 @@ const DEFAULT_COLOR_MODE_CONFIG: ThemeConfig['colorMode'] = {
|
||||||
export const DEFAULT_CONFIG: ThemeConfig = {
|
export const DEFAULT_CONFIG: ThemeConfig = {
|
||||||
colorMode: DEFAULT_COLOR_MODE_CONFIG,
|
colorMode: DEFAULT_COLOR_MODE_CONFIG,
|
||||||
docs: DEFAULT_DOCS_CONFIG,
|
docs: DEFAULT_DOCS_CONFIG,
|
||||||
|
blog: DEFAULT_BLOG_CONFIG,
|
||||||
metadata: [],
|
metadata: [],
|
||||||
prism: {
|
prism: {
|
||||||
additionalLanguages: [],
|
additionalLanguages: [],
|
||||||
|
@ -333,6 +348,7 @@ export const ThemeConfigSchema = Joi.object<ThemeConfig>({
|
||||||
colorMode: ColorModeSchema,
|
colorMode: ColorModeSchema,
|
||||||
image: Joi.string(),
|
image: Joi.string(),
|
||||||
docs: DocsSchema,
|
docs: DocsSchema,
|
||||||
|
blog: BlogSchema,
|
||||||
metadata: Joi.array()
|
metadata: Joi.array()
|
||||||
.items(HtmlMetadataSchema)
|
.items(HtmlMetadataSchema)
|
||||||
.default(DEFAULT_CONFIG.metadata),
|
.default(DEFAULT_CONFIG.metadata),
|
||||||
|
|
|
@ -194,6 +194,19 @@ declare module '@theme/BlogListPaginator' {
|
||||||
export default function BlogListPaginator(props: Props): JSX.Element;
|
export default function BlogListPaginator(props: Props): JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module '@theme/BlogSidebar/Content' {
|
||||||
|
import type {ReactNode, ComponentType} from 'react';
|
||||||
|
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
readonly items: BlogSidebarItem[];
|
||||||
|
readonly ListComponent: ComponentType<{items: BlogSidebarItem[]}>;
|
||||||
|
readonly yearGroupHeadingClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlogSidebarContent(props: Props): ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@theme/BlogSidebar/Desktop' {
|
declare module '@theme/BlogSidebar/Desktop' {
|
||||||
import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
|
import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
import React, {memo, type ReactNode} from 'react';
|
||||||
|
import {useThemeConfig} from '@docusaurus/theme-common';
|
||||||
|
import {groupBlogSidebarItemsByYear} from '@docusaurus/theme-common/internal';
|
||||||
|
import Heading from '@theme/Heading';
|
||||||
|
import type {Props} from '@theme/BlogSidebar/Content';
|
||||||
|
|
||||||
|
function BlogSidebarYearGroup({
|
||||||
|
year,
|
||||||
|
yearGroupHeadingClassName,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
year: string;
|
||||||
|
yearGroupHeadingClassName?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div role="group">
|
||||||
|
<Heading as="h3" className={yearGroupHeadingClassName}>
|
||||||
|
{year}
|
||||||
|
</Heading>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BlogSidebarContent({
|
||||||
|
items,
|
||||||
|
yearGroupHeadingClassName,
|
||||||
|
ListComponent,
|
||||||
|
}: Props): ReactNode {
|
||||||
|
const themeConfig = useThemeConfig();
|
||||||
|
if (themeConfig.blog.sidebar.groupByYear) {
|
||||||
|
const itemsByYear = groupBlogSidebarItemsByYear(items);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{itemsByYear.map(([year, yearItems]) => (
|
||||||
|
<BlogSidebarYearGroup
|
||||||
|
key={year}
|
||||||
|
year={year}
|
||||||
|
yearGroupHeadingClassName={yearGroupHeadingClassName}>
|
||||||
|
<ListComponent items={yearItems} />
|
||||||
|
</BlogSidebarYearGroup>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <ListComponent items={items} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BlogSidebarContent);
|
|
@ -5,16 +5,32 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, {memo} from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import Link from '@docusaurus/Link';
|
|
||||||
import {translate} from '@docusaurus/Translate';
|
import {translate} from '@docusaurus/Translate';
|
||||||
import {useVisibleBlogSidebarItems} from '@docusaurus/theme-common/internal';
|
import {
|
||||||
|
useVisibleBlogSidebarItems,
|
||||||
|
BlogSidebarItemList,
|
||||||
|
} from '@docusaurus/theme-common/internal';
|
||||||
|
import BlogSidebarContent from '@theme/BlogSidebar/Content';
|
||||||
|
import type {Props as BlogSidebarContentProps} from '@theme/BlogSidebar/Content';
|
||||||
import type {Props} from '@theme/BlogSidebar/Desktop';
|
import type {Props} from '@theme/BlogSidebar/Desktop';
|
||||||
|
|
||||||
import styles from './styles.module.css';
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
export default function BlogSidebarDesktop({sidebar}: Props): JSX.Element {
|
const ListComponent: BlogSidebarContentProps['ListComponent'] = ({items}) => {
|
||||||
|
return (
|
||||||
|
<BlogSidebarItemList
|
||||||
|
items={items}
|
||||||
|
ulClassName={clsx(styles.sidebarItemList, 'clean-list')}
|
||||||
|
liClassName={styles.sidebarItem}
|
||||||
|
linkClassName={styles.sidebarItemLink}
|
||||||
|
linkActiveClassName={styles.sidebarItemLinkActive}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function BlogSidebarDesktop({sidebar}: Props) {
|
||||||
const items = useVisibleBlogSidebarItems(sidebar.items);
|
const items = useVisibleBlogSidebarItems(sidebar.items);
|
||||||
return (
|
return (
|
||||||
<aside className="col col--3">
|
<aside className="col col--3">
|
||||||
|
@ -28,20 +44,14 @@ export default function BlogSidebarDesktop({sidebar}: Props): JSX.Element {
|
||||||
<div className={clsx(styles.sidebarItemTitle, 'margin-bottom--md')}>
|
<div className={clsx(styles.sidebarItemTitle, 'margin-bottom--md')}>
|
||||||
{sidebar.title}
|
{sidebar.title}
|
||||||
</div>
|
</div>
|
||||||
<ul className={clsx(styles.sidebarItemList, 'clean-list')}>
|
<BlogSidebarContent
|
||||||
{items.map((item) => (
|
items={items}
|
||||||
<li key={item.permalink} className={styles.sidebarItem}>
|
ListComponent={ListComponent}
|
||||||
<Link
|
yearGroupHeadingClassName={styles.yearGroupHeading}
|
||||||
isNavLink
|
/>
|
||||||
to={item.permalink}
|
|
||||||
className={styles.sidebarItemLink}
|
|
||||||
activeClassName={styles.sidebarItemLinkActive}>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(BlogSidebarDesktop);
|
||||||
|
|
|
@ -43,3 +43,8 @@
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.yearGroupHeading {
|
||||||
|
margin-top: 1.6rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
|
@ -5,32 +5,42 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, {memo} from 'react';
|
||||||
import Link from '@docusaurus/Link';
|
import {
|
||||||
import {useVisibleBlogSidebarItems} from '@docusaurus/theme-common/internal';
|
useVisibleBlogSidebarItems,
|
||||||
|
BlogSidebarItemList,
|
||||||
|
} from '@docusaurus/theme-common/internal';
|
||||||
import {NavbarSecondaryMenuFiller} from '@docusaurus/theme-common';
|
import {NavbarSecondaryMenuFiller} from '@docusaurus/theme-common';
|
||||||
|
import BlogSidebarContent from '@theme/BlogSidebar/Content';
|
||||||
import type {Props} from '@theme/BlogSidebar/Mobile';
|
import type {Props} from '@theme/BlogSidebar/Mobile';
|
||||||
|
import type {Props as BlogSidebarContentProps} from '@theme/BlogSidebar/Content';
|
||||||
|
|
||||||
|
import styles from './styles.module.css';
|
||||||
|
|
||||||
|
const ListComponent: BlogSidebarContentProps['ListComponent'] = ({items}) => {
|
||||||
|
return (
|
||||||
|
<BlogSidebarItemList
|
||||||
|
items={items}
|
||||||
|
ulClassName="menu__list"
|
||||||
|
liClassName="menu__list-item"
|
||||||
|
linkClassName="menu__link"
|
||||||
|
linkActiveClassName="menu__link--active"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function BlogSidebarMobileSecondaryMenu({sidebar}: Props): JSX.Element {
|
function BlogSidebarMobileSecondaryMenu({sidebar}: Props): JSX.Element {
|
||||||
const items = useVisibleBlogSidebarItems(sidebar.items);
|
const items = useVisibleBlogSidebarItems(sidebar.items);
|
||||||
return (
|
return (
|
||||||
<ul className="menu__list">
|
<BlogSidebarContent
|
||||||
{items.map((item) => (
|
items={items}
|
||||||
<li key={item.permalink} className="menu__list-item">
|
ListComponent={ListComponent}
|
||||||
<Link
|
yearGroupHeadingClassName={styles.yearGroupHeading}
|
||||||
isNavLink
|
/>
|
||||||
to={item.permalink}
|
|
||||||
className="menu__link"
|
|
||||||
activeClassName="menu__link--active">
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogSidebarMobile(props: Props): JSX.Element {
|
function BlogSidebarMobile(props: Props): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<NavbarSecondaryMenuFiller
|
<NavbarSecondaryMenuFiller
|
||||||
component={BlogSidebarMobileSecondaryMenu}
|
component={BlogSidebarMobileSecondaryMenu}
|
||||||
|
@ -38,3 +48,5 @@ export default function BlogSidebarMobile(props: Props): JSX.Element {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(BlogSidebarMobile);
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.yearGroupHeading {
|
||||||
|
margin: 1rem 0.75rem 0.5rem;
|
||||||
|
}
|
|
@ -90,7 +90,7 @@ export {isMultiColumnFooterLinks} from './utils/footerUtils';
|
||||||
|
|
||||||
export {isRegexpStringMatch} from './utils/regexpUtils';
|
export {isRegexpStringMatch} from './utils/regexpUtils';
|
||||||
|
|
||||||
export {duplicates, uniq} from './utils/jsUtils';
|
export {duplicates, uniq, groupBy} from './utils/jsUtils';
|
||||||
|
|
||||||
export {usePrismTheme} from './hooks/usePrismTheme';
|
export {usePrismTheme} from './hooks/usePrismTheme';
|
||||||
|
|
||||||
|
|
|
@ -113,7 +113,11 @@ export {
|
||||||
type TOCHighlightConfig,
|
type TOCHighlightConfig,
|
||||||
} from './hooks/useTOCHighlight';
|
} from './hooks/useTOCHighlight';
|
||||||
|
|
||||||
export {useVisibleBlogSidebarItems} from './utils/blogUtils';
|
export {
|
||||||
|
useVisibleBlogSidebarItems,
|
||||||
|
groupBlogSidebarItemsByYear,
|
||||||
|
BlogSidebarItemList,
|
||||||
|
} from './utils/blogUtils';
|
||||||
export {useDateTimeFormat} from './utils/IntlUtils';
|
export {useDateTimeFormat} from './utils/IntlUtils';
|
||||||
|
|
||||||
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
export {useHideableNavbar} from './hooks/useHideableNavbar';
|
||||||
|
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {groupBlogSidebarItemsByYear} from '../blogUtils';
|
||||||
|
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
|
describe('groupBlogSidebarItemsByYear', () => {
|
||||||
|
const post1: BlogSidebarItem = {
|
||||||
|
title: 'post1',
|
||||||
|
permalink: '/post1',
|
||||||
|
date: '2024-10-03',
|
||||||
|
unlisted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const post2: BlogSidebarItem = {
|
||||||
|
title: 'post2',
|
||||||
|
permalink: '/post2',
|
||||||
|
date: '2024-05-02',
|
||||||
|
unlisted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const post3: BlogSidebarItem = {
|
||||||
|
title: 'post3',
|
||||||
|
permalink: '/post3',
|
||||||
|
date: '2022-11-18',
|
||||||
|
unlisted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('can group items by year', () => {
|
||||||
|
const items: BlogSidebarItem[] = [post1, post2, post3];
|
||||||
|
const entries = groupBlogSidebarItemsByYear(items);
|
||||||
|
|
||||||
|
expect(entries).toEqual([
|
||||||
|
['2024', [post1, post2]],
|
||||||
|
['2022', [post3]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always returns result in descending chronological order', () => {
|
||||||
|
const items: BlogSidebarItem[] = [post3, post1, post2];
|
||||||
|
const entries = groupBlogSidebarItemsByYear(items);
|
||||||
|
|
||||||
|
expect(entries).toEqual([
|
||||||
|
['2024', [post1, post2]],
|
||||||
|
['2022', [post3]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -5,7 +5,7 @@
|
||||||
* LICENSE file in the root directory of this source tree.
|
* LICENSE file in the root directory of this source tree.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {uniq, duplicates} from '../jsUtils';
|
import {uniq, duplicates, groupBy} from '../jsUtils';
|
||||||
|
|
||||||
describe('duplicates', () => {
|
describe('duplicates', () => {
|
||||||
it('gets duplicate values', () => {
|
it('gets duplicate values', () => {
|
||||||
|
@ -51,3 +51,55 @@ describe('uniq', () => {
|
||||||
).toEqual([obj1, obj2, array1, array3, array2, obj3]);
|
).toEqual([obj1, obj2, array1, array3, array2, obj3]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('groupBy', () => {
|
||||||
|
type User = {name: string; age: number; type: 'a' | 'b' | 'c'};
|
||||||
|
|
||||||
|
const user1: User = {name: 'Seb', age: 42, type: 'c'};
|
||||||
|
const user2: User = {name: 'Robert', age: 42, type: 'b'};
|
||||||
|
const user3: User = {name: 'Seb', age: 32, type: 'c'};
|
||||||
|
|
||||||
|
const users = [user1, user2, user3];
|
||||||
|
|
||||||
|
it('group by name', () => {
|
||||||
|
const groups = groupBy(users, (u) => u.name);
|
||||||
|
|
||||||
|
expect(Object.keys(groups)).toEqual(['Seb', 'Robert']);
|
||||||
|
expect(groups).toEqual({
|
||||||
|
Seb: [user1, user3],
|
||||||
|
Robert: [user2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group by age', () => {
|
||||||
|
const groups = groupBy(users, (u) => u.age);
|
||||||
|
|
||||||
|
// Surprising keys order due to JS behavior
|
||||||
|
// see https://x.com/sebastienlorber/status/1806371668614369486
|
||||||
|
expect(Object.keys(groups)).toEqual(['32', '42']);
|
||||||
|
expect(groups).toEqual({
|
||||||
|
'32': [user3],
|
||||||
|
'42': [user1, user2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group by type', () => {
|
||||||
|
const groups = groupBy(users, (u) => u.type);
|
||||||
|
|
||||||
|
expect(Object.keys(groups)).toEqual(['c', 'b']);
|
||||||
|
expect(groups).toEqual({
|
||||||
|
c: [user1, user3],
|
||||||
|
b: [user2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group by name even duplicates', () => {
|
||||||
|
const groups = groupBy([user1, user2, user3, user1, user3], (u) => u.name);
|
||||||
|
|
||||||
|
expect(Object.keys(groups)).toEqual(['Seb', 'Robert']);
|
||||||
|
expect(groups).toEqual({
|
||||||
|
Seb: [user1, user3, user1, user3],
|
||||||
|
Robert: [user2],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
/**
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {useMemo} from 'react';
|
|
||||||
import {useLocation} from '@docusaurus/router';
|
|
||||||
import {isSamePath} from './routesUtils';
|
|
||||||
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
|
|
||||||
|
|
||||||
function isVisible(item: BlogSidebarItem, pathname: string): boolean {
|
|
||||||
if (item.unlisted && !isSamePath(item.permalink, pathname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the visible blog sidebar items to display.
|
|
||||||
* Unlisted items are filtered.
|
|
||||||
*/
|
|
||||||
export function useVisibleBlogSidebarItems(
|
|
||||||
items: BlogSidebarItem[],
|
|
||||||
): BlogSidebarItem[] {
|
|
||||||
const {pathname} = useLocation();
|
|
||||||
return useMemo(
|
|
||||||
() => items.filter((item) => isVisible(item, pathname)),
|
|
||||||
[items, pathname],
|
|
||||||
);
|
|
||||||
}
|
|
85
packages/docusaurus-theme-common/src/utils/blogUtils.tsx
Normal file
85
packages/docusaurus-theme-common/src/utils/blogUtils.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {type ReactNode, useMemo} from 'react';
|
||||||
|
import {useLocation} from '@docusaurus/router';
|
||||||
|
import Link from '@docusaurus/Link';
|
||||||
|
import {isSamePath} from './routesUtils';
|
||||||
|
import {groupBy} from './jsUtils';
|
||||||
|
import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
|
||||||
|
|
||||||
|
function isVisible(item: BlogSidebarItem, pathname: string): boolean {
|
||||||
|
if (item.unlisted && !isSamePath(item.permalink, pathname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the visible blog sidebar items to display.
|
||||||
|
* Unlisted items are filtered.
|
||||||
|
*/
|
||||||
|
export function useVisibleBlogSidebarItems(
|
||||||
|
items: BlogSidebarItem[],
|
||||||
|
): BlogSidebarItem[] {
|
||||||
|
const {pathname} = useLocation();
|
||||||
|
return useMemo(
|
||||||
|
() => items.filter((item) => isVisible(item, pathname)),
|
||||||
|
[items, pathname],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupBlogSidebarItemsByYear(
|
||||||
|
items: BlogSidebarItem[],
|
||||||
|
): [string, BlogSidebarItem[]][] {
|
||||||
|
const groupedByYear = groupBy(items, (item) => {
|
||||||
|
return `${new Date(item.date).getFullYear()}`;
|
||||||
|
});
|
||||||
|
// "as" is safe here
|
||||||
|
// see https://github.com/microsoft/TypeScript/pull/56805#issuecomment-2196526425
|
||||||
|
const entries = Object.entries(groupedByYear) as [
|
||||||
|
string,
|
||||||
|
BlogSidebarItem[],
|
||||||
|
][];
|
||||||
|
// We have to use entries because of https://x.com/sebastienlorber/status/1806371668614369486
|
||||||
|
// Objects with string/number keys are automatically sorted asc...
|
||||||
|
// Even if keys are strings like "2024"
|
||||||
|
// We want descending order for years
|
||||||
|
// Alternative: using Map.groupBy (not affected by this "reordering")
|
||||||
|
entries.reverse();
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlogSidebarItemList({
|
||||||
|
items,
|
||||||
|
ulClassName,
|
||||||
|
liClassName,
|
||||||
|
linkClassName,
|
||||||
|
linkActiveClassName,
|
||||||
|
}: {
|
||||||
|
items: BlogSidebarItem[];
|
||||||
|
ulClassName?: string;
|
||||||
|
liClassName?: string;
|
||||||
|
linkClassName?: string;
|
||||||
|
linkActiveClassName?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<ul className={ulClassName}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.permalink} className={liClassName}>
|
||||||
|
<Link
|
||||||
|
isNavLink
|
||||||
|
to={item.permalink}
|
||||||
|
className={linkClassName}
|
||||||
|
activeClassName={linkActiveClassName}>
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
|
@ -34,3 +34,21 @@ export function uniq<T>(arr: T[]): T[] {
|
||||||
// Note: had problems with [...new Set()]: https://github.com/facebook/docusaurus/issues/4972#issuecomment-863895061
|
// Note: had problems with [...new Set()]: https://github.com/facebook/docusaurus/issues/4972#issuecomment-863895061
|
||||||
return Array.from(new Set(arr));
|
return Array.from(new Set(arr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO 2025: replace by std Object.groupBy ?
|
||||||
|
// This is a local polyfill with exact same TS signature
|
||||||
|
// see https://github.com/microsoft/TypeScript/blob/main/src/lib/esnext.object.d.ts
|
||||||
|
export function groupBy<K extends PropertyKey, T>(
|
||||||
|
items: Iterable<T>,
|
||||||
|
keySelector: (item: T, index: number) => K,
|
||||||
|
): Partial<Record<K, T[]>> {
|
||||||
|
const result: Partial<Record<K, T[]>> = {};
|
||||||
|
let index = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const key = keySelector(item, index);
|
||||||
|
result[key] ??= [];
|
||||||
|
result[key]!.push(item);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
|
@ -109,6 +109,12 @@ export type ThemeConfig = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
blog: {
|
||||||
|
sidebar: {
|
||||||
|
groupByYear: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// TODO we should complete this theme config type over time
|
// TODO we should complete this theme config type over time
|
||||||
// and share it across all themes
|
// and share it across all themes
|
||||||
// and use it in the Joi validation schema?
|
// and use it in the Joi validation schema?
|
||||||
|
|
|
@ -158,6 +158,74 @@ export default {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
Our [main themes](./overview.mdx) offer additional theme configuration options for Docusaurus core content plugins.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
```mdx-code-block
|
||||||
|
<APITable name="navbar-overview">
|
||||||
|
```
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `versionPersistence` | `'localStorage' \| 'none'` | `undefined` | Defines the browser persistence of the preferred docs version. |
|
||||||
|
| `sidebar.hideable` | `boolean` | `false` | Show a hide button at the bottom of the sidebar. |
|
||||||
|
| `sidebar.autoCollapseCategories` | `boolean` | `false` | Automatically collapse all sibling categories of the one you navigate to. |
|
||||||
|
|
||||||
|
```mdx-code-block
|
||||||
|
</APITable>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example configuration:
|
||||||
|
|
||||||
|
```js title="docusaurus.config.js"
|
||||||
|
export default {
|
||||||
|
themeConfig: {
|
||||||
|
docs: {
|
||||||
|
// highlight-start
|
||||||
|
versionPersistence: 'localStorage',
|
||||||
|
sidebar: {
|
||||||
|
hideable: false,
|
||||||
|
autoCollapseCategories: false,
|
||||||
|
},
|
||||||
|
// highlight-end
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Blog
|
||||||
|
|
||||||
|
```mdx-code-block
|
||||||
|
<APITable name="navbar-overview">
|
||||||
|
```
|
||||||
|
|
||||||
|
| Name | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `sidebar.groupByYear` | `boolean` | `true` | Group sidebar blog posts by years. |
|
||||||
|
|
||||||
|
```mdx-code-block
|
||||||
|
</APITable>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example configuration:
|
||||||
|
|
||||||
|
```js title="docusaurus.config.js"
|
||||||
|
export default {
|
||||||
|
themeConfig: {
|
||||||
|
blog: {
|
||||||
|
// highlight-start
|
||||||
|
sidebar: {
|
||||||
|
groupByYear: true,
|
||||||
|
},
|
||||||
|
// highlight-end
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
## Navbar {#navbar}
|
## Navbar {#navbar}
|
||||||
|
|
||||||
Accepted fields:
|
Accepted fields:
|
||||||
|
|
Loading…
Add table
Reference in a new issue