feat(blog): authors page (#10216)

Co-authored-by: OzakIOne <OzakIOne@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
Co-authored-by: slorber <slorber@users.noreply.github.com>
This commit is contained in:
ozaki 2024-08-01 17:30:49 +02:00 committed by GitHub
parent 50f9fce29b
commit f356e29938
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1670 additions and 706 deletions

View file

@ -7,3 +7,4 @@ slorber:
twitter: sebastienlorber
x: https://x.com/sebastienlorber
github: slorber
page: true

View file

@ -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"

View file

@ -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`] = `
[
{

View file

@ -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",
},

View file

@ -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>): 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('can group blog posts', () => {
const post1 = post({metadata: {authors: [{key: 'ozaki'}]}});
const post2 = post({
metadata: {authors: [{key: 'slorber'}, {key: 'ozaki'}]},
});
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('getAuthorsMap can return undefined if yaml file not found', async () => {
await expect(
getAuthorsMap({
contentPaths,
authorsMapPath: 'authors_does_not_exist.yml',
}),
).resolves.toBeUndefined();
});
const blogPosts = [post1, post2, post3, post4, post5, post6];
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",
}
`);
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,
});
});
});
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',
},
});
});
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',
},
},
};
expect(validateAuthorsMap(authorsMap)).toEqual(authorsMap);
});
});

View file

@ -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);
});
});

View file

@ -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
",
]
`);
});
});

View file

@ -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({

View file

@ -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<void> {
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({

View file

@ -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,

View file

@ -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<AuthorsMap>()
.pattern(
Joi.string(),
Joi.object<Author>({
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<AuthorsMap | undefined> {
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};
}
return authorInput;
}
return Array.isArray(frontMatterAuthors)
? frontMatterAuthors.map(normalizeFrontMatterAuthor)
: [normalizeFrontMatterAuthor(frontMatterAuthors)];
}
function getFrontMatterAuthors(params: AuthorsParam): Author[] {
const {authorsMap} = params;
const frontMatterAuthors = normalizeFrontMatterAuthors(
params.frontMatter.authors,
);
const {authorsMap, frontMatter, baseUrl} = params;
return normalizeFrontMatterAuthors().map(toAuthor);
function normalizeFrontMatterAuthors(): BlogPostFrontMatterAuthor[] {
if (frontMatter.authors === undefined) {
return [];
}
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;
}
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 {
...author,
key: author.key ?? null,
page: author.page ?? null,
imageURL: normalizeImageUrl({imageURL: author.imageURL, baseUrl}),
};
}
return frontMatterAuthors.map(toAuthor);
}
function fixAuthorImageBaseURL(
authors: Author[],
{baseUrl}: {baseUrl: string},
) {
return authors.map((author) => ({
...author,
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<string, BlogPost[]> {
return _.mapValues(authorsMap, (author, key) =>
blogPosts.filter((p) => p.metadata.authors.some((a) => a.key === key)),
);
}

View file

@ -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<AuthorPage>({
permalink: Joi.string().required(),
});
const AuthorsMapInputSchema = Joi.object<AuthorsMapInput>()
.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<AuthorsMapInput | undefined> {
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<AuthorsMap | undefined> {
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;
}

View file

@ -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<BlogPost[]> {
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) {

View file

@ -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,
};
},

View file

@ -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<PluginOptions>({
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<PluginOptions>({
.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),

View file

@ -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 {

View file

@ -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,

View file

@ -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(),
];
}