mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-11 07:12:29 +02:00
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:
parent
50f9fce29b
commit
f356e29938
56 changed files with 1670 additions and 706 deletions
|
@ -7,3 +7,4 @@ slorber:
|
|||
twitter: sebastienlorber
|
||||
x: https://x.com/sebastienlorber
|
||||
github: slorber
|
||||
page: true
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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`] = `
|
||||
[
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
|
|
171
packages/docusaurus-plugin-content-blog/src/authorsMap.ts
Normal file
171
packages/docusaurus-plugin-content-blog/src/authorsMap.ts
Normal 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;
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue