From aab1f4868bdd4ebe858f0c284f729862539b7ced Mon Sep 17 00:00:00 2001 From: Alice Zhao <66543449+alicelovescake@users.noreply.github.com> Date: Fri, 28 Jun 2024 07:59:35 -0700 Subject: [PATCH] feat(blog): group sidebar items by year (`themeConfig.blog.sidebar.groupByYear`) (#10252) Co-authored-by: sebastien --- .../src/__tests__/props.test.ts | 60 ++++++++++++- .../src/plugin-content-blog.d.ts | 1 + .../src/props.ts | 25 +++++- .../src/routes.ts | 17 ++-- .../src/__tests__/options.test.ts | 11 ++- .../docusaurus-theme-classic/src/options.ts | 20 ++++- .../src/theme-classic.d.ts | 13 +++ .../src/theme/BlogSidebar/Content/index.tsx | 57 +++++++++++++ .../src/theme/BlogSidebar/Desktop/index.tsx | 44 ++++++---- .../BlogSidebar/Desktop/styles.module.css | 5 ++ .../src/theme/BlogSidebar/Mobile/index.tsx | 46 ++++++---- .../BlogSidebar/Mobile/styles.module.css | 10 +++ packages/docusaurus-theme-common/src/index.ts | 2 +- .../docusaurus-theme-common/src/internal.ts | 6 +- .../src/utils/__tests__/blogUtils.test.ts | 52 ++++++++++++ .../src/utils/__tests__/jsUtils.test.ts | 54 +++++++++++- .../src/utils/blogUtils.ts | 32 ------- .../src/utils/blogUtils.tsx | 85 +++++++++++++++++++ .../src/utils/jsUtils.ts | 18 ++++ .../src/utils/useThemeConfig.ts | 6 ++ .../docs/api/themes/theme-configuration.mdx | 68 +++++++++++++++ 21 files changed, 547 insertions(+), 85 deletions(-) create mode 100644 packages/docusaurus-theme-classic/src/theme/BlogSidebar/Content/index.tsx create mode 100644 packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/styles.module.css create mode 100644 packages/docusaurus-theme-common/src/utils/__tests__/blogUtils.test.ts delete mode 100644 packages/docusaurus-theme-common/src/utils/blogUtils.ts create mode 100644 packages/docusaurus-theme-common/src/utils/blogUtils.tsx diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/props.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/props.test.ts index 3446a3601468..4bd03a56cb3f 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/props.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/props.test.ts @@ -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[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", + } + `); + }); +}); diff --git a/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts b/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts index d3dbb699ec48..05985a667d7b 100644 --- a/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts +++ b/packages/docusaurus-plugin-content-blog/src/plugin-content-blog.d.ts @@ -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 = { diff --git a/packages/docusaurus-plugin-content-blog/src/props.ts b/packages/docusaurus-plugin-content-blog/src/props.ts index 517cd4a5f820..b4d4ddf78c03 100644 --- a/packages/docusaurus-plugin-content-blog/src/props.ts +++ b/packages/docusaurus-plugin-content-blog/src/props.ts @@ -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, + })), + }; +} diff --git a/packages/docusaurus-plugin-content-blog/src/routes.ts b/packages/docusaurus-plugin-content-blog/src/routes.ts index a810ce13dabc..fef334b5cc14 100644 --- a/packages/docusaurus-plugin-content-blog/src/routes.ts +++ b/packages/docusaurus-plugin-content-blog/src/routes.ts @@ -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); } diff --git a/packages/docusaurus-theme-classic/src/__tests__/options.test.ts b/packages/docusaurus-theme-classic/src/__tests__/options.test.ts index b73b2a3930b2..a228aba6f820 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/options.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/options.test.ts @@ -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', diff --git a/packages/docusaurus-theme-classic/src/options.ts b/packages/docusaurus-theme-classic/src/options.ts index 3347d57569f3..7bd6253565cf 100644 --- a/packages/docusaurus-theme-classic/src/options.ts +++ b/packages/docusaurus-theme-classic/src/options.ts @@ -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({ versionPersistence: Joi.string() .equal('localStorage', 'none') .default(DEFAULT_DOCS_CONFIG.versionPersistence), - sidebar: Joi.object({ + sidebar: Joi.object({ 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({ + sidebar: Joi.object({ + 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({ colorMode: ColorModeSchema, image: Joi.string(), docs: DocsSchema, + blog: BlogSchema, metadata: Joi.array() .items(HtmlMetadataSchema) .default(DEFAULT_CONFIG.metadata), diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 39e7195893b4..a71dfac8566d 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -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'; diff --git a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Content/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Content/index.tsx new file mode 100644 index 000000000000..decd530b8fd8 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Content/index.tsx @@ -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 ( +
+ + {year} + + {children} +
+ ); +} + +function BlogSidebarContent({ + items, + yearGroupHeadingClassName, + ListComponent, +}: Props): ReactNode { + const themeConfig = useThemeConfig(); + if (themeConfig.blog.sidebar.groupByYear) { + const itemsByYear = groupBlogSidebarItemsByYear(items); + return ( + <> + {itemsByYear.map(([year, yearItems]) => ( + + + + ))} + + ); + } else { + return ; + } +} + +export default memo(BlogSidebarContent); diff --git a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/index.tsx index 8488a49e3116..3c707ff6314e 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/index.tsx @@ -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 ( + + ); +}; + +function BlogSidebarDesktop({sidebar}: Props) { const items = useVisibleBlogSidebarItems(sidebar.items); return ( ); } + +export default memo(BlogSidebarDesktop); diff --git a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/styles.module.css b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/styles.module.css index 73a4bd8c041a..70781835456c 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Desktop/styles.module.css @@ -43,3 +43,8 @@ display: none; } } + +.yearGroupHeading { + margin-top: 1.6rem; + margin-bottom: 0.4rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/index.tsx index 7b021d7f39b7..2482e53b03e7 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/index.tsx @@ -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 ( + + ); +}; function BlogSidebarMobileSecondaryMenu({sidebar}: Props): JSX.Element { const items = useVisibleBlogSidebarItems(sidebar.items); return ( -
    - {items.map((item) => ( -
  • - - {item.title} - -
  • - ))} -
+ ); } -export default function BlogSidebarMobile(props: Props): JSX.Element { +function BlogSidebarMobile(props: Props): JSX.Element { return ( ); } + +export default memo(BlogSidebarMobile); diff --git a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/styles.module.css b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/styles.module.css new file mode 100644 index 000000000000..07943394419a --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/Mobile/styles.module.css @@ -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; +} diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 6859089b5a38..3cca2115358b 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -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'; diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index 31f081a62118..b58d2e2cc7db 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -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'; diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/blogUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/blogUtils.test.ts new file mode 100644 index 000000000000..119e7aaf899e --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/__tests__/blogUtils.test.ts @@ -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]], + ]); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/jsUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/jsUtils.test.ts index 9361063910d9..4b5fc0f301b1 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/jsUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/jsUtils.test.ts @@ -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], + }); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/blogUtils.ts b/packages/docusaurus-theme-common/src/utils/blogUtils.ts deleted file mode 100644 index d50a2ffac67e..000000000000 --- a/packages/docusaurus-theme-common/src/utils/blogUtils.ts +++ /dev/null @@ -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], - ); -} diff --git a/packages/docusaurus-theme-common/src/utils/blogUtils.tsx b/packages/docusaurus-theme-common/src/utils/blogUtils.tsx new file mode 100644 index 000000000000..5f3c43e3e1f7 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/blogUtils.tsx @@ -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 ( +
    + {items.map((item) => ( +
  • + + {item.title} + +
  • + ))} +
+ ); +} diff --git a/packages/docusaurus-theme-common/src/utils/jsUtils.ts b/packages/docusaurus-theme-common/src/utils/jsUtils.ts index 740a5a8b61df..f1f0ac92010d 100644 --- a/packages/docusaurus-theme-common/src/utils/jsUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/jsUtils.ts @@ -34,3 +34,21 @@ export function uniq(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( + items: Iterable, + keySelector: (item: T, index: number) => K, +): Partial> { + const result: Partial> = {}; + let index = 0; + for (const item of items) { + const key = keySelector(item, index); + result[key] ??= []; + result[key]!.push(item); + index += 1; + } + return result; +} diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index 17b4fbda6b81..8ce031c3f643 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -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? diff --git a/website/docs/api/themes/theme-configuration.mdx b/website/docs/api/themes/theme-configuration.mdx index de8dec94432f..43acdc325c7b 100644 --- a/website/docs/api/themes/theme-configuration.mdx +++ b/website/docs/api/themes/theme-configuration.mdx @@ -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 + +``` + +| 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 + +``` + +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 + +``` + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `sidebar.groupByYear` | `boolean` | `true` | Group sidebar blog posts by years. | + +```mdx-code-block + +``` + +Example configuration: + +```js title="docusaurus.config.js" +export default { + themeConfig: { + blog: { + // highlight-start + sidebar: { + groupByYear: true, + }, + // highlight-end + }, + }, +}; +``` + ## Navbar {#navbar} Accepted fields: