mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-28 09:47: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.
|
||||
*/
|
||||
|
||||
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",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
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);
|
||||
|
|
|
@ -43,3 +43,8 @@
|
|||
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.
|
||||
*/
|
||||
|
||||
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);
|
||||
|
|
|
@ -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 {duplicates, uniq} from './utils/jsUtils';
|
||||
export {duplicates, uniq, groupBy} from './utils/jsUtils';
|
||||
|
||||
export {usePrismTheme} from './hooks/usePrismTheme';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
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
|
||||
// and share it across all themes
|
||||
// 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}
|
||||
|
||||
Accepted fields:
|
||||
|
|
Loading…
Add table
Reference in a new issue