From f356e29938e48e9c4653f4a49caff2e8ca760cb3 Mon Sep 17 00:00:00 2001 From: ozaki <29860391+OzakIOne@users.noreply.github.com> Date: Thu, 1 Aug 2024 17:30:49 +0200 Subject: [PATCH] feat(blog): authors page (#10216) Co-authored-by: OzakIOne Co-authored-by: sebastien Co-authored-by: slorber --- .../__fixtures__/website/blog/authors.yml | 1 + .../authors.yml | 2 + .../__snapshots__/blogUtils.test.ts.snap | 19 + .../__snapshots__/index.test.ts.snap | 2 + .../src/__tests__/authors.test.ts | 475 ++++++------------ .../src/__tests__/authorsMap.test.ts | 307 +++++++++++ .../src/__tests__/authorsProblems.test.ts | 62 +-- .../src/__tests__/blogUtils.test.ts | 18 + .../src/__tests__/feed.test.ts | 11 +- .../src/__tests__/index.test.ts | 7 + .../src/authors.ts | 168 +++---- .../src/authorsMap.ts | 171 +++++++ .../src/blogUtils.ts | 11 +- .../src/index.ts | 24 +- .../src/options.ts | 12 + .../src/plugin-content-blog.d.ts | 102 +++- .../src/props.ts | 15 + .../src/routes.ts | 99 +++- .../src/getSwizzleConfig.ts | 21 + .../src/theme-classic.d.ts | 49 +- .../Components}/Author/Socials/index.tsx | 11 +- .../Author/Socials/styles.module.css | 9 +- .../theme/Blog/Components/Author/index.tsx | 99 ++++ .../Blog/Components/Author/styles.module.css | 74 +++ .../Blog/Pages/BlogAuthorsListPage/index.tsx | 62 +++ .../BlogAuthorsListPage/styles.module.css | 11 + .../Blog/Pages/BlogAuthorsPostsPage/index.tsx | 73 +++ .../BlogPostItem/Header/Author/index.tsx | 62 --- .../Header/Author/styles.module.css | 21 - .../BlogPostItem/Header/Authors/index.tsx | 5 +- .../src/theme/BlogTagsPostsPage/index.tsx | 34 +- .../docusaurus-theme-common/src/internal.ts | 7 + .../src/translations/blogTranslations.tsx | 79 +++ .../src/utils/ThemeClassNames.ts | 2 + .../{tagUtils.test.ts => tagsUtils.test.ts} | 21 +- .../locales/base/theme-common.json | 3 + .../dataFiles/actualData/bad.json | 1 - .../__fixtures__/dataFiles/actualData/bad.yml | 1 - .../dataFiles/actualData/valid.json | 1 - .../dataFiles/actualData/valid.yml | 1 - .../dataFiles/dataFiles/dataFile.json | 1 + .../dataFiles/dataFiles/dataFile.yml | 1 + .../dataFiles/dataFiles/invalid.yml | 1 + .../dataFiles/localized/dataFile.yml | 1 + .../src/__tests__/dataFileUtils.test.ts | 52 +- .../docusaurus-utils/src/dataFileUtils.ts | 27 +- packages/docusaurus-utils/src/index.ts | 2 +- packages/docusaurus-utils/src/tags.ts | 1 + project-words.txt | 1 + .../_blog tests/2024-07-03-dual-author.mdx | 8 +- .../_blog tests/2024-07-03-single-author.mdx | 1 - website/_dogfooding/_blog tests/authors.yml | 5 + website/blog/authors.yml | 12 +- .../docs/api/plugins/plugin-content-blog.mdx | 69 +++ website/docs/blog.mdx | 33 ++ .../ChangelogItem/Header/Author/index.tsx | 2 +- 56 files changed, 1667 insertions(+), 703 deletions(-) create mode 100644 packages/docusaurus-plugin-content-blog/src/__tests__/authorsMap.test.ts create mode 100644 packages/docusaurus-plugin-content-blog/src/authorsMap.ts rename packages/docusaurus-theme-classic/src/theme/{BlogPostItem/Header => Blog/Components}/Author/Socials/index.tsx (86%) rename packages/docusaurus-theme-classic/src/theme/{BlogPostItem/Header => Blog/Components}/Author/Socials/styles.module.css (75%) create mode 100644 packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/index.tsx create mode 100644 packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/styles.module.css create mode 100644 packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/index.tsx create mode 100644 packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/styles.module.css create mode 100644 packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsPostsPage/index.tsx delete mode 100644 packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/index.tsx delete mode 100644 packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/styles.module.css create mode 100644 packages/docusaurus-theme-common/src/translations/blogTranslations.tsx rename packages/docusaurus-theme-common/src/utils/__tests__/{tagUtils.test.ts => tagsUtils.test.ts} (78%) delete mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.json delete mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.yml delete mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.json delete mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.yml create mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.json create mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.yml create mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml create mode 100644 packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/localized/dataFile.yml diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml index c44d2ee68da6..6e7482b6ebd6 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml @@ -7,3 +7,4 @@ slorber: twitter: sebastienlorber x: https://x.com/sebastienlorber github: slorber + page: true diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml index f509f4ff45ee..0e68e44c2151 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml @@ -2,3 +2,5 @@ slorber: name: Sébastien Lorber (translated) title: Docusaurus maintainer (translated) email: lorber.sebastien@gmail.com + page: + permalink: "/slorber-custom-permalink-localized" diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/blogUtils.test.ts.snap b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/blogUtils.test.ts.snap index 038e71ca8f8e..12f6076df5ad 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/blogUtils.test.ts.snap +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/blogUtils.test.ts.snap @@ -25,6 +25,25 @@ exports[`paginateBlogPosts generates a single page 1`] = ` ] `; +exports[`paginateBlogPosts generates pages - 0 blog post 1`] = ` +[ + { + "items": [], + "metadata": { + "blogDescription": "Blog Description", + "blogTitle": "Blog Title", + "nextPage": undefined, + "page": 1, + "permalink": "/blog", + "postsPerPage": 2, + "previousPage": undefined, + "totalCount": 0, + "totalPages": 1, + }, + }, +] +`; + exports[`paginateBlogPosts generates pages 1`] = ` [ { diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/index.test.ts.snap index a9ff614dbab4..30c280918767 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/index.test.ts.snap @@ -121,7 +121,9 @@ exports[`blog plugin process blog posts load content 2`] = ` "authors": [ { "imageURL": undefined, + "key": null, "name": "Sébastien Lorber", + "page": null, "title": "Docusaurus maintainer", "url": "https://sebastienlorber.com", }, diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts index 851bf77fa2fd..7072e8124caa 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts @@ -5,13 +5,13 @@ * LICENSE file in the root directory of this source tree. */ -import * as path from 'path'; -import { - type AuthorsMap, - getAuthorsMap, - getBlogPostAuthors, - validateAuthorsMap, -} from '../authors'; +import {fromPartial, type PartialDeep} from '@total-typescript/shoehorn'; +import {getBlogPostAuthors, groupBlogPostsByAuthorKey} from '../authors'; +import type {AuthorsMap, BlogPost} from '@docusaurus/plugin-content-blog'; + +function post(partial: PartialDeep): BlogPost { + return fromPartial(partial); +} describe('getBlogPostAuthors', () => { it('can read no authors', () => { @@ -42,7 +42,15 @@ describe('getBlogPostAuthors', () => { authorsMap: undefined, baseUrl: '/', }), - ).toEqual([{name: 'Sébastien Lorber'}]); + ).toEqual([ + { + name: 'Sébastien Lorber', + imageURL: undefined, + key: null, + page: null, + title: undefined, + }, + ]); expect( getBlogPostAuthors({ frontMatter: { @@ -51,7 +59,15 @@ describe('getBlogPostAuthors', () => { authorsMap: undefined, baseUrl: '/', }), - ).toEqual([{title: 'maintainer'}]); + ).toEqual([ + { + title: 'maintainer', + imageURL: undefined, + key: null, + name: undefined, + page: null, + }, + ]); expect( getBlogPostAuthors({ frontMatter: { @@ -60,7 +76,14 @@ describe('getBlogPostAuthors', () => { authorsMap: undefined, baseUrl: '/', }), - ).toEqual([{imageURL: 'https://github.com/slorber.png'}]); + ).toEqual([ + { + imageURL: 'https://github.com/slorber.png', + key: null, + name: undefined, + page: null, + }, + ]); expect( getBlogPostAuthors({ frontMatter: { @@ -69,7 +92,14 @@ describe('getBlogPostAuthors', () => { authorsMap: undefined, baseUrl: '/', }), - ).toEqual([{imageURL: '/img/slorber.png'}]); + ).toEqual([ + { + imageURL: '/img/slorber.png', + key: null, + name: undefined, + page: null, + }, + ]); expect( getBlogPostAuthors({ frontMatter: { @@ -78,7 +108,15 @@ describe('getBlogPostAuthors', () => { authorsMap: undefined, baseUrl: '/baseURL', }), - ).toEqual([{imageURL: '/baseURL/img/slorber.png'}]); + ).toEqual([ + { + imageURL: '/baseURL/img/slorber.png', + + key: null, + name: undefined, + page: null, + }, + ]); expect( getBlogPostAuthors({ frontMatter: { @@ -99,6 +137,8 @@ describe('getBlogPostAuthors', () => { title: 'maintainer1', imageURL: 'https://github.com/slorber1.png', url: 'https://github.com/slorber1', + key: null, + page: null, }, ]); }); @@ -109,10 +149,19 @@ describe('getBlogPostAuthors', () => { frontMatter: { authors: 'slorber', }, - authorsMap: {slorber: {name: 'Sébastien Lorber'}}, + authorsMap: { + slorber: {name: 'Sébastien Lorber', key: 'slorber', page: null}, + }, baseUrl: '/', }), - ).toEqual([{key: 'slorber', name: 'Sébastien Lorber'}]); + ).toEqual([ + { + key: 'slorber', + name: 'Sébastien Lorber', + imageURL: undefined, + page: null, + }, + ]); expect( getBlogPostAuthors({ frontMatter: { @@ -122,6 +171,8 @@ describe('getBlogPostAuthors', () => { slorber: { name: 'Sébastien Lorber', imageURL: 'https://github.com/slorber.png', + key: 'slorber', + page: null, }, }, baseUrl: '/', @@ -131,6 +182,7 @@ describe('getBlogPostAuthors', () => { key: 'slorber', name: 'Sébastien Lorber', imageURL: 'https://github.com/slorber.png', + page: null, }, ]); expect( @@ -142,6 +194,8 @@ describe('getBlogPostAuthors', () => { slorber: { name: 'Sébastien Lorber', imageURL: '/img/slorber.png', + key: 'slorber', + page: null, }, }, baseUrl: '/', @@ -151,6 +205,7 @@ describe('getBlogPostAuthors', () => { key: 'slorber', name: 'Sébastien Lorber', imageURL: '/img/slorber.png', + page: null, }, ]); expect( @@ -162,6 +217,8 @@ describe('getBlogPostAuthors', () => { slorber: { name: 'Sébastien Lorber', imageURL: '/img/slorber.png', + key: 'slorber', + page: null, }, }, baseUrl: '/baseUrl', @@ -171,6 +228,7 @@ describe('getBlogPostAuthors', () => { key: 'slorber', name: 'Sébastien Lorber', imageURL: '/baseUrl/img/slorber.png', + page: null, }, ]); }); @@ -182,14 +240,31 @@ describe('getBlogPostAuthors', () => { authors: ['slorber', 'yangshun'], }, authorsMap: { - slorber: {name: 'Sébastien Lorber', title: 'maintainer'}, - yangshun: {name: 'Yangshun Tay'}, + slorber: { + name: 'Sébastien Lorber', + title: 'maintainer', + key: 'slorber', + page: null, + }, + yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null}, }, baseUrl: '/', }), ).toEqual([ - {key: 'slorber', name: 'Sébastien Lorber', title: 'maintainer'}, - {key: 'yangshun', name: 'Yangshun Tay'}, + { + key: 'slorber', + name: 'Sébastien Lorber', + title: 'maintainer', + imageURL: undefined, + page: null, + }, + { + key: 'yangshun', + name: 'Yangshun Tay', + imageURL: undefined, + + page: null, + }, ]); }); @@ -202,7 +277,15 @@ describe('getBlogPostAuthors', () => { authorsMap: undefined, baseUrl: '/', }), - ).toEqual([{name: 'Sébastien Lorber', title: 'maintainer'}]); + ).toEqual([ + { + name: 'Sébastien Lorber', + title: 'maintainer', + imageURL: undefined, + key: null, + page: null, + }, + ]); }); it('can read authors Author[]', () => { @@ -218,8 +301,14 @@ describe('getBlogPostAuthors', () => { baseUrl: '/', }), ).toEqual([ - {name: 'Sébastien Lorber', title: 'maintainer'}, - {name: 'Yangshun Tay'}, + { + name: 'Sébastien Lorber', + title: 'maintainer', + imageURL: undefined, + key: null, + page: null, + }, + {name: 'Yangshun Tay', imageURL: undefined, key: null, page: null}, ]); }); @@ -238,66 +327,38 @@ describe('getBlogPostAuthors', () => { ], }, authorsMap: { - slorber: {name: 'Sébastien Lorber', title: 'maintainer'}, - yangshun: {name: 'Yangshun Tay', title: 'Yangshun title original'}, + slorber: { + name: 'Sébastien Lorber', + title: 'maintainer', + key: 'slorber', + page: null, + }, + yangshun: { + name: 'Yangshun Tay', + title: 'Yangshun title original', + key: 'yangshun', + page: null, + }, }, baseUrl: '/', }), ).toEqual([ - {key: 'slorber', name: 'Sébastien Lorber', title: 'maintainer'}, + { + key: 'slorber', + name: 'Sébastien Lorber', + title: 'maintainer', + imageURL: undefined, + page: null, + }, { key: 'yangshun', name: 'Yangshun Tay', title: 'Yangshun title local override', extra: 42, + imageURL: undefined, + page: null, }, - {name: 'Alexey'}, - ]); - }); - - it('can normalize inline authors', () => { - expect( - getBlogPostAuthors({ - frontMatter: { - authors: [ - { - name: 'Seb1', - socials: { - x: 'https://x.com/sebastienlorber', - twitter: 'sebastienlorber', - github: 'slorber', - }, - }, - { - name: 'Seb2', - socials: { - x: 'sebastienlorber', - twitter: 'https://twitter.com/sebastienlorber', - github: 'https://github.com/slorber', - }, - }, - ], - }, - authorsMap: {}, - baseUrl: '/', - }), - ).toEqual([ - { - name: 'Seb1', - socials: { - x: 'https://x.com/sebastienlorber', - twitter: 'https://twitter.com/sebastienlorber', - github: 'https://github.com/slorber', - }, - }, - { - name: 'Seb2', - socials: { - x: 'https://x.com/sebastienlorber', - twitter: 'https://twitter.com/sebastienlorber', - github: 'https://github.com/slorber', - }, - }, + {name: 'Alexey', imageURL: undefined, key: null, page: null}, ]); }); @@ -339,8 +400,8 @@ describe('getBlogPostAuthors', () => { }, authorsMap: { - yangshun: {name: 'Yangshun Tay'}, - jmarcey: {name: 'Joel Marcey'}, + yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null}, + jmarcey: {name: 'Joel Marcey', key: 'jmarcey', page: null}, }, baseUrl: '/', }), @@ -360,8 +421,8 @@ describe('getBlogPostAuthors', () => { }, authorsMap: { - yangshun: {name: 'Yangshun Tay'}, - jmarcey: {name: 'Joel Marcey'}, + yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null}, + jmarcey: {name: 'Joel Marcey', key: 'jmarcey', page: null}, }, baseUrl: '/', }), @@ -381,8 +442,8 @@ describe('getBlogPostAuthors', () => { }, authorsMap: { - yangshun: {name: 'Yangshun Tay'}, - jmarcey: {name: 'Joel Marcey'}, + yangshun: {name: 'Yangshun Tay', key: 'yangshun', page: null}, + jmarcey: {name: 'Joel Marcey', key: 'jmarcey', page: null}, }, baseUrl: '/', }), @@ -415,7 +476,9 @@ describe('getBlogPostAuthors', () => { authors: [{key: 'slorber'}], author_title: 'legacy title', }, - authorsMap: {slorber: {name: 'Sébastien Lorber'}}, + authorsMap: { + slorber: {name: 'Sébastien Lorber', key: 'slorber', page: null}, + }, baseUrl: '/', }), ).toThrowErrorMatchingInlineSnapshot(` @@ -425,241 +488,37 @@ describe('getBlogPostAuthors', () => { }); }); -describe('getAuthorsMap', () => { - const fixturesDir = path.join(__dirname, '__fixtures__/authorsMapFiles'); - const contentPaths = { - contentPathLocalized: fixturesDir, - contentPath: fixturesDir, - }; - - it('getAuthorsMap can read yml file', async () => { - await expect( - getAuthorsMap({ - contentPaths, - authorsMapPath: 'authors.yml', - }), - ).resolves.toBeDefined(); +describe('groupBlogPostsByAuthorKey', () => { + const authorsMap: AuthorsMap = fromPartial({ + ozaki: {}, + slorber: {}, + keyWithNoPost: {}, }); - it('getAuthorsMap can read json file', async () => { - await expect( - getAuthorsMap({ - contentPaths, - authorsMapPath: 'authors.json', - }), - ).resolves.toBeDefined(); - }); - - it('getAuthorsMap can return undefined if yaml file not found', async () => { - await expect( - getAuthorsMap({ - contentPaths, - authorsMapPath: 'authors_does_not_exist.yml', - }), - ).resolves.toBeUndefined(); - }); - - describe('getAuthorsMap returns normalized', () => { - it('socials', async () => { - const authorsMap = await getAuthorsMap({ - contentPaths, - authorsMapPath: 'authors.yml', - }); - expect(authorsMap.slorber.socials).toMatchInlineSnapshot(` - { - "stackoverflow": "https://stackoverflow.com/users/82609", - "twitter": "https://twitter.com/sebastienlorber", - "x": "https://x.com/sebastienlorber", - } - `); - expect(authorsMap.JMarcey.socials).toMatchInlineSnapshot(` - { - "stackoverflow": "https://stackoverflow.com/users/102705/Joel-Marcey", - "twitter": "https://twitter.com/JoelMarcey", - "x": "https://x.com/JoelMarcey", - } - `); + it('can group blog posts', () => { + const post1 = post({metadata: {authors: [{key: 'ozaki'}]}}); + const post2 = post({ + metadata: {authors: [{key: 'slorber'}, {key: 'ozaki'}]}, }); - }); -}); - -describe('validateAuthorsMap', () => { - it('accept valid authors map', () => { - const authorsMap: AuthorsMap = { - slorber: { - name: 'Sébastien Lorber', - title: 'maintainer', - url: 'https://sebastienlorber.com', - imageURL: 'https://github.com/slorber.png', - }, - yangshun: { - name: 'Yangshun Tay', - imageURL: 'https://github.com/yangshun.png', - randomField: 42, - }, - jmarcey: { - name: 'Joel', - title: 'creator of Docusaurus', - hello: new Date(), - }, - }; - expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap); - }); - - it('rename snake case image_url to camelCase imageURL', () => { - const authorsMap: AuthorsMap = { - slorber: { - name: 'Sébastien Lorber', - image_url: 'https://github.com/slorber.png', - }, - }; - expect(validateAuthorsMap(authorsMap)).toEqual({ - slorber: { - name: 'Sébastien Lorber', - imageURL: 'https://github.com/slorber.png', - }, + const post3 = post({metadata: {authors: [{key: 'slorber'}]}}); + const post4 = post({ + metadata: {authors: [{name: 'Inline author 1'}, {key: 'slorber'}]}, + }); + const post5 = post({ + metadata: {authors: [{name: 'Inline author 2'}]}, + }); + const post6 = post({ + metadata: {authors: [{key: 'unknownKey'}]}, }); - }); - - it('accept author with only image', () => { - const authorsMap: AuthorsMap = { - slorber: { - imageURL: 'https://github.com/slorber.png', - url: 'https://github.com/slorber', - }, - }; - expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap); - }); - - it('reject author without name or image', () => { - const authorsMap: AuthorsMap = { - slorber: { - title: 'foo', - }, - }; - expect(() => - validateAuthorsMap(authorsMap), - ).toThrowErrorMatchingInlineSnapshot( - `""slorber" must contain at least one of [name, imageURL]"`, - ); - }); - - it('reject undefined author', () => { - expect(() => - validateAuthorsMap({ - slorber: undefined, - }), - ).toThrowErrorMatchingInlineSnapshot( - `""slorber" cannot be undefined. It should be an author object containing properties like name, title, and imageURL."`, - ); - }); - - it('reject null author', () => { - expect(() => - validateAuthorsMap({ - slorber: null, - }), - ).toThrowErrorMatchingInlineSnapshot( - `""slorber" should be an author object containing properties like name, title, and imageURL."`, - ); - }); - - it('reject array author', () => { - expect(() => - validateAuthorsMap({slorber: []}), - ).toThrowErrorMatchingInlineSnapshot( - `""slorber" should be an author object containing properties like name, title, and imageURL."`, - ); - }); - - it('reject array content', () => { - expect(() => validateAuthorsMap([])).toThrowErrorMatchingInlineSnapshot( - `"The authors map file should contain an object where each entry contains an author key and the corresponding author's data."`, - ); - }); - - it('reject flat author', () => { - expect(() => - validateAuthorsMap({name: 'Sébastien'}), - ).toThrowErrorMatchingInlineSnapshot( - `""name" should be an author object containing properties like name, title, and imageURL."`, - ); - }); - - it('reject non-map author', () => { - const authorsMap: AuthorsMap = { - // @ts-expect-error: for tests - slorber: [], - }; - expect(() => - validateAuthorsMap(authorsMap), - ).toThrowErrorMatchingInlineSnapshot( - `""slorber" should be an author object containing properties like name, title, and imageURL."`, - ); - }); -}); - -describe('authors socials', () => { - it('valid known author map socials', () => { - const authorsMap: AuthorsMap = { - ozaki: { - name: 'ozaki', - socials: { - twitter: 'ozakione', - github: 'ozakione', - }, - }, - }; - - expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap); - }); - - it('throw socials that are not strings', () => { - const authorsMap: AuthorsMap = { - ozaki: { - name: 'ozaki', - socials: { - // @ts-expect-error: for tests - twitter: 42, - }, - }, - }; - - expect(() => - validateAuthorsMap(authorsMap), - ).toThrowErrorMatchingInlineSnapshot( - `""ozaki.socials.twitter" must be a string"`, - ); - }); - - it('throw socials that are objects', () => { - const authorsMap: AuthorsMap = { - ozaki: { - name: 'ozaki', - socials: { - // @ts-expect-error: for tests - twitter: {link: 'ozakione'}, - }, - }, - }; - - expect(() => - validateAuthorsMap(authorsMap), - ).toThrowErrorMatchingInlineSnapshot( - `""ozaki.socials.twitter" must be a string"`, - ); - }); - it('valid unknown author map socials', () => { - const authorsMap: AuthorsMap = { - ozaki: { - name: 'ozaki', - socials: { - random: 'ozakione', - }, - }, - }; + const blogPosts = [post1, post2, post3, post4, post5, post6]; - expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap); + expect(groupBlogPostsByAuthorKey({authorsMap, blogPosts})).toEqual({ + ozaki: [post1, post2], + slorber: [post2, post3, post4], + keyWithNoPost: [], + // We don't care about this edge case, it doesn't happen in practice + unknownKey: undefined, + }); }); }); diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/authorsMap.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/authorsMap.test.ts new file mode 100644 index 000000000000..b6393ede6854 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/authorsMap.test.ts @@ -0,0 +1,307 @@ +/** + * 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 path from 'path'; +import { + type AuthorsMapInput, + checkAuthorsMapPermalinkCollisions, + getAuthorsMap, + validateAuthorsMap, + validateAuthorsMapInput, +} from '../authorsMap'; +import type {AuthorsMap} from '@docusaurus/plugin-content-blog'; + +describe('checkAuthorsMapPermalinkCollisions', () => { + it('do not throw when permalinks are unique', () => { + const authors: AuthorsMap = { + author1: { + name: 'author1', + key: 'author1', + page: { + permalink: '/author1', + }, + }, + author2: { + name: 'author2', + key: 'author2', + page: { + permalink: '/author2', + }, + }, + }; + + expect(() => { + checkAuthorsMapPermalinkCollisions(authors); + }).not.toThrow(); + }); + + it('throw when permalinks collide', () => { + const authors: AuthorsMap = { + author1: { + name: 'author1', + key: 'author1', + page: { + permalink: '/author1', + }, + }, + author2: { + name: 'author1', + key: 'author1', + page: { + permalink: '/author1', + }, + }, + }; + + expect(() => { + checkAuthorsMapPermalinkCollisions(authors); + }).toThrowErrorMatchingInlineSnapshot(` + "The following permalinks are duplicated: + Permalink: /author1 + Authors: author1, author1" + `); + }); +}); + +describe('getAuthorsMap', () => { + const fixturesDir = path.join(__dirname, '__fixtures__/authorsMapFiles'); + const contentPaths = { + contentPathLocalized: fixturesDir, + contentPath: fixturesDir, + }; + + it('getAuthorsMap can read yml file', async () => { + await expect( + getAuthorsMap({ + contentPaths, + authorsMapPath: 'authors.yml', + authorsBaseRoutePath: '/authors', + }), + ).resolves.toBeDefined(); + }); + + it('getAuthorsMap can read json file', async () => { + await expect( + getAuthorsMap({ + contentPaths, + authorsMapPath: 'authors.json', + authorsBaseRoutePath: '/authors', + }), + ).resolves.toBeDefined(); + }); + + it('getAuthorsMap can return undefined if yaml file not found', async () => { + await expect( + getAuthorsMap({ + contentPaths, + authorsMapPath: 'authors_does_not_exist.yml', + authorsBaseRoutePath: '/authors', + }), + ).resolves.toBeUndefined(); + }); +}); + +describe('validateAuthorsMapInput', () => { + it('accept valid authors map', () => { + const authorsMap: AuthorsMapInput = { + slorber: { + name: 'Sébastien Lorber', + title: 'maintainer', + url: 'https://sebastienlorber.com', + imageURL: 'https://github.com/slorber.png', + key: 'slorber', + page: false, + }, + yangshun: { + name: 'Yangshun Tay', + imageURL: 'https://github.com/yangshun.png', + randomField: 42, + key: 'yangshun', + page: false, + }, + jmarcey: { + name: 'Joel', + title: 'creator of Docusaurus', + hello: new Date(), + key: 'jmarcey', + page: false, + }, + }; + expect(validateAuthorsMapInput(authorsMap)).toEqual(authorsMap); + }); + + it('rename snake case image_url to camelCase imageURL', () => { + const authorsMap: AuthorsMapInput = { + slorber: { + name: 'Sébastien Lorber', + image_url: 'https://github.com/slorber.png', + key: 'slorber', + page: false, + }, + }; + expect(validateAuthorsMapInput(authorsMap)).toEqual({ + slorber: { + name: 'Sébastien Lorber', + imageURL: 'https://github.com/slorber.png', + page: false, + key: 'slorber', + }, + }); + }); + + it('accept author with only image', () => { + const authorsMap: AuthorsMapInput = { + slorber: { + imageURL: 'https://github.com/slorber.png', + url: 'https://github.com/slorber', + key: 'slorber', + page: false, + }, + }; + expect(validateAuthorsMapInput(authorsMap)).toEqual(authorsMap); + }); + + it('reject author without name or image', () => { + const authorsMap: AuthorsMapInput = { + slorber: { + title: 'foo', + key: 'slorber', + page: false, + }, + }; + expect(() => + validateAuthorsMapInput(authorsMap), + ).toThrowErrorMatchingInlineSnapshot( + `""slorber" must contain at least one of [name, imageURL]"`, + ); + }); + + it('reject undefined author', () => { + expect(() => + validateAuthorsMapInput({ + slorber: undefined, + }), + ).toThrowErrorMatchingInlineSnapshot( + `""slorber" cannot be undefined. It should be an author object containing properties like name, title, and imageURL."`, + ); + }); + + it('reject null author', () => { + expect(() => + validateAuthorsMapInput({ + slorber: null, + }), + ).toThrowErrorMatchingInlineSnapshot( + `""slorber" should be an author object containing properties like name, title, and imageURL."`, + ); + }); + + it('reject array author', () => { + expect(() => + validateAuthorsMapInput({slorber: []}), + ).toThrowErrorMatchingInlineSnapshot( + `""slorber" should be an author object containing properties like name, title, and imageURL."`, + ); + }); + + it('reject array content', () => { + expect(() => + validateAuthorsMapInput([]), + ).toThrowErrorMatchingInlineSnapshot( + `"The authors map file should contain an object where each entry contains an author key and the corresponding author's data."`, + ); + }); + + it('reject flat author', () => { + expect(() => + validateAuthorsMapInput({name: 'Sébastien'}), + ).toThrowErrorMatchingInlineSnapshot( + `""name" should be an author object containing properties like name, title, and imageURL."`, + ); + }); + + it('reject non-map author', () => { + const authorsMap: AuthorsMapInput = { + // @ts-expect-error: intentionally invalid + slorber: [], + }; + expect(() => + validateAuthorsMapInput(authorsMap), + ).toThrowErrorMatchingInlineSnapshot( + `""slorber" should be an author object containing properties like name, title, and imageURL."`, + ); + }); +}); + +describe('authors socials', () => { + it('valid known author map socials', () => { + const authorsMap: AuthorsMapInput = { + ozaki: { + name: 'ozaki', + socials: { + twitter: 'ozakione', + github: 'ozakione', + }, + key: 'ozaki', + page: false, + }, + }; + + expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap); + }); + + it('throw socials that are not strings', () => { + const authorsMap: AuthorsMapInput = { + ozaki: { + name: 'ozaki', + socials: { + // @ts-expect-error: for tests + twitter: 42, + }, + }, + }; + + expect(() => + validateAuthorsMap(authorsMap), + ).toThrowErrorMatchingInlineSnapshot( + `""ozaki.socials.twitter" must be a string"`, + ); + }); + + it('throw socials that are objects', () => { + const authorsMap: AuthorsMapInput = { + ozaki: { + name: 'ozaki', + socials: { + // @ts-expect-error: for tests + twitter: {link: 'ozakione'}, + }, + }, + }; + + expect(() => + validateAuthorsMap(authorsMap), + ).toThrowErrorMatchingInlineSnapshot( + `""ozaki.socials.twitter" must be a string"`, + ); + }); + + it('valid unknown author map socials', () => { + const authorsMap: AuthorsMapInput = { + ozaki: { + name: 'ozaki', + socials: { + random: 'ozakione', + }, + key: 'ozaki', + page: false, + }, + }; + + expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap); + }); +}); diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/authorsProblems.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/authorsProblems.test.ts index 84296df1c931..ec64b3f225a0 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/authorsProblems.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/authorsProblems.test.ts @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -import {jest} from '@jest/globals'; import {reportDuplicateAuthors, reportInlineAuthors} from '../authorsProblems'; import type {Author} from '@docusaurus/plugin-content-blog'; @@ -23,9 +22,13 @@ describe('duplicate authors', () => { const authors: Author[] = [ { name: 'Sébastien Lorber', + key: null, + page: null, }, { name: 'Sébastien Lorber', + key: null, + page: null, }, ]; @@ -42,11 +45,13 @@ describe('duplicate authors', () => { key: 'slorber', name: 'Sébastien Lorber 1', title: 'some title', + page: null, }, { key: 'slorber', name: 'Sébastien Lorber 2', imageURL: '/slorber.png', + page: null, }, ]; @@ -56,7 +61,7 @@ describe('duplicate authors', () => { }), ).toThrowErrorMatchingInlineSnapshot(` "Duplicate blog post authors were found in blog post "doc.md" front matter: - - {"key":"slorber","name":"Sébastien Lorber 2","imageURL":"/slorber.png"}" + - {"key":"slorber","name":"Sébastien Lorber 2","imageURL":"/slorber.png","page":null}" `); }); }); @@ -91,10 +96,12 @@ describe('inline authors', () => { { key: 'slorber', name: 'Sébastien Lorber', + page: null, }, { key: 'ozaki', name: 'Clément Couriol', + page: null, }, ]; @@ -110,13 +117,15 @@ describe('inline authors', () => { { key: 'slorber', name: 'Sébastien Lorber', + page: null, }, - {name: 'Inline author 1'}, + {name: 'Inline author 1', page: null, key: null}, { key: 'ozaki', name: 'Clément Couriol', + page: null, }, - {imageURL: '/inline-author2.png'}, + {imageURL: '/inline-author2.png', page: null, key: null}, ]; expect(() => @@ -125,8 +134,8 @@ describe('inline authors', () => { }), ).toThrowErrorMatchingInlineSnapshot(` "Some blog authors used in "doc.md" are not defined in "authors.yml": - - {"name":"Inline author 1"} - - {"imageURL":"/inline-author2.png"} + - {"name":"Inline author 1","page":null,"key":null} + - {"imageURL":"/inline-author2.png","page":null,"key":null} Note that we recommend to declare authors once in a "authors.yml" file and reference them by key in blog posts front matter to avoid author info duplication. But if you want to allow inline blog authors, you can disable this message by setting onInlineAuthors: 'ignore' in your blog plugin options. @@ -134,45 +143,4 @@ describe('inline authors', () => { " `); }); - - it('warn inline authors', () => { - const authors: Author[] = [ - { - key: 'slorber', - name: 'Sébastien Lorber', - }, - {name: 'Inline author 1'}, - { - key: 'ozaki', - name: 'Clément Couriol', - }, - {imageURL: '/inline-author2.png'}, - ]; - - const consoleMock = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); - - expect(() => - testReport({ - authors, - options: { - onInlineAuthors: 'warn', - }, - }), - ).not.toThrow(); - expect(consoleMock).toHaveBeenCalledTimes(1); - expect(consoleMock.mock.calls[0]).toMatchInlineSnapshot(` - [ - "[WARNING] Some blog authors used in "doc.md" are not defined in "authors.yml": - - {"name":"Inline author 1"} - - {"imageURL":"/inline-author2.png"} - - Note that we recommend to declare authors once in a "authors.yml" file and reference them by key in blog posts front matter to avoid author info duplication. - But if you want to allow inline blog authors, you can disable this message by setting onInlineAuthors: 'ignore' in your blog plugin options. - More info at https://docusaurus.io/docs/blog - ", - ] - `); - }); }); diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts index 5b45f13a0823..a340ec61e79e 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts @@ -54,6 +54,24 @@ describe('paginateBlogPosts', () => { ).toMatchSnapshot(); }); + it('generates pages - 0 blog post', () => { + const pages = paginateBlogPosts({ + blogPosts: [], + basePageUrl: '/blog', + blogTitle: 'Blog Title', + blogDescription: 'Blog Description', + postsPerPageOption: 2, + pageBasePath: 'page', + }); + // As part ot https://github.com/facebook/docusaurus/pull/10216 + // it was decided that authors with "page: true" that haven't written any + // blog posts yet should still have a dedicated author page + // For this purpose, we generate an empty first page + expect(pages).toHaveLength(1); + expect(pages[0]!.items).toHaveLength(0); + expect(pages).toMatchSnapshot(); + }); + it('generates pages at blog root', () => { expect( paginateBlogPosts({ diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/feed.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/feed.test.ts index 3fbd4dbf27fd..ef57b4c7d63e 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/feed.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/feed.test.ts @@ -13,6 +13,7 @@ import {fromPartial} from '@total-typescript/shoehorn'; import {DEFAULT_OPTIONS} from '../options'; import {generateBlogPosts} from '../blogUtils'; import {createBlogFeedFiles} from '../feed'; +import {getAuthorsMap} from '../authorsMap'; import type {LoadContext, I18n} from '@docusaurus/types'; import type {BlogContentPaths} from '../types'; import type {PluginOptions} from '@docusaurus/plugin-content-blog'; @@ -51,10 +52,18 @@ async function testGenerateFeeds( context: LoadContext, options: PluginOptions, ): Promise { + const contentPaths = getBlogContentPaths(context.siteDir); + const authorsMap = await getAuthorsMap({ + contentPaths, + authorsMapPath: options.authorsMapPath, + authorsBaseRoutePath: '/authors', + }); + const blogPosts = await generateBlogPosts( - getBlogContentPaths(context.siteDir), + contentPaths, context, options, + authorsMap, ); await createBlogFeedFiles({ diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts index 5280a72c18c8..b5ffcb7571b6 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts @@ -220,12 +220,17 @@ describe('blog plugin', () => { authors: [ { name: 'Yangshun Tay (translated)', + imageURL: undefined, + key: null, + page: null, }, { email: 'lorber.sebastien@gmail.com', key: 'slorber', name: 'Sébastien Lorber (translated)', title: 'Docusaurus maintainer (translated)', + imageURL: undefined, + page: {permalink: '/blog/authors/slorber-custom-permalink-localized'}, }, ], date: new Date('2018-12-14'), @@ -319,6 +324,8 @@ describe('blog plugin', () => { title: 'Docusaurus maintainer', url: 'https://sebastienlorber.com', imageURL: undefined, + page: null, + key: null, }, ], prevItem: undefined, diff --git a/packages/docusaurus-plugin-content-blog/src/authors.ts b/packages/docusaurus-plugin-content-blog/src/authors.ts index 541521286e02..1fe0ef923815 100644 --- a/packages/docusaurus-plugin-content-blog/src/authors.ts +++ b/packages/docusaurus-plugin-content-blog/src/authors.ts @@ -5,83 +5,16 @@ * LICENSE file in the root directory of this source tree. */ -import * as _ from 'lodash'; -import {getDataFileData, normalizeUrl} from '@docusaurus/utils'; -import {Joi, URISchema} from '@docusaurus/utils-validation'; -import {AuthorSocialsSchema, normalizeSocials} from './authorsSocials'; -import type {BlogContentPaths} from './types'; +import _ from 'lodash'; +import {normalizeUrl} from '@docusaurus/utils'; import type { Author, + AuthorsMap, + BlogPost, BlogPostFrontMatter, BlogPostFrontMatterAuthor, - BlogPostFrontMatterAuthors, } from '@docusaurus/plugin-content-blog'; -export type AuthorsMap = {[authorKey: string]: Author}; - -const AuthorsMapSchema = Joi.object() - .pattern( - Joi.string(), - Joi.object({ - name: Joi.string(), - url: URISchema, - imageURL: URISchema, - title: Joi.string(), - email: Joi.string(), - socials: AuthorSocialsSchema, - }) - .rename('image_url', 'imageURL') - .or('name', 'imageURL') - .unknown() - .required() - .messages({ - 'object.base': - '{#label} should be an author object containing properties like name, title, and imageURL.', - 'any.required': - '{#label} cannot be undefined. It should be an author object containing properties like name, title, and imageURL.', - }), - ) - .messages({ - 'object.base': - "The authors map file should contain an object where each entry contains an author key and the corresponding author's data.", - }); - -export function validateAuthorsMap(content: unknown): AuthorsMap { - const {error, value} = AuthorsMapSchema.validate(content); - if (error) { - throw error; - } - return value; -} - -function normalizeAuthor(author: Author): Author { - return { - ...author, - socials: author.socials ? normalizeSocials(author.socials) : undefined, - }; -} - -function normalizeAuthorsMap(authorsMap: AuthorsMap): AuthorsMap { - return _.mapValues(authorsMap, normalizeAuthor); -} - -export async function getAuthorsMap(params: { - authorsMapPath: string; - contentPaths: BlogContentPaths; -}): Promise { - const authorsMap = await getDataFileData( - { - filePath: params.authorsMapPath, - contentPaths: params.contentPaths, - fileType: 'authors map', - }, - // TODO annoying to test: tightly coupled FS reads + validation... - validateAuthorsMap, - ); - - return authorsMap ? normalizeAuthorsMap(authorsMap) : undefined; -} - type AuthorsParam = { frontMatter: BlogPostFrontMatter; authorsMap: AuthorsMap | undefined; @@ -102,6 +35,7 @@ function normalizeImageUrl({ // Legacy v1/early-v2 front matter fields // We may want to deprecate those in favor of using only frontMatter.authors +// TODO Docusaurus v4: remove this legacy front matter function getFrontMatterAuthorLegacy({ baseUrl, frontMatter, @@ -123,37 +57,40 @@ function getFrontMatterAuthorLegacy({ title, url, imageURL, + // legacy front matter authors do not have an author key/page + key: null, + page: null, }; } return undefined; } -function normalizeFrontMatterAuthors( - frontMatterAuthors: BlogPostFrontMatterAuthors = [], -): BlogPostFrontMatterAuthor[] { - function normalizeFrontMatterAuthor( - authorInput: string | Author, - ): BlogPostFrontMatterAuthor { - if (typeof authorInput === 'string') { - // Technically, we could allow users to provide an author's name here, but - // we only support keys, otherwise, a typo in a key would fallback to - // becoming a name and may end up unnoticed - return {key: authorInput}; +function getFrontMatterAuthors(params: AuthorsParam): Author[] { + const {authorsMap, frontMatter, baseUrl} = params; + return normalizeFrontMatterAuthors().map(toAuthor); + + function normalizeFrontMatterAuthors(): BlogPostFrontMatterAuthor[] { + if (frontMatter.authors === undefined) { + return []; } - return authorInput; - } - return Array.isArray(frontMatterAuthors) - ? frontMatterAuthors.map(normalizeFrontMatterAuthor) - : [normalizeFrontMatterAuthor(frontMatterAuthors)]; -} + function normalizeAuthor( + authorInput: string | BlogPostFrontMatterAuthor, + ): BlogPostFrontMatterAuthor { + if (typeof authorInput === 'string') { + // We could allow users to provide an author's name here, but we only + // support keys, otherwise, a typo in a key would fall back to + // becoming a name and may end up unnoticed + return {key: authorInput}; + } + return authorInput; + } -function getFrontMatterAuthors(params: AuthorsParam): Author[] { - const {authorsMap} = params; - const frontMatterAuthors = normalizeFrontMatterAuthors( - params.frontMatter.authors, - ); + return Array.isArray(frontMatter.authors) + ? frontMatter.authors.map(normalizeAuthor) + : [normalizeAuthor(frontMatter.authors)]; + } function getAuthorsMapAuthor(key: string | undefined): Author | undefined { if (key) { @@ -175,36 +112,29 @@ ${Object.keys(authorsMap) } function toAuthor(frontMatterAuthor: BlogPostFrontMatterAuthor): Author { - return normalizeAuthor({ + const author = { // Author def from authorsMap can be locally overridden by front matter ...getAuthorsMapAuthor(frontMatterAuthor.key), ...frontMatterAuthor, - }); - } - - return frontMatterAuthors.map(toAuthor); -} + }; -function fixAuthorImageBaseURL( - authors: Author[], - {baseUrl}: {baseUrl: string}, -) { - return authors.map((author) => ({ - ...author, - imageURL: normalizeImageUrl({imageURL: author.imageURL, baseUrl}), - })); + return { + ...author, + key: author.key ?? null, + page: author.page ?? null, + imageURL: normalizeImageUrl({imageURL: author.imageURL, baseUrl}), + }; + } } export function getBlogPostAuthors(params: AuthorsParam): Author[] { const authorLegacy = getFrontMatterAuthorLegacy(params); const authors = getFrontMatterAuthors(params); - const updatedAuthors = fixAuthorImageBaseURL(authors, params); - if (authorLegacy) { // Technically, we could allow mixing legacy/authors front matter, but do we // really want to? - if (updatedAuthors.length > 0) { + if (authors.length > 0) { throw new Error( `To declare blog post authors, use the 'authors' front matter in priority. Don't mix 'authors' with other existing 'author_*' front matter. Choose one or the other, not both at the same time.`, @@ -213,5 +143,21 @@ Don't mix 'authors' with other existing 'author_*' front matter. Choose one or t return [authorLegacy]; } - return updatedAuthors; + return authors; +} + +/** + * Group blog posts by author key + * Blog posts with only inline authors are ignored + */ +export function groupBlogPostsByAuthorKey({ + blogPosts, + authorsMap, +}: { + blogPosts: BlogPost[]; + authorsMap: AuthorsMap | undefined; +}): Record { + return _.mapValues(authorsMap, (author, key) => + blogPosts.filter((p) => p.metadata.authors.some((a) => a.key === key)), + ); } diff --git a/packages/docusaurus-plugin-content-blog/src/authorsMap.ts b/packages/docusaurus-plugin-content-blog/src/authorsMap.ts new file mode 100644 index 000000000000..d1378aa3f26d --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/authorsMap.ts @@ -0,0 +1,171 @@ +/** + * 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 _ from 'lodash'; +import {readDataFile, normalizeUrl} from '@docusaurus/utils'; +import {Joi, URISchema} from '@docusaurus/utils-validation'; +import {AuthorSocialsSchema, normalizeSocials} from './authorsSocials'; +import type {BlogContentPaths} from './types'; +import type { + Author, + AuthorAttributes, + AuthorPage, + AuthorsMap, +} from '@docusaurus/plugin-content-blog'; + +type AuthorInput = AuthorAttributes & { + page?: boolean | AuthorPage; +}; + +export type AuthorsMapInput = {[authorKey: string]: AuthorInput}; + +const AuthorPageSchema = Joi.object({ + permalink: Joi.string().required(), +}); + +const AuthorsMapInputSchema = Joi.object() + .pattern( + Joi.string(), + Joi.object({ + name: Joi.string(), + url: URISchema, + imageURL: URISchema, + title: Joi.string(), + email: Joi.string(), + page: Joi.alternatives(Joi.bool(), AuthorPageSchema), + socials: AuthorSocialsSchema, + description: Joi.string(), + }) + .rename('image_url', 'imageURL') + .or('name', 'imageURL') + .unknown() + .required() + .messages({ + 'object.base': + '{#label} should be an author object containing properties like name, title, and imageURL.', + 'any.required': + '{#label} cannot be undefined. It should be an author object containing properties like name, title, and imageURL.', + }), + ) + .messages({ + 'object.base': + "The authors map file should contain an object where each entry contains an author key and the corresponding author's data.", + }); + +export function checkAuthorsMapPermalinkCollisions( + authorsMap: AuthorsMap | undefined, +): void { + if (!authorsMap) { + return; + } + + const permalinkCounts = _(authorsMap) + // Filter to keep only authors with a page + .pickBy((author) => !!author.page) + // Group authors by their permalink + .groupBy((author) => author.page?.permalink) + // Filter to keep only permalinks with more than one author + .pickBy((authors) => authors.length > 1) + // Transform the object into an array of [permalink, authors] pairs + .toPairs() + .value(); + + if (permalinkCounts.length > 0) { + const errorMessage = permalinkCounts + .map( + ([permalink, authors]) => + `Permalink: ${permalink}\nAuthors: ${authors + .map((author) => author.name || 'Unknown') + .join(', ')}`, + ) + .join('\n'); + + throw new Error( + `The following permalinks are duplicated:\n${errorMessage}`, + ); + } +} + +function normalizeAuthor({ + authorsBaseRoutePath, + authorKey, + author, +}: { + authorsBaseRoutePath: string; + authorKey: string; + author: AuthorInput; +}): Author & {key: string} { + function getAuthorPage(): AuthorPage | null { + if (!author.page) { + return null; + } + const slug = + author.page === true ? _.kebabCase(authorKey) : author.page.permalink; + return { + permalink: normalizeUrl([authorsBaseRoutePath, slug]), + }; + } + + return { + ...author, + key: authorKey, + page: getAuthorPage(), + socials: author.socials ? normalizeSocials(author.socials) : undefined, + }; +} + +function normalizeAuthorsMap({ + authorsBaseRoutePath, + authorsMapInput, +}: { + authorsBaseRoutePath: string; + authorsMapInput: AuthorsMapInput; +}): AuthorsMap { + return _.mapValues(authorsMapInput, (author, authorKey) => { + return normalizeAuthor({authorsBaseRoutePath, authorKey, author}); + }); +} + +export function validateAuthorsMapInput(content: unknown): AuthorsMapInput { + const {error, value} = AuthorsMapInputSchema.validate(content); + if (error) { + throw error; + } + return value; +} + +async function getAuthorsMapInput(params: { + authorsMapPath: string; + contentPaths: BlogContentPaths; +}): Promise { + const content = await readDataFile({ + filePath: params.authorsMapPath, + contentPaths: params.contentPaths, + }); + return content ? validateAuthorsMapInput(content) : undefined; +} + +export async function getAuthorsMap(params: { + authorsMapPath: string; + authorsBaseRoutePath: string; + contentPaths: BlogContentPaths; +}): Promise { + const authorsMapInput = await getAuthorsMapInput(params); + if (!authorsMapInput) { + return undefined; + } + const authorsMap = normalizeAuthorsMap({authorsMapInput, ...params}); + return authorsMap; +} + +export function validateAuthorsMap(content: unknown): AuthorsMapInput { + const {error, value} = AuthorsMapInputSchema.validate(content); + if (error) { + throw error; + } + return value; +} diff --git a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts index 68f429cc8c0c..d26d319c0f1b 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts @@ -29,11 +29,12 @@ import { } from '@docusaurus/utils'; import {getTagsFile} from '@docusaurus/utils-validation'; import {validateBlogPostFrontMatter} from './frontMatter'; -import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors'; +import {getBlogPostAuthors} from './authors'; import {reportAuthorsProblems} from './authorsProblems'; import type {TagsFile} from '@docusaurus/utils'; import type {LoadContext, ParseFrontMatter} from '@docusaurus/types'; import type { + AuthorsMap, PluginOptions, ReadingTimeFunction, BlogPost, @@ -64,7 +65,7 @@ export function paginateBlogPosts({ const totalCount = blogPosts.length; const postsPerPage = postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption; - const numberOfPages = Math.ceil(totalCount / postsPerPage); + const numberOfPages = Math.max(1, Math.ceil(totalCount / postsPerPage)); const pages: BlogPaginated[] = []; @@ -366,6 +367,7 @@ export async function generateBlogPosts( contentPaths: BlogContentPaths, context: LoadContext, options: PluginOptions, + authorsMap?: AuthorsMap, ): Promise { const {include, exclude} = options; @@ -378,11 +380,6 @@ export async function generateBlogPosts( ignore: exclude, }); - const authorsMap = await getAuthorsMap({ - contentPaths, - authorsMapPath: options.authorsMapPath, - }); - const tagsFile = await getTagsFile({contentPaths, tags: options.tags}); async function doProcessBlogSourceFile(blogSourceFile: string) { diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index 2792e9015e15..679924729e5f 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -34,6 +34,7 @@ import {translateContent, getTranslationFiles} from './translations'; import {createBlogFeedFiles, createFeedHtmlHeadTags} from './feed'; import {createAllRoutes} from './routes'; +import {checkAuthorsMapPermalinkCollisions, getAuthorsMap} from './authorsMap'; import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types'; import type {LoadContext, Plugin} from '@docusaurus/types'; import type { @@ -160,11 +161,30 @@ export default async function pluginContentBlog( blogTitle, blogSidebarTitle, pageBasePath, + authorsBasePath, + authorsMapPath, } = options; const baseBlogUrl = normalizeUrl([baseUrl, routeBasePath]); const blogTagsListPath = normalizeUrl([baseBlogUrl, tagsBasePath]); - let blogPosts = await generateBlogPosts(contentPaths, context, options); + + const authorsMap = await getAuthorsMap({ + contentPaths, + authorsMapPath, + authorsBaseRoutePath: normalizeUrl([ + baseUrl, + routeBasePath, + authorsBasePath, + ]), + }); + checkAuthorsMapPermalinkCollisions(authorsMap); + + let blogPosts = await generateBlogPosts( + contentPaths, + context, + options, + authorsMap, + ); blogPosts = await applyProcessBlogPosts({ blogPosts, processBlogPosts: options.processBlogPosts, @@ -178,6 +198,7 @@ export default async function pluginContentBlog( blogListPaginated: [], blogTags: {}, blogTagsListPath, + authorsMap, }; } @@ -226,6 +247,7 @@ export default async function pluginContentBlog( blogListPaginated, blogTags, blogTagsListPath, + authorsMap, }; }, diff --git a/packages/docusaurus-plugin-content-blog/src/options.ts b/packages/docusaurus-plugin-content-blog/src/options.ts index 68660ffa2b6f..20e0c3427948 100644 --- a/packages/docusaurus-plugin-content-blog/src/options.ts +++ b/packages/docusaurus-plugin-content-blog/src/options.ts @@ -34,6 +34,8 @@ export const DEFAULT_OPTIONS: PluginOptions = { showReadingTime: true, blogTagsPostsComponent: '@theme/BlogTagsPostsPage', blogTagsListComponent: '@theme/BlogTagsListPage', + blogAuthorsPostsComponent: '@theme/Blog/Pages/BlogAuthorsPostsPage', + blogAuthorsListComponent: '@theme/Blog/Pages/BlogAuthorsListPage', blogPostComponent: '@theme/BlogPostPage', blogListComponent: '@theme/BlogListPage', blogArchiveComponent: '@theme/BlogArchivePage', @@ -58,6 +60,7 @@ export const DEFAULT_OPTIONS: PluginOptions = { processBlogPosts: async () => undefined, onInlineTags: 'warn', tags: undefined, + authorsBasePath: 'authors', onInlineAuthors: 'warn', }; @@ -82,6 +85,12 @@ const PluginOptionSchema = Joi.object({ blogTagsPostsComponent: Joi.string().default( DEFAULT_OPTIONS.blogTagsPostsComponent, ), + blogAuthorsPostsComponent: Joi.string().default( + DEFAULT_OPTIONS.blogAuthorsPostsComponent, + ), + blogAuthorsListComponent: Joi.string().default( + DEFAULT_OPTIONS.blogAuthorsListComponent, + ), blogArchiveComponent: Joi.string().default( DEFAULT_OPTIONS.blogArchiveComponent, ), @@ -157,6 +166,9 @@ const PluginOptionSchema = Joi.object({ .disallow('') .allow(null, false) .default(() => DEFAULT_OPTIONS.tags), + authorsBasePath: Joi.string() + .default(DEFAULT_OPTIONS.authorsBasePath) + .disallow(''), onInlineAuthors: Joi.string() .equal('ignore', 'log', 'warn', 'throw') .default(DEFAULT_OPTIONS.onInlineAuthors), 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 589a5e93b169..e0e5617ce792 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 @@ -22,13 +22,7 @@ declare module '@docusaurus/plugin-content-blog' { export type Assets = { /** - * If `metadata.yarn workspace website typecheck -4 -yarn workspace v1.22.19yarn workspace website typecheck -4 -yarn workspace v1.22.19yarn workspace website typecheck -4 -yarn workspace v1.22.19image` is a collocated image path, this entry will be the + * If `metadata.image` is a collocated image path, this entry will be the * bundler-generated image path. Otherwise, it's empty, and the image URL * should be accessed through `frontMatter.image`. */ @@ -66,9 +60,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the [customAuthorSocialPlatform: string]: string; }; - export type Author = { - key?: string; // TODO temporary, need refactor - + export type AuthorAttributes = { /** * If `name` doesn't exist, an `imageURL` is expected. */ @@ -98,11 +90,45 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the */ socials?: AuthorSocials; /** - * Unknown keys are allowed, so that we can pass custom fields to authors, + * Description of the author. + */ + description?: string; + /** + * Unknown keys are allowed, so that we can pass custom fields to authors. */ [customAuthorAttribute: string]: unknown; }; + /** + * Metadata of the author's page, if it exists. + */ + export type AuthorPage = {permalink: string}; + + /** + * Normalized author metadata. + */ + export type Author = AuthorAttributes & { + /** + * Author key, if the author was loaded from the authors map. + * `null` means the author was declared inline. + */ + key: string | null; + /** + * Metadata of the author's page. + * `null` means the author doesn't have a dedicated author page. + */ + page: AuthorPage | null; + }; + + /** Authors coming from the AuthorsMap always have a key */ + export type AuthorWithKey = Author & {key: string}; + + /** What the authors list page should know about each author. */ + export type AuthorItemProp = AuthorWithKey & { + /** Number of blog posts with this author. */ + count: number; + }; + /** * Everything is partial/unnormalized, because front matter is always * preserved as-is. Default values will be applied when generating metadata @@ -194,7 +220,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the last_update?: FrontMatterLastUpdate; }; - export type BlogPostFrontMatterAuthor = Author & { + export type BlogPostFrontMatterAuthor = AuthorAttributes & { /** * Will be normalized into the `imageURL` prop. */ @@ -427,6 +453,10 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the blogTagsListComponent: string; /** Root component of the "posts containing tag" page. */ blogTagsPostsComponent: string; + /** Root component of the authors list page. */ + blogAuthorsListComponent: string; + /** Root component of the "posts containing author" page. */ + blogAuthorsPostsComponent: string; /** Root component of the blog archive page. */ blogArchiveComponent: string; /** Blog page title for better SEO. */ @@ -471,6 +501,8 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the * (filter, modify, delete, etc...). */ processBlogPosts: ProcessBlogPostsFn; + /* Base path for the authors page */ + authorsBasePath: string; /** The behavior of Docusaurus when it finds inline authors. */ onInlineAuthors: 'ignore' | 'log' | 'warn' | 'throw'; }; @@ -508,17 +540,22 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the items: BlogSidebarItem[]; }; + export type AuthorsMap = {[authorKey: string]: AuthorWithKey}; + export type BlogContent = { blogSidebarTitle: string; blogPosts: BlogPost[]; blogListPaginated: BlogPaginated[]; blogTags: BlogTags; blogTagsListPath: string; + authorsMap?: AuthorsMap; }; export type BlogMetadata = { /** the path to the base of the blog */ blogBasePath: string; + /** the path to the authors list page */ + authorsListPath: string; /** title of the overall blog */ blogTitle: string; }; @@ -679,6 +716,47 @@ declare module '@theme/BlogTagsListPage' { export default function BlogTagsListPage(props: Props): JSX.Element; } +declare module '@theme/Blog/Pages/BlogAuthorsListPage' { + import type { + AuthorItemProp, + BlogSidebar, + } from '@docusaurus/plugin-content-blog'; + + export interface Props { + /** Blog sidebar. */ + readonly sidebar: BlogSidebar; + /** All authors declared in this blog. */ + readonly authors: AuthorItemProp[]; + } + + export default function BlogAuthorsListPage(props: Props): JSX.Element; +} + +declare module '@theme/Blog/Pages/BlogAuthorsPostsPage' { + import type {Content} from '@theme/BlogPostPage'; + import type { + AuthorItemProp, + BlogSidebar, + BlogPaginatedMetadata, + } from '@docusaurus/plugin-content-blog'; + + export interface Props { + /** Blog sidebar. */ + readonly sidebar: BlogSidebar; + /** Metadata of this author. */ + readonly author: AuthorItemProp; + /** Looks exactly the same as the posts list page */ + readonly listMetadata: BlogPaginatedMetadata; + /** + * Array of blog posts included on this page. Every post's metadata is also + * available. + */ + readonly items: readonly {readonly content: Content}[]; + } + + export default function BlogAuthorsPostsPage(props: Props): JSX.Element; +} + declare module '@theme/BlogTagsPostsPage' { import type {Content} from '@theme/BlogPostPage'; import type { diff --git a/packages/docusaurus-plugin-content-blog/src/props.ts b/packages/docusaurus-plugin-content-blog/src/props.ts index b4d4ddf78c03..df7afbb42563 100644 --- a/packages/docusaurus-plugin-content-blog/src/props.ts +++ b/packages/docusaurus-plugin-content-blog/src/props.ts @@ -6,6 +6,8 @@ */ import type {TagsListItem, TagModule} from '@docusaurus/utils'; import type { + AuthorItemProp, + AuthorWithKey, BlogPost, BlogSidebar, BlogTag, @@ -40,6 +42,19 @@ export function toTagProp({ }; } +export function toAuthorItemProp({ + author, + count, +}: { + author: AuthorWithKey; + count: number; +}): AuthorItemProp { + return { + ...author, + count, + }; +} + export function toBlogSidebarProp({ blogSidebarTitle, blogPosts, diff --git a/packages/docusaurus-plugin-content-blog/src/routes.ts b/packages/docusaurus-plugin-content-blog/src/routes.ts index fef334b5cc14..ced92dc0fda9 100644 --- a/packages/docusaurus-plugin-content-blog/src/routes.ts +++ b/packages/docusaurus-plugin-content-blog/src/routes.ts @@ -11,9 +11,15 @@ import { docuHash, aliasedSitePathToRelativePath, } from '@docusaurus/utils'; -import {shouldBeListed} from './blogUtils'; +import {paginateBlogPosts, shouldBeListed} from './blogUtils'; -import {toBlogSidebarProp, toTagProp, toTagsProp} from './props'; +import { + toAuthorItemProp, + toBlogSidebarProp, + toTagProp, + toTagsProp, +} from './props'; +import {groupBlogPostsByAuthorKey} from './authors'; import type { PluginContentLoadedActions, RouteConfig, @@ -26,6 +32,7 @@ import type { BlogContent, PluginOptions, BlogPost, + AuthorWithKey, } from '@docusaurus/plugin-content-blog'; type CreateAllRoutesParam = { @@ -54,11 +61,16 @@ export async function buildAllRoutes({ blogListComponent, blogPostComponent, blogTagsListComponent, + blogAuthorsListComponent, + blogAuthorsPostsComponent, blogTagsPostsComponent, blogArchiveComponent, routeBasePath, archiveBasePath, blogTitle, + authorsBasePath, + postsPerPage, + blogDescription, } = options; const pluginId = options.id!; const {createData} = actions; @@ -68,8 +80,15 @@ export async function buildAllRoutes({ blogListPaginated, blogTags, blogTagsListPath, + authorsMap, } = content; + const authorsListPath = normalizeUrl([ + baseUrl, + routeBasePath, + authorsBasePath, + ]); + const listedBlogPosts = blogPosts.filter(shouldBeListed); const blogPostsById = _.keyBy(blogPosts, (post) => post.id); @@ -102,6 +121,7 @@ export async function buildAllRoutes({ const blogMetadata: BlogMetadata = { blogBasePath: normalizeUrl([baseUrl, routeBasePath]), blogTitle, + authorsListPath, }; const modulePath = await createData( `blogMetadata-${pluginId}.json`, @@ -249,10 +269,85 @@ export async function buildAllRoutes({ return [tagsListRoute, ...tagsPaginatedRoutes]; } + function createAuthorsRoutes(): RouteConfig[] { + if (authorsMap === undefined || Object.keys(authorsMap).length === 0) { + return []; + } + + const blogPostsByAuthorKey = groupBlogPostsByAuthorKey({ + authorsMap, + blogPosts, + }); + const authors = Object.values(authorsMap); + + return [ + createAuthorListRoute(), + ...authors.flatMap(createAuthorPaginatedRoute), + ]; + + function createAuthorListRoute(): RouteConfig { + return { + path: authorsListPath, + component: blogAuthorsListComponent, + exact: true, + modules: { + sidebar: sidebarModulePath, + }, + props: { + authors: authors.map((author) => + toAuthorItemProp({ + author, + count: blogPostsByAuthorKey[author.key]?.length ?? 0, + }), + ), + }, + context: { + blogMetadata: blogMetadataModulePath, + }, + }; + } + + function createAuthorPaginatedRoute(author: AuthorWithKey): RouteConfig[] { + const authorBlogPosts = blogPostsByAuthorKey[author.key] ?? []; + if (!author.page) { + return []; + } + + const pages = paginateBlogPosts({ + blogPosts: authorBlogPosts, + basePageUrl: author.page.permalink, + blogDescription, + blogTitle, + pageBasePath: authorsBasePath, + postsPerPageOption: postsPerPage, + }); + + return pages.map(({metadata, items}) => { + return { + path: metadata.permalink, + component: blogAuthorsPostsComponent, + exact: true, + modules: { + items: blogPostItemsModule(items), + sidebar: sidebarModulePath, + }, + props: { + author: toAuthorItemProp({author, count: authorBlogPosts.length}), + listMetadata: metadata, + }, + context: { + blogMetadata: blogMetadataModulePath, + }, + }; + }); + } + } + return [ ...createBlogPostRoutes(), ...createBlogPostsPaginatedRoutes(), ...createTagsRoutes(), ...createArchiveRoute(), + ...createAuthorsRoutes(), ]; } diff --git a/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts b/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts index 1b0a871197e2..60363ec4849e 100644 --- a/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts +++ b/packages/docusaurus-theme-classic/src/getSwizzleConfig.ts @@ -127,6 +127,27 @@ export default function getSwizzleConfig(): SwizzleConfig { description: 'The object mapping admonition type to a React component.\nUse it to add custom admonition type components, or replace existing ones.\nCan be ejected or wrapped (only manually, see our documentation).', }, + Blog: { + actions: { + // Forbidden because it's a parent folder, makes the CLI crash atm + eject: 'forbidden', + wrap: 'forbidden', + }, + }, + 'Blog/Components': { + actions: { + // Forbidden because it's a parent folder, makes the CLI crash atm + eject: 'forbidden', + wrap: 'forbidden', + }, + }, + 'Blog/Pages': { + actions: { + // Forbidden because it's a parent folder, makes the CLI crash atm + eject: 'forbidden', + wrap: 'forbidden', + }, + }, CodeBlock: { actions: { eject: 'safe', diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 142a229862f9..c35e7615af26 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -185,6 +185,30 @@ declare module '@theme/BackToTopButton' { export default function BackToTopButton(): JSX.Element; } +declare module '@theme/Blog/Components/Author' { + import type {Author} from '@docusaurus/plugin-content-blog'; + + export interface Props { + readonly as?: 'h1' | 'h2'; + readonly author: Author; + readonly className?: string; + readonly count?: number; + } + + export default function BlogAuthor(props: Props): JSX.Element; +} + +declare module '@theme/Blog/Components/Author/Socials' { + import type {Author} from '@docusaurus/plugin-content-blog'; + + export interface Props { + readonly author: Author; + readonly className?: string; + } + + export default function BlogAuthorSocials(props: Props): JSX.Element; +} + declare module '@theme/BlogListPaginator' { import type {BlogPaginatedMetadata} from '@docusaurus/plugin-content-blog'; @@ -291,31 +315,6 @@ declare module '@theme/BlogPostItem/Header/Info' { export default function BlogPostItemHeaderInfo(): JSX.Element; } -declare module '@theme/BlogPostItem/Header/Author' { - import type {Author} from '@docusaurus/plugin-content-blog'; - - export interface Props { - readonly author: Author; - readonly singleAuthor: boolean; - readonly className?: string; - } - - export default function BlogPostItemHeaderAuthor(props: Props): JSX.Element; -} - -declare module '@theme/BlogPostItem/Header/Author/Socials' { - import type {Author} from '@docusaurus/plugin-content-blog'; - - export interface Props { - readonly author: Author; - readonly className?: string; - } - - export default function BlogPostItemHeaderAuthorSocials( - props: Props, - ): JSX.Element; -} - declare module '@theme/BlogPostItem/Header/Authors' { export interface Props { readonly className?: string; diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/Socials/index.tsx b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/Socials/index.tsx similarity index 86% rename from packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/Socials/index.tsx rename to packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/Socials/index.tsx index 55866b14f2c2..59bdca629b16 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/Socials/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/Socials/index.tsx @@ -9,7 +9,7 @@ import type {ComponentType} from 'react'; import React from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; -import type {Props} from '@theme/BlogPostItem/Header/Author/Socials'; +import type {Props} from '@theme/Blog/Components/Author/Socials'; import Twitter from '@theme/Icon/Socials/Twitter'; import GitHub from '@theme/Icon/Socials/GitHub'; @@ -50,10 +50,15 @@ function SocialLink({platform, link}: {platform: string; link: string}) { ); } -export default function AuthorSocials({author}: {author: Props['author']}) { +export default function BlogAuthorSocials({ + author, +}: { + author: Props['author']; +}): JSX.Element { + const entries = Object.entries(author.socials ?? {}); return (
- {Object.entries(author.socials ?? {}).map(([platform, linkUrl]) => { + {entries.map(([platform, linkUrl]) => { return ; })}
diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/Socials/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/Socials/styles.module.css similarity index 75% rename from packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/Socials/styles.module.css rename to packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/Socials/styles.module.css index 1fca8b7e385e..7c1ffc07365a 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/Socials/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/Socials/styles.module.css @@ -10,7 +10,12 @@ } .authorSocials { - margin-top: 0.2rem; + /* + This ensures that container takes height even if there's no social link + This keeps author names aligned even if only some have socials + */ + height: var(--docusaurus-blog-social-icon-size); + display: flex; flex-wrap: wrap; align-items: center; @@ -25,7 +30,7 @@ height: var(--docusaurus-blog-social-icon-size); width: var(--docusaurus-blog-social-icon-size); line-height: 0; - margin-right: 0.3rem; + margin-right: 0.4rem; } .authorSocialIcon { diff --git a/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/index.tsx b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/index.tsx new file mode 100644 index 000000000000..5861b3da8091 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/index.tsx @@ -0,0 +1,99 @@ +/** + * 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 from 'react'; +import clsx from 'clsx'; +import Link, {type Props as LinkProps} from '@docusaurus/Link'; +import AuthorSocials from '@theme/Blog/Components/Author/Socials'; +import type {Props} from '@theme/Blog/Components/Author'; +import Heading from '@theme/Heading'; +import styles from './styles.module.css'; + +function MaybeLink(props: LinkProps): JSX.Element { + if (props.href) { + return ; + } + return <>{props.children}; +} + +function AuthorTitle({title}: {title: string}) { + return ( + + {title} + + ); +} + +function AuthorName({name, as}: {name: string; as: Props['as']}) { + if (!as) { + return {name}; + } else { + return ( + + {name} + + ); + } +} + +function AuthorBlogPostCount({count}: {count: number}) { + return {count}; +} + +// Note: in the future we might want to have multiple "BlogAuthor" components +// Creating different display modes with the "as" prop may not be the best idea +// Explainer: https://kyleshevlin.com/prefer-multiple-compositions/ +// For now, we almost use the same design for all cases, so it's good enough +export default function BlogAuthor({ + as, + author, + className, + count, +}: Props): JSX.Element { + const {name, title, url, imageURL, email, page} = author; + const link = + page?.permalink || url || (email && `mailto:${email}`) || undefined; + + return ( +
+ {imageURL && ( + + {name} + + )} + + {(name || title) && ( +
+
+ {name && ( + + + + )} + {count && } +
+ {!!title && } + + {/* + We always render AuthorSocials even if there's none + This keeps other things aligned with flexbox layout + */} + +
+ )} +
+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/styles.module.css new file mode 100644 index 000000000000..43feb0f2883e --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Components/Author/styles.module.css @@ -0,0 +1,74 @@ +/** + * 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. + */ + +.authorImage { + --ifm-avatar-photo-size: 3.6rem; +} + +.author-as-h1 .authorImage { + --ifm-avatar-photo-size: 7rem; +} + +.author-as-h2 .authorImage { + --ifm-avatar-photo-size: 5.4rem; +} + +.authorDetails { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-around; +} + +.authorName { + font-size: 1.1rem; + line-height: 1.1rem; + display: flex; + flex-direction: row; +} + +.author-as-h1 .authorName { + font-size: 2.4rem; + line-height: 2.4rem; + display: inline; +} + +.author-as-h2 .authorName { + font-size: 1.4rem; + line-height: 1.4rem; + display: inline; +} + +.authorTitle { + font-size: 0.8rem; + line-height: 0.8rem; + display: -webkit-box; + overflow: hidden; + line-clamp: 1; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +.author-as-h1 .authorTitle { + font-size: 1.2rem; + line-height: 1.2rem; +} + +.author-as-h2 .authorTitle { + font-size: 1rem; + line-height: 1rem; +} + +.authorBlogPostCount { + background: var(--ifm-color-secondary); + color: var(--ifm-color-black); + font-size: 0.8rem; + line-height: 1.2; + border-radius: var(--ifm-global-radius); + padding: 0.1rem 0.4rem; + margin-left: 0.3rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/index.tsx new file mode 100644 index 000000000000..71d60a0402a1 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/index.tsx @@ -0,0 +1,62 @@ +/** + * 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} from 'react'; +import clsx from 'clsx'; +import { + PageMetadata, + HtmlClassNameProvider, + ThemeClassNames, +} from '@docusaurus/theme-common'; +import {translateBlogAuthorsListPageTitle} from '@docusaurus/theme-common/internal'; +import BlogLayout from '@theme/BlogLayout'; +import type {Props} from '@theme/Blog/Pages/BlogAuthorsListPage'; +import SearchMetadata from '@theme/SearchMetadata'; +import Heading from '@theme/Heading'; +import Author from '@theme/Blog/Components/Author'; +import type {AuthorItemProp} from '@docusaurus/plugin-content-blog'; +import styles from './styles.module.css'; + +function AuthorListItem({author}: {author: AuthorItemProp}) { + return ( +
  • + +
  • + ); +} + +function AuthorsList({authors}: {authors: Props['authors']}) { + return ( +
    +
      + {authors.map((author) => ( + + ))} +
    +
    + ); +} + +export default function BlogAuthorsListPage({ + authors, + sidebar, +}: Props): ReactNode { + const title: string = translateBlogAuthorsListPageTitle(); + return ( + + + + + {title} + + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/styles.module.css new file mode 100644 index 000000000000..5c0d7455ceb9 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsListPage/styles.module.css @@ -0,0 +1,11 @@ +/** + * 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. + */ + +.authorListItem { + list-style-type: none; + margin-bottom: 2rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsPostsPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsPostsPage/index.tsx new file mode 100644 index 000000000000..1d49c35b04a5 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Blog/Pages/BlogAuthorsPostsPage/index.tsx @@ -0,0 +1,73 @@ +/** + * 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 from 'react'; +import clsx from 'clsx'; +import { + PageMetadata, + HtmlClassNameProvider, + ThemeClassNames, +} from '@docusaurus/theme-common'; +import { + useBlogAuthorPageTitle, + BlogAuthorsListViewAllLabel, +} from '@docusaurus/theme-common/internal'; +import Link from '@docusaurus/Link'; +import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client'; +import BlogLayout from '@theme/BlogLayout'; +import BlogListPaginator from '@theme/BlogListPaginator'; +import SearchMetadata from '@theme/SearchMetadata'; +import type {Props} from '@theme/Blog/Pages/BlogAuthorsPostsPage'; +import BlogPostItems from '@theme/BlogPostItems'; +import Author from '@theme/Blog/Components/Author'; + +function Metadata({author}: Props): JSX.Element { + const title = useBlogAuthorPageTitle(author); + return ( + <> + + + + ); +} + +function ViewAllAuthorsLink() { + const {authorsListPath} = useBlogMetadata(); + return ( + + + + ); +} + +function Content({author, items, sidebar, listMetadata}: Props): JSX.Element { + return ( + +
    + + {author.description &&

    {author.description}

    } + +
    +
    + + +
    + ); +} + +export default function BlogAuthorsPostsPage(props: Props): JSX.Element { + return ( + + + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/index.tsx deleted file mode 100644 index 0e81c7a42675..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/index.tsx +++ /dev/null @@ -1,62 +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 React from 'react'; -import clsx from 'clsx'; -import Link, {type Props as LinkProps} from '@docusaurus/Link'; -import AuthorSocials from '@theme/BlogPostItem/Header/Author/Socials'; - -import type {Props} from '@theme/BlogPostItem/Header/Author'; -import styles from './styles.module.css'; - -function MaybeLink(props: LinkProps): JSX.Element { - if (props.href) { - return ; - } - return <>{props.children}; -} - -function AuthorTitle({title}: {title: string}) { - return ( - - {title} - - ); -} - -export default function BlogPostItemHeaderAuthor({ - // singleAuthor, // may be useful in the future, or for swizzle users - author, - className, -}: Props): JSX.Element { - const {name, title, url, socials, imageURL, email} = author; - const link = url || (email && `mailto:${email}`) || undefined; - - const hasSocials = socials && Object.keys(socials).length > 0; - - return ( -
    - {imageURL && ( - - {name} - - )} - - {(name || title) && ( -
    -
    - - {name} - -
    - {!!title && } - {hasSocials && } -
    - )} -
    - ); -} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/styles.module.css b/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/styles.module.css deleted file mode 100644 index 21ea5d40dc89..000000000000 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Author/styles.module.css +++ /dev/null @@ -1,21 +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. - */ - -.authorName { - font-size: 1.1rem; -} - -.authorTitle { - margin-top: 0.06rem; - font-size: 0.8rem; - line-height: 0.8rem; - display: -webkit-box; - overflow: hidden; - line-clamp: 1; - -webkit-line-clamp: 1; - -webkit-box-orient: vertical; -} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Authors/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Authors/index.tsx index 7ee6a472b22b..fc7b313dd278 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Authors/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostItem/Header/Authors/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import clsx from 'clsx'; import {useBlogPost} from '@docusaurus/plugin-content-blog/client'; -import BlogPostItemHeaderAuthor from '@theme/BlogPostItem/Header/Author'; +import BlogAuthor from '@theme/Blog/Components/Author'; import type {Props} from '@theme/BlogPostItem/Header/Authors'; import styles from './styles.module.css'; @@ -40,8 +40,7 @@ export default function BlogPostItemHeaderAuthors({ imageOnly ? styles.imageOnlyAuthorCol : styles.authorCol, )} key={idx}> - - selectMessage( - count, - translate( - { - id: 'theme.blog.post.plurals', - description: - 'Pluralized label for "{count} posts". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', - message: 'One post|{count} posts', - }, - {count}, - ), - ); -} - -function useBlogTagsPostsPageTitle(tag: Props['tag']): string { - const blogPostsPlural = useBlogPostsPlural(); - return translate( - { - id: 'theme.blog.tagTitle', - description: 'The title of the page for a blog tag', - message: '{nPosts} tagged with "{tagName}"', - }, - {nPosts: blogPostsPlural(tag.count), tagName: tag.label}, - ); -} - function BlogTagsPostsPageMetadata({tag}: Props): JSX.Element { const title = useBlogTagsPostsPageTitle(tag); return ( diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index a31b19269f34..8c3f5d6b7c72 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -90,3 +90,10 @@ export {useLockBodyScroll} from './hooks/useLockBodyScroll'; export {useCodeWordWrap} from './hooks/useCodeWordWrap'; export {getPrismCssVariables} from './utils/codeBlockUtils'; export {useBackToTopButton} from './hooks/useBackToTopButton'; + +export { + useBlogTagsPostsPageTitle, + useBlogAuthorPageTitle, + translateBlogAuthorsListPageTitle, + BlogAuthorsListViewAllLabel, +} from './translations/blogTranslations'; diff --git a/packages/docusaurus-theme-common/src/translations/blogTranslations.tsx b/packages/docusaurus-theme-common/src/translations/blogTranslations.tsx new file mode 100644 index 000000000000..0390ee063a10 --- /dev/null +++ b/packages/docusaurus-theme-common/src/translations/blogTranslations.tsx @@ -0,0 +1,79 @@ +/** + * 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} from 'react'; +import Translate, {translate} from '@docusaurus/Translate'; +import {usePluralForm} from '../utils/usePluralForm'; + +// Only used locally +function useBlogPostsPlural(): (count: number) => string { + const {selectMessage} = usePluralForm(); + return (count: number) => + selectMessage( + count, + translate( + { + id: 'theme.blog.post.plurals', + description: + 'Pluralized label for "{count} posts". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', + message: 'One post|{count} posts', + }, + {count}, + ), + ); +} + +export function useBlogTagsPostsPageTitle(tag: { + label: string; + count: number; +}): string { + const blogPostsPlural = useBlogPostsPlural(); + return translate( + { + id: 'theme.blog.tagTitle', + description: 'The title of the page for a blog tag', + message: '{nPosts} tagged with "{tagName}"', + }, + {nPosts: blogPostsPlural(tag.count), tagName: tag.label}, + ); +} + +export function useBlogAuthorPageTitle(author: { + key: string; + name?: string; + count: number; +}): string { + const blogPostsPlural = useBlogPostsPlural(); + return translate( + { + id: 'theme.blog.author.pageTitle', + description: 'The title of the page for a blog author', + message: '{authorName} - {nPosts}', + }, + { + nPosts: blogPostsPlural(author.count), + authorName: author.name || author.key, + }, + ); +} + +export const translateBlogAuthorsListPageTitle = (): string => + translate({ + id: 'theme.blog.authorsList.pageTitle', + message: 'Authors', + description: 'The title of the authors page', + }); + +export function BlogAuthorsListViewAllLabel(): ReactNode { + return ( + + View All Authors + + ); +} diff --git a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts index 1cdcda3ce50a..413a2492fa4a 100644 --- a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts +++ b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts @@ -18,6 +18,8 @@ export const ThemeClassNames = { blogPostPage: 'blog-post-page', blogTagsListPage: 'blog-tags-list-page', blogTagPostListPage: 'blog-tags-post-list-page', + blogAuthorsListPage: 'blog-authors-list-page', + blogAuthorsPostsPage: 'blog-authors-posts-page', docsDocPage: 'docs-doc-page', docsTagsListPage: 'docs-tags-list-page', diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/tagUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/tagsUtils.test.ts similarity index 78% rename from packages/docusaurus-theme-common/src/utils/__tests__/tagUtils.test.ts rename to packages/docusaurus-theme-common/src/utils/__tests__/tagsUtils.test.ts index 4e1f614d7702..8e5fac8cef16 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/tagUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/tagsUtils.test.ts @@ -7,42 +7,47 @@ import _ from 'lodash'; import {listTagsByLetters} from '../tagsUtils'; +import type {TagsListItem} from '@docusaurus/utils'; describe('listTagsByLetters', () => { - type Param = Parameters[0]; - type Tag = Param[number]; type Result = ReturnType; it('creates letters list', () => { - const tag1: Tag = { + const tag1: TagsListItem = { label: 'tag1', permalink: '/tag1', count: 1, + description: '', }; - const tag2: Tag = { + const tag2: TagsListItem = { label: 'Tag2', permalink: '/tag2', count: 11, + description: '', }; - const tagZxy: Tag = { + const tagZxy: TagsListItem = { label: 'zxy', permalink: '/zxy', count: 987, + description: '', }; - const tagAbc: Tag = { + const tagAbc: TagsListItem = { label: 'Abc', permalink: '/abc', count: 123, + description: '', }; - const tagDef: Tag = { + const tagDef: TagsListItem = { label: 'def', permalink: '/def', count: 1, + description: '', }; - const tagAaa: Tag = { + const tagAaa: TagsListItem = { label: 'aaa', permalink: '/aaa', count: 10, + description: '', }; const expectedResult: Result = [ diff --git a/packages/docusaurus-theme-translations/locales/base/theme-common.json b/packages/docusaurus-theme-translations/locales/base/theme-common.json index fcaeb33e9a98..3cc0b8b8b369 100644 --- a/packages/docusaurus-theme-translations/locales/base/theme-common.json +++ b/packages/docusaurus-theme-translations/locales/base/theme-common.json @@ -41,6 +41,9 @@ "theme.admonition.tip___DESCRIPTION": "The default label used for the Tip admonition (:::tip)", "theme.admonition.warning": "warning", "theme.admonition.warning___DESCRIPTION": "The default label used for the Warning admonition (:::warning)", + "theme.blog.authorsList.pageTitle": "Authors", + "theme.blog.authorsList.viewAll": "View All Authors", + "theme.blog.author.pageTitle": "{authorName} - {nPosts}", "theme.blog.archive.description": "Archive", "theme.blog.archive.description___DESCRIPTION": "The page & hero description of the blog archive page", "theme.blog.archive.title": "Archive", diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.json b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.json deleted file mode 100644 index 3c27e19a80d4..000000000000 --- a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.json +++ /dev/null @@ -1 +0,0 @@ -{"a": 2} diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.yml b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.yml deleted file mode 100644 index 9dfc208dffa8..000000000000 --- a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/bad.yml +++ /dev/null @@ -1 +0,0 @@ -a: 2 diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.json b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.json deleted file mode 100644 index cb5b2f69babc..000000000000 --- a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.json +++ /dev/null @@ -1 +0,0 @@ -{"a": 1} diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.yml b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.yml deleted file mode 100644 index a8926a52d8dc..000000000000 --- a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/actualData/valid.yml +++ /dev/null @@ -1 +0,0 @@ -a: 1 diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.json b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.json new file mode 100644 index 000000000000..f06ab684a504 --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.json @@ -0,0 +1 @@ +{"content": "json"} diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.yml b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.yml new file mode 100644 index 000000000000..59b8b3c49617 --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.yml @@ -0,0 +1 @@ +content: original yaml diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml new file mode 100644 index 000000000000..54bd7745b096 --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml @@ -0,0 +1 @@ +}{{{{12434665¨£%£%%£%£}}}} diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/localized/dataFile.yml b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/localized/dataFile.yml new file mode 100644 index 000000000000..bf980e035eba --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/localized/dataFile.yml @@ -0,0 +1 @@ +content: localized yaml diff --git a/packages/docusaurus-utils/src/__tests__/dataFileUtils.test.ts b/packages/docusaurus-utils/src/__tests__/dataFileUtils.test.ts index 4d809a45c791..732442f05015 100644 --- a/packages/docusaurus-utils/src/__tests__/dataFileUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/dataFileUtils.test.ts @@ -10,7 +10,7 @@ import { findFolderContainingFile, getFolderContainingFile, getDataFilePath, - getDataFileData, + readDataFile, } from '../dataFileUtils'; describe('getDataFilePath', () => { @@ -125,46 +125,40 @@ describe('getDataFilePath', () => { }); describe('getDataFileData', () => { - const fixturesDir = path.join(__dirname, '__fixtures__/dataFiles/actualData'); - function readDataFile(filePath: string) { - return getDataFileData( - { - filePath, - contentPaths: {contentPath: fixturesDir, contentPathLocalized: ''}, - fileType: 'test', - }, - (content) => { - // @ts-expect-error: good enough - if (content.a !== 1) { - throw new Error('Nope'); - } - return content; - }, + function testFile(filePath: string) { + const contentPath = path.join( + __dirname, + '__fixtures__/dataFiles/dataFiles', ); + const contentPathLocalized = path.join(contentPath, 'localized'); + return readDataFile({ + filePath, + contentPaths: {contentPath, contentPathLocalized}, + }); } it('returns undefined for nonexistent file', async () => { - await expect(readDataFile('nonexistent.yml')).resolves.toBeUndefined(); - }); - - it('read valid yml author file', async () => { - await expect(readDataFile('valid.yml')).resolves.toEqual({a: 1}); + await expect(testFile('nonexistent.yml')).resolves.toBeUndefined(); }); it('read valid json author file', async () => { - await expect(readDataFile('valid.json')).resolves.toEqual({a: 1}); + await expect(testFile('dataFile.json')).resolves.toEqual({ + content: 'json', + }); }); - it('fail to read invalid yml', async () => { - await expect( - readDataFile('bad.yml'), - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Nope"`); + it('read valid yml author file using localized source in priority', async () => { + await expect(testFile('dataFile.yml')).resolves.toEqual({ + content: 'localized yaml', + }); }); - it('fail to read invalid json', async () => { + it('throw for invalid file', async () => { await expect( - readDataFile('bad.json'), - ).rejects.toThrowErrorMatchingInlineSnapshot(`"Nope"`); + testFile('invalid.yml'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The file at "packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml" looks invalid (not Yaml nor JSON)."`, + ); }); }); diff --git a/packages/docusaurus-utils/src/dataFileUtils.ts b/packages/docusaurus-utils/src/dataFileUtils.ts index ba671d6e8875..decd57da940b 100644 --- a/packages/docusaurus-utils/src/dataFileUtils.ts +++ b/packages/docusaurus-utils/src/dataFileUtils.ts @@ -43,31 +43,28 @@ export async function getDataFilePath({ } /** - * Looks up for a data file in the content paths, returns the object validated - * and normalized according to the `validate` callback. + * Looks up for a data file in the content paths + * Favors the localized content path over the base content path + * Currently supports Yaml and JSON data files + * It is the caller responsibility to validate and normalize the resulting data * * @returns `undefined` when file not found - * @throws Throws when validation fails, displaying a helpful context message. + * @throws Throws when data file can't be parsed */ -export async function getDataFileData( - params: DataFileParams & { - /** Used for the "The X file looks invalid" message. */ - fileType: string; - }, - validate: (content: unknown) => T, -): Promise { +export async function readDataFile(params: DataFileParams): Promise { const filePath = await getDataFilePath(params); if (!filePath) { return undefined; } try { const contentString = await fs.readFile(filePath, {encoding: 'utf8'}); - const unsafeContent = Yaml.load(contentString); - // TODO we shouldn't validate here: it makes validation harder to test - return validate(unsafeContent); + return Yaml.load(contentString); } catch (err) { - logger.error`The ${params.fileType} file at path=${filePath} looks invalid.`; - throw err; + const msg = logger.interpolate`The file at path=${path.relative( + process.cwd(), + filePath, + )} looks invalid (not Yaml nor JSON).`; + throw new Error(msg, {cause: err as Error}); } } diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 7201f8eec1a8..d9a24a408655 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -109,7 +109,7 @@ export {escapeShellArg} from './shellUtils'; export {loadFreshModule} from './moduleUtils'; export { getDataFilePath, - getDataFileData, + readDataFile, getContentPathList, findFolderContainingFile, getFolderContainingFile, diff --git a/packages/docusaurus-utils/src/tags.ts b/packages/docusaurus-utils/src/tags.ts index fd06bf2fea25..cef2ea7dd6f6 100644 --- a/packages/docusaurus-utils/src/tags.ts +++ b/packages/docusaurus-utils/src/tags.ts @@ -45,6 +45,7 @@ export type TagsListItem = Tag & { /** What the tag's own page should know about the tag. */ export type TagModule = TagsListItem & { /** The tags list page's permalink. */ + // TODO move this global value to a shared docs/blog bundle allTagsPath: string; /** Is this tag unlisted? (when it only contains unlisted items) */ unlisted: boolean; diff --git a/project-words.txt b/project-words.txt index 8fcf07008feb..528d635318ee 100644 --- a/project-words.txt +++ b/project-words.txt @@ -173,6 +173,7 @@ Lorber's lqip LQIP lunrjs +marcey Marcey Marcey's markprompt diff --git a/website/_dogfooding/_blog tests/2024-07-03-dual-author.mdx b/website/_dogfooding/_blog tests/2024-07-03-dual-author.mdx index 0a7ff12b993b..13eb48e40a5e 100644 --- a/website/_dogfooding/_blog tests/2024-07-03-dual-author.mdx +++ b/website/_dogfooding/_blog tests/2024-07-03-dual-author.mdx @@ -4,17 +4,15 @@ authors: - name: Sébastien Lorber imageURL: https://github.com/slorber.png socials: - twitter: sebastienlorber - github: slorber - stackoverflow: 82609 - linkedin: sebastienlorber + twitter: https://twitter.com/sebastienlorber + github: https://github.com/slorber + linkedin: https://www.linkedin.com/in/sebastienlorber/ newsletter: https://thisweekinreact.com/newsletter - name: Sébastien Lorber imageURL: https://github.com/slorber.png socials: x: https://x.com/sebastienlorber github: https://github.com/slorber - stackoverflow: 82609 linkedin: https://www.linkedin.com/in/sebastienlorber/ newsletter: https://thisweekinreact.com/newsletter --- diff --git a/website/_dogfooding/_blog tests/2024-07-03-single-author.mdx b/website/_dogfooding/_blog tests/2024-07-03-single-author.mdx index 0a418f42e283..8b2d6c2d78a7 100644 --- a/website/_dogfooding/_blog tests/2024-07-03-single-author.mdx +++ b/website/_dogfooding/_blog tests/2024-07-03-single-author.mdx @@ -8,7 +8,6 @@ authors: x: https://x.com/sebastienlorber twitter: https://twitter.com/sebastienlorber github: https://github.com/slorber - stackoverflow: 82609 linkedin: https://www.linkedin.com/in/sebastienlorber/ newsletter: https://thisweekinreact.com/newsletter --- diff --git a/website/_dogfooding/_blog tests/authors.yml b/website/_dogfooding/_blog tests/authors.yml index fc6a50b90bc1..040b9899ee19 100644 --- a/website/_dogfooding/_blog tests/authors.yml +++ b/website/_dogfooding/_blog tests/authors.yml @@ -4,3 +4,8 @@ slorber: url: https://sebastienlorber.com image_url: https://github.com/slorber.png twitter: sebastienlorber + page: true + +ozaki: + name: ozaki + page: {permalink: '/custom/ozaki/permalink'} diff --git a/website/blog/authors.yml b/website/blog/authors.yml index 318effc9b46b..a20778060947 100644 --- a/website/blog/authors.yml +++ b/website/blog/authors.yml @@ -3,7 +3,7 @@ JMarcey: title: Developer Advocate at Meta url: https://twitter.com/JoelMarcey image_url: https://github.com/JoelMarcey.png - email: jimarcey@gmail.com + page: true socials: x: joelmarcey github: JoelMarcey @@ -13,6 +13,7 @@ zpao: title: Engineering Manager at Meta url: https://x.com/zpao image_url: https://github.com/zpao.png + page: true socials: x: zpao github: zpao @@ -22,6 +23,11 @@ slorber: title: Docusaurus maintainer, This Week In React editor url: https://thisweekinreact.com image_url: https://github.com/slorber.png + page: true + description: > + A freelance React and React-Native developer near Paris and Docusaurus maintainer. Also runs ThisWeekInReact.com, a newsletter to stay updated with the React ecosystem. + + socials: x: sebastienlorber linkedin: sebastienlorber @@ -33,7 +39,7 @@ yangshun: title: Front End Engineer at Meta url: https://github.com/yangshun image_url: https://github.com/yangshun.png - email: tay.yang.shun@gmail.com + page: true socials: x: yangshunz github: yangshun @@ -44,6 +50,7 @@ lex111: url: https://github.com/lex111 image_url: https://github.com/lex111.png email: lex@php.net + page: true Josh-Cena: name: Joshua Chen @@ -51,6 +58,7 @@ Josh-Cena: url: https://joshcena.com/ image_url: https://github.com/josh-cena.png email: sidachen2003@gmail.com + page: true endiliey: name: Endilie Yacop Sucipto diff --git a/website/docs/api/plugins/plugin-content-blog.mdx b/website/docs/api/plugins/plugin-content-blog.mdx index 2eb10ccfb42c..3ad31c590296 100644 --- a/website/docs/api/plugins/plugin-content-blog.mdx +++ b/website/docs/api/plugins/plugin-content-blog.mdx @@ -50,6 +50,7 @@ Accepted fields: | `tagsBasePath` | `string` | `'tags'` | URL route for the tags section of your blog. Will be appended to `routeBasePath`. | | `pageBasePath` | `string` | `'page'` | URL route for the pages section of your blog. Will be appended to `routeBasePath`. | | `archiveBasePath` | string \| null | `'archive'` | URL route for the archive section of your blog. Will be appended to `routeBasePath`. **DO NOT** include a trailing slash. Use `null` to disable generation of archive. | +| `authorsBasePath` | `string` | `'authors'` | URL route for the authors pages of your blog. Will be appended to `path`. | | `include` | `string[]` | `['**/*.{md,mdx}']` | Array of glob patterns matching Markdown files to be built, relative to the content path. | | `exclude` | `string[]` | _See example configuration_ | Array of glob patterns matching Markdown files to be excluded. Serves as refinement based on the `include` option. | | `postsPerPage` | number \| 'ALL' | `10` | Number of posts to show per page in the listing page. Use `'ALL'` to display all posts on one listing page. | @@ -58,6 +59,8 @@ Accepted fields: | `blogTagsListComponent` | `string` | `'@theme/BlogTagsListPage'` | Root component of the tags list page. | | `blogTagsPostsComponent` | `string` | `'@theme/BlogTagsPostsPage'` | Root component of the "posts containing tag" page. | | `blogArchiveComponent` | `string` | `'@theme/BlogArchivePage'` | Root component of the blog archive page. | +| `blogAuthorsPostsComponent` | `string` | `'@theme/Blog/Pages/BlogAuthorsPostsPage'` | Root component of the blog author page. | +| `blogAuthorsListComponent` | `string` | `'@theme/Blog/Pages/BlogAuthorsListPage'` | Root component of the blog authors page index. | | `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. | | `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. | | `rehypePlugins` | `any[]` | `[]` | Recma plugins passed to MDX. | @@ -298,6 +301,72 @@ import TagsFileApiRefSection from './_partial-tags-file-api-ref-section.mdx'; +## Authors File {#authors-file} + +Use the [`authors` plugin option](#authors) to configure the path of a YAML authors file. + +By convention, the plugin will look for a `authors.yml` file at the root of your blog content folder(s). + +This file can contain a list of predefined [global blog authors](../../blog.mdx#global-authors). You can reference these authors by their keys in Markdown files thanks to the [`authors` front matter](#markdown-front-matter). + +### Types {#authors-file-types} + +The YAML content of the provided authors file should respect the following shape: + +```tsx +type AuthorsMapInput = { + [authorKey: string]: AuthorInput; +}; + +type AuthorInput = { + name?: string; + title?: string; + description?: string; + imageURL?: string; + url?: string; + email?: string; + page?: boolean | {permalink: string}; + socials?: Record; + [customAuthorAttribute: string]: unknown; +}; +``` + +### Example {#authors-file-example} + +```yml title="tags.yml" +slorber: + name: Sébastien Lorber + title: Docusaurus maintainer + url: https://sebastienlorber.com + image_url: https://github.com/slorber.png + page: true + socials: + x: sebastienlorber + github: slorber + +jmarcey: + name: Joel Marcey + title: Co-creator of Docusaurus 1 + url: https://github.com/JoelMarcey + image_url: https://github.com/JoelMarcey.png + email: jimarcey@gmail.com + page: + permalink: '/joel-marcey' + socials: + x: joelmarcey + github: JoelMarcey +``` + +```md title="blog/my-blog-post.md" +--- +authors: [slorber, jmarcey] +--- + +# My Blog Post + +Content +``` + ## i18n {#i18n} Read the [i18n introduction](../../i18n/i18n-introduction.mdx) first. diff --git a/website/docs/blog.mdx b/website/docs/blog.mdx index 8ec62d11fea7..e6cf18644e98 100644 --- a/website/docs/blog.mdx +++ b/website/docs/blog.mdx @@ -401,6 +401,39 @@ An author, either declared through front matter or through the authors map, need ::: +### Authors pages {#authors-pages} + +The authors pages feature is optional, and mainly useful for multi-author blogs. + +You can activate it independently for each author by adding a `page: true` attribute to the [global author configuration](#global-authors): + +```yml title="website/blog/authors.yml" +slorber: + name: Sébastien Lorber + // highlight-start + page: true # Turns the feature on - route will be /authors/slorber + // highlight-end + +jmarcey: + name: Joel Marcey + // highlight-start + page: + # Turns the feature on - route will be /authors/custom-author-url + permalink: '/custom-author-url' + // highlight-end +``` + +The blog plugin will now generate: + +- a dedicated author page for each author ([example](/blog/authors/slorber)) listing all the blog posts they contributed to +- an authors index page ([example](/blog/authors)) listing all these authors, in the order they appear in `authors.yml` + +:::warning About inline authors + +Only [global authors](#global-authors) can activate this feature. [Inline authors](#inline-authors) are not supported. + +::: + ## Blog post tags {#blog-post-tags} Tags are declared in the front matter and introduce another dimension of categorization. diff --git a/website/src/plugins/changelog/theme/ChangelogItem/Header/Author/index.tsx b/website/src/plugins/changelog/theme/ChangelogItem/Header/Author/index.tsx index 185505530d24..b37b0f5bf9e3 100644 --- a/website/src/plugins/changelog/theme/ChangelogItem/Header/Author/index.tsx +++ b/website/src/plugins/changelog/theme/ChangelogItem/Header/Author/index.tsx @@ -8,7 +8,7 @@ import React from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; -import type {Props} from '@theme/BlogPostItem/Header/Author'; +import type {Props} from '@theme/Blog/Components/Author'; import styles from './styles.module.css';