diff --git a/packages/docusaurus-plugin-content-blog/index.d.ts b/packages/docusaurus-plugin-content-blog/index.d.ts index 1565438024..ce5f0baf59 100644 --- a/packages/docusaurus-plugin-content-blog/index.d.ts +++ b/packages/docusaurus-plugin-content-blog/index.d.ts @@ -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>}; + export type Props = { + readonly sidebar: BlogSidebar; + readonly tags: Readonly>; + }; 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}[]; }; diff --git a/packages/docusaurus-plugin-content-blog/package.json b/packages/docusaurus-plugin-content-blog/package.json index b19e6396da..8be0e5e3cb 100644 --- a/packages/docusaurus-plugin-content-blog/package.json +++ b/packages/docusaurus-plugin-content-blog/package.json @@ -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" diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/pluginOptionSchema.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/pluginOptionSchema.test.ts index 7d3ea5d9a9..4390ab7b6d 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/pluginOptionSchema.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/pluginOptionSchema.test.ts @@ -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]`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index 9cf9d2c488..041e2a4066 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -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), }, }); diff --git a/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts b/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts index f17eb8ddbf..c8b7045c89 100644 --- a/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts +++ b/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts @@ -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), diff --git a/packages/docusaurus-plugin-content-blog/src/types.ts b/packages/docusaurus-plugin-content-blog/src/types.ts index 7b64ac5d61..0ee2e2acc6 100644 --- a/packages/docusaurus-plugin-content-blog/src/types.ts +++ b/packages/docusaurus-plugin-content-blog/src/types.ts @@ -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)[]; diff --git a/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx index 89fd9707cb..d5f65fb6fb 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx @@ -12,9 +12,10 @@ import Layout from '@theme/Layout'; import BlogPostItem from '@theme/BlogPostItem'; import BlogListPaginator from '@theme/BlogListPaginator'; import type {Props} from '@theme/BlogListPage'; +import BlogSidebar from '@theme/BlogSidebar'; function BlogListPage(props: Props): JSX.Element { - const {metadata, items} = props; + const {metadata, items, sidebar} = props; const { siteConfig: {title: siteTitle}, } = useDocusaurusContext(); @@ -25,7 +26,10 @@ function BlogListPage(props: Props): JSX.Element {
-
+
+ +
+
{items.map(({content: BlogPostContent}) => (
-
+
+ +
+
+

{sidebar.title}

+
    + {sidebar.items.map((item) => { + return ( +
  • + + {item.title} + +
  • + ); + })} +
+
+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogSidebar/styles.module.css b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/styles.module.css new file mode 100644 index 0000000000..987abd022c --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/BlogSidebar/styles.module.css @@ -0,0 +1,47 @@ +/** + * 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. + */ + +.sidebar { + display: inherit; + max-height: calc(100vh - (var(--ifm-navbar-height) + 2rem)); + overflow-y: auto; + position: sticky; + top: calc(var(--ifm-navbar-height) + 2rem); +} + +.sidebarItemTitle { + margin-bottom: 0.5rem; +} + +.sidebarItemList { + overflow-y: auto; + font-size: .9rem; + padding-left: 0; +} + +.sidebarItem { + list-style-type: none; + margin-top: 0.8rem; + margin-bottom: 0.8rem; +} + +.sidebarItemLink { + color: var(--ifm-font-color-base); +} +.sidebarItemLink:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} +.sidebarItemLinkActive { + color: var(--ifm-color-primary); +} + +@media only screen and (max-width: 996px) { + .sidebar { + display: none; + } +} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogTagsListPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogTagsListPage/index.tsx index c35ab3a987..89cbde8bef 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogTagsListPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogTagsListPage/index.tsx @@ -10,6 +10,7 @@ import React from 'react'; import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; import type {Props} from '@theme/BlogTagsListPage'; +import BlogSidebar from '@theme/BlogSidebar'; function getCategoryOfTag(tag: string) { // tag's category should be customizable @@ -17,7 +18,7 @@ function getCategoryOfTag(tag: string) { } function BlogTagsListPage(props: Props): JSX.Element { - const {tags} = props; + const {tags, sidebar} = props; const tagCategories: {[category: string]: string[]} = {}; Object.keys(tags).forEach((tag) => { @@ -52,7 +53,10 @@ function BlogTagsListPage(props: Props): JSX.Element {
-
+
+ +
+

Tags

{tagsSection}
diff --git a/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx index 6a67bb9666..d086394e34 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx @@ -11,13 +11,14 @@ import Layout from '@theme/Layout'; import BlogPostItem from '@theme/BlogPostItem'; import Link from '@docusaurus/Link'; import type {Props} from '@theme/BlogTagsPostsPage'; +import BlogSidebar from '@theme/BlogSidebar'; function pluralize(count: number, word: string) { return count > 1 ? `${word}s` : word; } function BlogTagsPostPage(props: Props): JSX.Element { - const {metadata, items} = props; + const {metadata, items, sidebar} = props; const {allTagsPath, name: tagName, count} = metadata; return ( @@ -26,7 +27,10 @@ function BlogTagsPostPage(props: Props): JSX.Element { description={`Blog | Tagged "${tagName}"`}>
-
+
+ +
+

{count} {pluralize(count, 'post')} tagged with "{tagName} " diff --git a/website/docs/using-plugins.md b/website/docs/using-plugins.md index 9baa9d1e65..207ac5a7e2 100644 --- a/website/docs/using-plugins.md +++ b/website/docs/using-plugins.md @@ -208,6 +208,16 @@ module.exports = { * Blog page meta description for better SEO */ blogDescription: 'Blog', + /** + * Number of blog post elements to show in the blog sidebar + * 'ALL' to show all blog posts + * 0 to disable + */ + blogSidebarCount: 5, + /** + * Title of the blog sidebar + */ + blogSidebarTitle: 'All our posts', /** * URL route for the blog section of your site. * *DO NOT* include a trailing slash. diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 56fa70ca7f..b63be75e0d 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -216,6 +216,8 @@ module.exports = { type: 'all', copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc.`, }, + blogSidebarCount: 'ALL', + blogSidebarTitle: 'All our posts', }, pages: { remarkPlugins: [require('@docusaurus/remark-plugin-npm2yarn')],