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:
Alice Zhao 2024-06-28 07:59:35 -07:00 committed by GitHub
parent 10830ce25c
commit aab1f4868b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 547 additions and 85 deletions

View file

@ -5,7 +5,9 @@
* 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', () => {
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",
}
`);
});
});

View file

@ -473,6 +473,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
title: string;
permalink: string;
unlisted: boolean;
date: Date | string;
};
export type BlogSidebar = {

View file

@ -5,7 +5,12 @@
* LICENSE file in the root directory of this source tree.
*/
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[] {
return Object.values(blogTags)
@ -34,3 +39,21 @@ export function toTagProp({
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,
})),
};
}

View file

@ -13,7 +13,7 @@ import {
} from '@docusaurus/utils';
import {shouldBeListed} from './blogUtils';
import {toTagProp, toTagsProp} from './props';
import {toBlogSidebarProp, toTagProp, toTagsProp} from './props';
import type {
PluginContentLoadedActions,
RouteConfig,
@ -26,7 +26,6 @@ import type {
BlogContent,
PluginOptions,
BlogPost,
BlogSidebar,
} from '@docusaurus/plugin-content-blog';
type CreateAllRoutesParam = {
@ -88,17 +87,13 @@ export async function buildAllRoutes({
: blogPosts.slice(0, options.blogSidebarCount);
async function createSidebarModule() {
const sidebar: BlogSidebar = {
title: blogSidebarTitle,
items: sidebarBlogPosts.map((blogPost) => ({
title: blogPost.metadata.title,
permalink: blogPost.metadata.permalink,
unlisted: blogPost.metadata.unlisted,
})),
};
const sidebarProp = toBlogSidebarProp({
blogSidebarTitle,
blogPosts: sidebarBlogPosts,
});
const modulePath = await createData(
`blog-post-list-prop-${pluginId}.json`,
sidebar,
sidebarProp,
);
return aliasedSource(modulePath);
}

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import _ from 'lodash';
import * as _ from 'lodash';
import {
normalizeThemeConfig,
@ -32,6 +32,10 @@ function testValidateOptions(options: Options) {
}
describe('themeConfig', () => {
it('accepts empty theme config', () => {
expect(testValidateThemeConfig({})).toEqual(DEFAULT_CONFIG);
});
it('accepts valid theme config', () => {
const userConfig = {
prism: {
@ -54,6 +58,11 @@ describe('themeConfig', () => {
autoCollapseCategories: false,
},
},
blog: {
sidebar: {
groupByYear: false,
},
},
announcementBar: {
id: 'supports',
content: 'pls support',

View file

@ -15,6 +15,7 @@ import type {
} from '@docusaurus/types';
const defaultPrismTheme = themes.palenight;
const DEFAULT_DOCS_CONFIG: ThemeConfig['docs'] = {
versionPersistence: 'localStorage',
sidebar: {
@ -22,11 +23,12 @@ const DEFAULT_DOCS_CONFIG: ThemeConfig['docs'] = {
autoCollapseCategories: false,
},
};
const DocsSchema = Joi.object({
const DocsSchema = Joi.object<ThemeConfig['docs']>({
versionPersistence: Joi.string()
.equal('localStorage', 'none')
.default(DEFAULT_DOCS_CONFIG.versionPersistence),
sidebar: Joi.object({
sidebar: Joi.object<ThemeConfig['docs']['sidebar']>({
hideable: Joi.bool().default(DEFAULT_DOCS_CONFIG.sidebar.hideable),
autoCollapseCategories: Joi.bool().default(
DEFAULT_DOCS_CONFIG.sidebar.autoCollapseCategories,
@ -34,6 +36,18 @@ const DocsSchema = Joi.object({
}).default(DEFAULT_DOCS_CONFIG.sidebar),
}).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'] = {
defaultMode: 'light',
disableSwitch: false,
@ -43,6 +57,7 @@ const DEFAULT_COLOR_MODE_CONFIG: ThemeConfig['colorMode'] = {
export const DEFAULT_CONFIG: ThemeConfig = {
colorMode: DEFAULT_COLOR_MODE_CONFIG,
docs: DEFAULT_DOCS_CONFIG,
blog: DEFAULT_BLOG_CONFIG,
metadata: [],
prism: {
additionalLanguages: [],
@ -333,6 +348,7 @@ export const ThemeConfigSchema = Joi.object<ThemeConfig>({
colorMode: ColorModeSchema,
image: Joi.string(),
docs: DocsSchema,
blog: BlogSchema,
metadata: Joi.array()
.items(HtmlMetadataSchema)
.default(DEFAULT_CONFIG.metadata),

View file

@ -194,6 +194,19 @@ declare module '@theme/BlogListPaginator' {
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' {
import type {BlogSidebar} from '@docusaurus/plugin-content-blog';

View file

@ -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);

View file

@ -5,16 +5,32 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import React, {memo} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
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 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);
return (
<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')}>
{sidebar.title}
</div>
<ul className={clsx(styles.sidebarItemList, 'clean-list')}>
{items.map((item) => (
<li key={item.permalink} className={styles.sidebarItem}>
<Link
isNavLink
to={item.permalink}
className={styles.sidebarItemLink}
activeClassName={styles.sidebarItemLinkActive}>
{item.title}
</Link>
</li>
))}
</ul>
<BlogSidebarContent
items={items}
ListComponent={ListComponent}
yearGroupHeadingClassName={styles.yearGroupHeading}
/>
</nav>
</aside>
);
}
export default memo(BlogSidebarDesktop);

View file

@ -43,3 +43,8 @@
display: none;
}
}
.yearGroupHeading {
margin-top: 1.6rem;
margin-bottom: 0.4rem;
}

View file

@ -5,32 +5,42 @@
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Link from '@docusaurus/Link';
import {useVisibleBlogSidebarItems} from '@docusaurus/theme-common/internal';
import React, {memo} from 'react';
import {
useVisibleBlogSidebarItems,
BlogSidebarItemList,
} from '@docusaurus/theme-common/internal';
import {NavbarSecondaryMenuFiller} from '@docusaurus/theme-common';
import BlogSidebarContent from '@theme/BlogSidebar/Content';
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 {
const items = useVisibleBlogSidebarItems(sidebar.items);
return (
<ul className="menu__list">
{items.map((item) => (
<li key={item.permalink} className="menu__list-item">
<Link
isNavLink
to={item.permalink}
className="menu__link"
activeClassName="menu__link--active">
{item.title}
</Link>
</li>
))}
</ul>
<BlogSidebarContent
items={items}
ListComponent={ListComponent}
yearGroupHeadingClassName={styles.yearGroupHeading}
/>
);
}
export default function BlogSidebarMobile(props: Props): JSX.Element {
function BlogSidebarMobile(props: Props): JSX.Element {
return (
<NavbarSecondaryMenuFiller
component={BlogSidebarMobileSecondaryMenu}
@ -38,3 +48,5 @@ export default function BlogSidebarMobile(props: Props): JSX.Element {
/>
);
}
export default memo(BlogSidebarMobile);

View file

@ -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;
}

View file

@ -90,7 +90,7 @@ export {isMultiColumnFooterLinks} from './utils/footerUtils';
export {isRegexpStringMatch} from './utils/regexpUtils';
export {duplicates, uniq} from './utils/jsUtils';
export {duplicates, uniq, groupBy} from './utils/jsUtils';
export {usePrismTheme} from './hooks/usePrismTheme';

View file

@ -113,7 +113,11 @@ export {
type TOCHighlightConfig,
} from './hooks/useTOCHighlight';
export {useVisibleBlogSidebarItems} from './utils/blogUtils';
export {
useVisibleBlogSidebarItems,
groupBlogSidebarItemsByYear,
BlogSidebarItemList,
} from './utils/blogUtils';
export {useDateTimeFormat} from './utils/IntlUtils';
export {useHideableNavbar} from './hooks/useHideableNavbar';

View file

@ -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]],
]);
});
});

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {uniq, duplicates} from '../jsUtils';
import {uniq, duplicates, groupBy} from '../jsUtils';
describe('duplicates', () => {
it('gets duplicate values', () => {
@ -51,3 +51,55 @@ describe('uniq', () => {
).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],
});
});
});

View file

@ -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],
);
}

View 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>
);
}

View file

@ -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
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;
}

View file

@ -109,6 +109,12 @@ export type ThemeConfig = {
};
};
blog: {
sidebar: {
groupByYear: boolean;
};
};
// TODO we should complete this theme config type over time
// and share it across all themes
// and use it in the Joi validation schema?

View file

@ -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}
Accepted fields: