feat(v2): blog sidebar (#3593)

* blog sidebar POC

* polish blog post sidebar

* add doc for blogSidebarCount

* Update packages/docusaurus-theme-classic/src/theme/BlogSidebar/styles.module.css

Co-authored-by: Alexey Pyltsyn <lex61rus@gmail.com>

Co-authored-by: Alexey Pyltsyn <lex61rus@gmail.com>
This commit is contained in:
Sébastien Lorber 2020-10-16 19:12:05 +02:00 committed by GitHub
parent da6268911c
commit e4c1626106
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 226 additions and 14 deletions

View file

@ -5,10 +5,27 @@
* LICENSE file in the root directory of this source tree.
*/
/* eslint-disable import/no-duplicates */
/* eslint-disable camelcase */
declare module '@theme/BlogSidebar' {
export type BlogSidebarItem = {title: string; permalink: string};
export type BlogSidebar = {
title: string;
items: BlogSidebarItem[];
};
export type Props = {
readonly sidebar: BlogSidebar;
};
const BlogSidebar: (props: Props) => JSX.Element;
export default BlogSidebar;
}
declare module '@theme/BlogPostPage' {
import type {MarkdownRightTableOfContents} from '@docusaurus/types';
import type {BlogSidebar} from '@theme/BlogSidebar';
export type FrontMatter = {
readonly title: string;
@ -49,6 +66,7 @@ declare module '@theme/BlogPostPage' {
};
export type Props = {
readonly sidebar: BlogSidebar;
readonly content: Content;
};
@ -57,8 +75,8 @@ declare module '@theme/BlogPostPage' {
}
declare module '@theme/BlogListPage' {
// eslint-disable-next-line import/no-duplicates
import type {Content} from '@theme/BlogPostPage';
import type {BlogSidebar} from '@theme/BlogSidebar';
export type Item = {
readonly content: () => JSX.Element;
@ -77,6 +95,7 @@ declare module '@theme/BlogListPage' {
};
export type Props = {
readonly sidebar: BlogSidebar;
readonly metadata: Metadata;
readonly items: readonly {readonly content: Content}[];
};
@ -86,6 +105,8 @@ declare module '@theme/BlogListPage' {
}
declare module '@theme/BlogTagsListPage' {
import type {BlogSidebar} from '@theme/BlogSidebar';
export type Tag = {
permalink: string;
name: string;
@ -94,18 +115,22 @@ declare module '@theme/BlogTagsListPage' {
slug: string;
};
export type Props = {readonly tags: Readonly<Record<string, Tag>>};
export type Props = {
readonly sidebar: BlogSidebar;
readonly tags: Readonly<Record<string, Tag>>;
};
const BlogTagsListPage: (props: Props) => JSX.Element;
export default BlogTagsListPage;
}
declare module '@theme/BlogTagsPostsPage' {
import type {BlogSidebar} from '@theme/BlogSidebar';
import type {Tag} from '@theme/BlogTagsListPage';
// eslint-disable-next-line import/no-duplicates
import type {Content} from '@theme/BlogPostPage';
export type Props = {
readonly sidebar: BlogSidebar;
readonly metadata: Tag;
readonly items: readonly {readonly content: Content}[];
};

View file

@ -27,7 +27,7 @@
"fs-extra": "^8.1.0",
"globby": "^10.0.1",
"loader-utils": "^1.2.3",
"lodash.kebabcase": "^4.1.1",
"lodash": "^4.5.2",
"reading-time": "^1.2.0",
"remark-admonitions": "^1.2.1",
"webpack": "^4.44.1"

View file

@ -81,3 +81,42 @@ test('should convert all feed type to array with other feed type', () => {
feedOptions: {type: ['rss', 'atom']},
});
});
describe('blog sidebar', () => {
test('should accept 0 sidebar count', () => {
const userOptions = {blogSidebarCount: 0};
const {value, error} = PluginOptionSchema.validate(userOptions);
expect(value).toEqual({...DEFAULT_OPTIONS, ...userOptions});
expect(error).toBe(undefined);
});
test('should accept "ALL" sidebar count', () => {
const userOptions = {blogSidebarCount: 'ALL'};
const {value, error} = PluginOptionSchema.validate(userOptions);
expect(value).toEqual({...DEFAULT_OPTIONS, ...userOptions});
expect(error).toBe(undefined);
});
test('should reject "abcdef" sidebar count', () => {
const userOptions = {blogSidebarCount: 'abcdef'};
const {error} = PluginOptionSchema.validate(userOptions);
expect(error).toMatchInlineSnapshot(
`[ValidationError: "blogSidebarCount" must be one of [ALL, number]]`,
);
});
test('should accept "all posts" sidebar title', () => {
const userOptions = {blogSidebarTitle: 'all posts'};
const {value, error} = PluginOptionSchema.validate(userOptions);
expect(value).toEqual({...DEFAULT_OPTIONS, ...userOptions});
expect(error).toBe(undefined);
});
test('should reject 42 sidebar title', () => {
const userOptions = {blogSidebarTitle: 42};
const {error} = PluginOptionSchema.validate(userOptions);
expect(error).toMatchInlineSnapshot(
`[ValidationError: "blogSidebarTitle" must be a string]`,
);
});
});

View file

@ -6,7 +6,6 @@
*/
import fs from 'fs-extra';
import kebabCase from 'lodash.kebabcase';
import path from 'path';
import admonitions from 'remark-admonitions';
import {normalizeUrl, docuHash, aliasedSitePath} from '@docusaurus/utils';
@ -15,6 +14,7 @@ import {
DEFAULT_PLUGIN_ID,
} from '@docusaurus/core/lib/constants';
import {ValidationError} from '@hapi/joi';
import {take, kebabCase} from 'lodash';
import {
PluginOptions,
@ -50,12 +50,13 @@ export default function pluginContentBlog(
const {siteDir, generatedFilesDir} = context;
const contentPath = path.resolve(siteDir, options.path);
const pluginId = options.id ?? DEFAULT_PLUGIN_ID;
const pluginDataDirRoot = path.join(
generatedFilesDir,
'docusaurus-plugin-content-blog',
);
const dataDir = path.join(pluginDataDirRoot, options.id ?? DEFAULT_PLUGIN_ID);
const dataDir = path.join(pluginDataDirRoot, pluginId);
const aliasedSource = (source: string) =>
`~blog/${path.relative(pluginDataDirRoot, source)}`;
@ -217,6 +218,29 @@ export default function pluginContentBlog(
const blogItemsToMetadata: BlogItemsToMetadata = {};
const sidebarBlogPosts =
options.blogSidebarCount === 'ALL'
? blogPosts
: take(blogPosts, options.blogSidebarCount);
// This prop is useful to provide the blog list sidebar
const sidebarProp = await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
`blog-post-list-prop-${pluginId}.json`,
JSON.stringify(
{
title: options.blogSidebarTitle,
items: sidebarBlogPosts.map((blogPost) => ({
title: blogPost.metadata.title,
permalink: blogPost.metadata.permalink,
})),
},
null,
2,
),
);
// Create routes for blog entries.
await Promise.all(
loadedBlogPosts.map(async (blogPost) => {
@ -233,6 +257,7 @@ export default function pluginContentBlog(
component: blogPostComponent,
exact: true,
modules: {
sidebar: sidebarProp,
content: metadata.source,
},
});
@ -256,6 +281,7 @@ export default function pluginContentBlog(
component: blogListComponent,
exact: true,
modules: {
sidebar: sidebarProp,
items: items.map((postID) => {
// To tell routes.js this is an import and not a nested object to recurse.
return {
@ -303,6 +329,7 @@ export default function pluginContentBlog(
component: blogTagsPostsComponent,
exact: true,
modules: {
sidebar: sidebarProp,
items: items.map((postID) => {
const metadata = blogItemsToMetadata[postID];
return {
@ -333,6 +360,7 @@ export default function pluginContentBlog(
component: blogTagsListComponent,
exact: true,
modules: {
sidebar: sidebarProp,
tags: aliasedSource(tagsListPath),
},
});

View file

@ -28,6 +28,8 @@ export const DEFAULT_OPTIONS = {
blogListComponent: '@theme/BlogListPage',
blogDescription: 'Blog',
blogTitle: 'Blog',
blogSidebarCount: 5,
blogSidebarTitle: 'Recent posts',
postsPerPage: 10,
include: ['*.md', '*.mdx'],
routeBasePath: 'blog',
@ -57,6 +59,10 @@ export const PluginOptionSchema = Joi.object({
blogDescription: Joi.string()
.allow('')
.default(DEFAULT_OPTIONS.blogDescription),
blogSidebarCount: Joi.alternatives()
.try(Joi.equal('ALL').required(), Joi.number().required())
.default(DEFAULT_OPTIONS.blogSidebarCount),
blogSidebarTitle: Joi.string().default(DEFAULT_OPTIONS.blogSidebarTitle),
showReadingTime: Joi.bool().default(DEFAULT_OPTIONS.showReadingTime),
remarkPlugins: RemarkPluginsSchema.default(DEFAULT_OPTIONS.remarkPlugins),
rehypePlugins: RehypePluginsSchema.default(DEFAULT_OPTIONS.rehypePlugins),

View file

@ -31,6 +31,8 @@ export interface PluginOptions {
blogTagsPostsComponent: string;
blogTitle: string;
blogDescription: string;
blogSidebarCount: number | 'ALL';
blogSidebarTitle: string;
remarkPlugins: ([Function, object] | Function)[];
beforeDefaultRehypePlugins: ([Function, object] | Function)[];
beforeDefaultRemarkPlugins: ([Function, object] | Function)[];