mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-10 07:37:19 +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(),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -127,6 +127,27 @@ export default function getSwizzleConfig(): SwizzleConfig {
|
|||
description:
|
||||
'The object mapping admonition type to a React component.\nUse it to add custom admonition type components, or replace existing ones.\nCan be ejected or wrapped (only manually, see our documentation).',
|
||||
},
|
||||
Blog: {
|
||||
actions: {
|
||||
// Forbidden because it's a parent folder, makes the CLI crash atm
|
||||
eject: 'forbidden',
|
||||
wrap: 'forbidden',
|
||||
},
|
||||
},
|
||||
'Blog/Components': {
|
||||
actions: {
|
||||
// Forbidden because it's a parent folder, makes the CLI crash atm
|
||||
eject: 'forbidden',
|
||||
wrap: 'forbidden',
|
||||
},
|
||||
},
|
||||
'Blog/Pages': {
|
||||
actions: {
|
||||
// Forbidden because it's a parent folder, makes the CLI crash atm
|
||||
eject: 'forbidden',
|
||||
wrap: 'forbidden',
|
||||
},
|
||||
},
|
||||
CodeBlock: {
|
||||
actions: {
|
||||
eject: 'safe',
|
||||
|
|
|
@ -185,6 +185,30 @@ declare module '@theme/BackToTopButton' {
|
|||
export default function BackToTopButton(): JSX.Element;
|
||||
}
|
||||
|
||||
declare module '@theme/Blog/Components/Author' {
|
||||
import type {Author} from '@docusaurus/plugin-content-blog';
|
||||
|
||||
export interface Props {
|
||||
readonly as?: 'h1' | 'h2';
|
||||
readonly author: Author;
|
||||
readonly className?: string;
|
||||
readonly count?: number;
|
||||
}
|
||||
|
||||
export default function BlogAuthor(props: Props): JSX.Element;
|
||||
}
|
||||
|
||||
declare module '@theme/Blog/Components/Author/Socials' {
|
||||
import type {Author} from '@docusaurus/plugin-content-blog';
|
||||
|
||||
export interface Props {
|
||||
readonly author: Author;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export default function BlogAuthorSocials(props: Props): JSX.Element;
|
||||
}
|
||||
|
||||
declare module '@theme/BlogListPaginator' {
|
||||
import type {BlogPaginatedMetadata} from '@docusaurus/plugin-content-blog';
|
||||
|
||||
|
@ -291,31 +315,6 @@ declare module '@theme/BlogPostItem/Header/Info' {
|
|||
export default function BlogPostItemHeaderInfo(): JSX.Element;
|
||||
}
|
||||
|
||||
declare module '@theme/BlogPostItem/Header/Author' {
|
||||
import type {Author} from '@docusaurus/plugin-content-blog';
|
||||
|
||||
export interface Props {
|
||||
readonly author: Author;
|
||||
readonly singleAuthor: boolean;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export default function BlogPostItemHeaderAuthor(props: Props): JSX.Element;
|
||||
}
|
||||
|
||||
declare module '@theme/BlogPostItem/Header/Author/Socials' {
|
||||
import type {Author} from '@docusaurus/plugin-content-blog';
|
||||
|
||||
export interface Props {
|
||||
readonly author: Author;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export default function BlogPostItemHeaderAuthorSocials(
|
||||
props: Props,
|
||||
): JSX.Element;
|
||||
}
|
||||
|
||||
declare module '@theme/BlogPostItem/Header/Authors' {
|
||||
export interface Props {
|
||||
readonly className?: string;
|
||||
|
|
|
@ -9,7 +9,7 @@ import type {ComponentType} from 'react';
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link from '@docusaurus/Link';
|
||||
import type {Props} from '@theme/BlogPostItem/Header/Author/Socials';
|
||||
import type {Props} from '@theme/Blog/Components/Author/Socials';
|
||||
|
||||
import Twitter from '@theme/Icon/Socials/Twitter';
|
||||
import GitHub from '@theme/Icon/Socials/GitHub';
|
||||
|
@ -50,10 +50,15 @@ function SocialLink({platform, link}: {platform: string; link: string}) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function AuthorSocials({author}: {author: Props['author']}) {
|
||||
export default function BlogAuthorSocials({
|
||||
author,
|
||||
}: {
|
||||
author: Props['author'];
|
||||
}): JSX.Element {
|
||||
const entries = Object.entries(author.socials ?? {});
|
||||
return (
|
||||
<div className={styles.authorSocials}>
|
||||
{Object.entries(author.socials ?? {}).map(([platform, linkUrl]) => {
|
||||
{entries.map(([platform, linkUrl]) => {
|
||||
return <SocialLink key={platform} platform={platform} link={linkUrl} />;
|
||||
})}
|
||||
</div>
|
|
@ -10,7 +10,12 @@
|
|||
}
|
||||
|
||||
.authorSocials {
|
||||
margin-top: 0.2rem;
|
||||
/*
|
||||
This ensures that container takes height even if there's no social link
|
||||
This keeps author names aligned even if only some have socials
|
||||
*/
|
||||
height: var(--docusaurus-blog-social-icon-size);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
@ -25,7 +30,7 @@
|
|||
height: var(--docusaurus-blog-social-icon-size);
|
||||
width: var(--docusaurus-blog-social-icon-size);
|
||||
line-height: 0;
|
||||
margin-right: 0.3rem;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.authorSocialIcon {
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link, {type Props as LinkProps} from '@docusaurus/Link';
|
||||
import AuthorSocials from '@theme/Blog/Components/Author/Socials';
|
||||
import type {Props} from '@theme/Blog/Components/Author';
|
||||
import Heading from '@theme/Heading';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function MaybeLink(props: LinkProps): JSX.Element {
|
||||
if (props.href) {
|
||||
return <Link {...props} />;
|
||||
}
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
function AuthorTitle({title}: {title: string}) {
|
||||
return (
|
||||
<small className={styles.authorTitle} title={title}>
|
||||
{title}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorName({name, as}: {name: string; as: Props['as']}) {
|
||||
if (!as) {
|
||||
return <span className={styles.authorName}>{name}</span>;
|
||||
} else {
|
||||
return (
|
||||
<Heading as={as} className={styles.authorName}>
|
||||
{name}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function AuthorBlogPostCount({count}: {count: number}) {
|
||||
return <span className={clsx(styles.authorBlogPostCount)}>{count}</span>;
|
||||
}
|
||||
|
||||
// Note: in the future we might want to have multiple "BlogAuthor" components
|
||||
// Creating different display modes with the "as" prop may not be the best idea
|
||||
// Explainer: https://kyleshevlin.com/prefer-multiple-compositions/
|
||||
// For now, we almost use the same design for all cases, so it's good enough
|
||||
export default function BlogAuthor({
|
||||
as,
|
||||
author,
|
||||
className,
|
||||
count,
|
||||
}: Props): JSX.Element {
|
||||
const {name, title, url, imageURL, email, page} = author;
|
||||
const link =
|
||||
page?.permalink || url || (email && `mailto:${email}`) || undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'avatar margin-bottom--sm',
|
||||
className,
|
||||
styles[`author-as-${as}`],
|
||||
)}>
|
||||
{imageURL && (
|
||||
<MaybeLink href={link} className="avatar__photo-link">
|
||||
<img
|
||||
className={clsx('avatar__photo', styles.authorImage)}
|
||||
src={imageURL}
|
||||
alt={name}
|
||||
/>
|
||||
</MaybeLink>
|
||||
)}
|
||||
|
||||
{(name || title) && (
|
||||
<div className={clsx('avatar__intro', styles.authorDetails)}>
|
||||
<div className="avatar__name">
|
||||
{name && (
|
||||
<MaybeLink href={link}>
|
||||
<AuthorName name={name} as={as} />
|
||||
</MaybeLink>
|
||||
)}
|
||||
{count && <AuthorBlogPostCount count={count} />}
|
||||
</div>
|
||||
{!!title && <AuthorTitle title={title} />}
|
||||
|
||||
{/*
|
||||
We always render AuthorSocials even if there's none
|
||||
This keeps other things aligned with flexbox layout
|
||||
*/}
|
||||
<AuthorSocials author={author} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.authorImage {
|
||||
--ifm-avatar-photo-size: 3.6rem;
|
||||
}
|
||||
|
||||
.author-as-h1 .authorImage {
|
||||
--ifm-avatar-photo-size: 7rem;
|
||||
}
|
||||
|
||||
.author-as-h2 .authorImage {
|
||||
--ifm-avatar-photo-size: 5.4rem;
|
||||
}
|
||||
|
||||
.authorDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.author-as-h1 .authorName {
|
||||
font-size: 2.4rem;
|
||||
line-height: 2.4rem;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.author-as-h2 .authorName {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.4rem;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.authorTitle {
|
||||
font-size: 0.8rem;
|
||||
line-height: 0.8rem;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
line-clamp: 1;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.author-as-h1 .authorTitle {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
|
||||
.author-as-h2 .authorTitle {
|
||||
font-size: 1rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.authorBlogPostCount {
|
||||
background: var(--ifm-color-secondary);
|
||||
color: var(--ifm-color-black);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
border-radius: var(--ifm-global-radius);
|
||||
padding: 0.1rem 0.4rem;
|
||||
margin-left: 0.3rem;
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
import React, {type ReactNode} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PageMetadata,
|
||||
HtmlClassNameProvider,
|
||||
ThemeClassNames,
|
||||
} from '@docusaurus/theme-common';
|
||||
import {translateBlogAuthorsListPageTitle} from '@docusaurus/theme-common/internal';
|
||||
import BlogLayout from '@theme/BlogLayout';
|
||||
import type {Props} from '@theme/Blog/Pages/BlogAuthorsListPage';
|
||||
import SearchMetadata from '@theme/SearchMetadata';
|
||||
import Heading from '@theme/Heading';
|
||||
import Author from '@theme/Blog/Components/Author';
|
||||
import type {AuthorItemProp} from '@docusaurus/plugin-content-blog';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function AuthorListItem({author}: {author: AuthorItemProp}) {
|
||||
return (
|
||||
<li className={styles.authorListItem}>
|
||||
<Author as="h2" author={author} count={author.count} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthorsList({authors}: {authors: Props['authors']}) {
|
||||
return (
|
||||
<section className={clsx('margin-vert--lg', styles.authorsListSection)}>
|
||||
<ul>
|
||||
{authors.map((author) => (
|
||||
<AuthorListItem key={author.key} author={author} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogAuthorsListPage({
|
||||
authors,
|
||||
sidebar,
|
||||
}: Props): ReactNode {
|
||||
const title: string = translateBlogAuthorsListPageTitle();
|
||||
return (
|
||||
<HtmlClassNameProvider
|
||||
className={clsx(
|
||||
ThemeClassNames.wrapper.blogPages,
|
||||
ThemeClassNames.page.blogAuthorsListPage,
|
||||
)}>
|
||||
<PageMetadata title={title} />
|
||||
<SearchMetadata tag="blog_authors_list" />
|
||||
<BlogLayout sidebar={sidebar}>
|
||||
<Heading as="h1">{title}</Heading>
|
||||
<AuthorsList authors={authors} />
|
||||
</BlogLayout>
|
||||
</HtmlClassNameProvider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.authorListItem {
|
||||
list-style-type: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
PageMetadata,
|
||||
HtmlClassNameProvider,
|
||||
ThemeClassNames,
|
||||
} from '@docusaurus/theme-common';
|
||||
import {
|
||||
useBlogAuthorPageTitle,
|
||||
BlogAuthorsListViewAllLabel,
|
||||
} from '@docusaurus/theme-common/internal';
|
||||
import Link from '@docusaurus/Link';
|
||||
import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
|
||||
import BlogLayout from '@theme/BlogLayout';
|
||||
import BlogListPaginator from '@theme/BlogListPaginator';
|
||||
import SearchMetadata from '@theme/SearchMetadata';
|
||||
import type {Props} from '@theme/Blog/Pages/BlogAuthorsPostsPage';
|
||||
import BlogPostItems from '@theme/BlogPostItems';
|
||||
import Author from '@theme/Blog/Components/Author';
|
||||
|
||||
function Metadata({author}: Props): JSX.Element {
|
||||
const title = useBlogAuthorPageTitle(author);
|
||||
return (
|
||||
<>
|
||||
<PageMetadata title={title} />
|
||||
<SearchMetadata tag="blog_authors_posts" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewAllAuthorsLink() {
|
||||
const {authorsListPath} = useBlogMetadata();
|
||||
return (
|
||||
<Link href={authorsListPath}>
|
||||
<BlogAuthorsListViewAllLabel />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({author, items, sidebar, listMetadata}: Props): JSX.Element {
|
||||
return (
|
||||
<BlogLayout sidebar={sidebar}>
|
||||
<header className="margin-bottom--xl">
|
||||
<Author as="h1" author={author} />
|
||||
{author.description && <p>{author.description}</p>}
|
||||
<ViewAllAuthorsLink />
|
||||
</header>
|
||||
<hr />
|
||||
<BlogPostItems items={items} />
|
||||
<BlogListPaginator metadata={listMetadata} />
|
||||
</BlogLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogAuthorsPostsPage(props: Props): JSX.Element {
|
||||
return (
|
||||
<HtmlClassNameProvider
|
||||
className={clsx(
|
||||
ThemeClassNames.wrapper.blogPages,
|
||||
ThemeClassNames.page.blogAuthorsPostsPage,
|
||||
)}>
|
||||
<Metadata {...props} />
|
||||
<Content {...props} />
|
||||
</HtmlClassNameProvider>
|
||||
);
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link, {type Props as LinkProps} from '@docusaurus/Link';
|
||||
import AuthorSocials from '@theme/BlogPostItem/Header/Author/Socials';
|
||||
|
||||
import type {Props} from '@theme/BlogPostItem/Header/Author';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
function MaybeLink(props: LinkProps): JSX.Element {
|
||||
if (props.href) {
|
||||
return <Link {...props} />;
|
||||
}
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
function AuthorTitle({title}: {title: string}) {
|
||||
return (
|
||||
<small className={styles.authorTitle} title={title}>
|
||||
{title}
|
||||
</small>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlogPostItemHeaderAuthor({
|
||||
// singleAuthor, // may be useful in the future, or for swizzle users
|
||||
author,
|
||||
className,
|
||||
}: Props): JSX.Element {
|
||||
const {name, title, url, socials, imageURL, email} = author;
|
||||
const link = url || (email && `mailto:${email}`) || undefined;
|
||||
|
||||
const hasSocials = socials && Object.keys(socials).length > 0;
|
||||
|
||||
return (
|
||||
<div className={clsx('avatar margin-bottom--sm', className)}>
|
||||
{imageURL && (
|
||||
<MaybeLink href={link} className="avatar__photo-link">
|
||||
<img className="avatar__photo" src={imageURL} alt={name} />
|
||||
</MaybeLink>
|
||||
)}
|
||||
|
||||
{(name || title) && (
|
||||
<div className="avatar__intro">
|
||||
<div className="avatar__name">
|
||||
<MaybeLink href={link}>
|
||||
<span className={styles.authorName}>{name}</span>
|
||||
</MaybeLink>
|
||||
</div>
|
||||
{!!title && <AuthorTitle title={title} />}
|
||||
{hasSocials && <AuthorSocials author={author} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,21 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
.authorName {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.authorTitle {
|
||||
margin-top: 0.06rem;
|
||||
font-size: 0.8rem;
|
||||
line-height: 0.8rem;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
line-clamp: 1;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
|
||||
import BlogPostItemHeaderAuthor from '@theme/BlogPostItem/Header/Author';
|
||||
import BlogAuthor from '@theme/Blog/Components/Author';
|
||||
import type {Props} from '@theme/BlogPostItem/Header/Authors';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
|
@ -40,8 +40,7 @@ export default function BlogPostItemHeaderAuthors({
|
|||
imageOnly ? styles.imageOnlyAuthorCol : styles.authorCol,
|
||||
)}
|
||||
key={idx}>
|
||||
<BlogPostItemHeaderAuthor
|
||||
singleAuthor={singleAuthor}
|
||||
<BlogAuthor
|
||||
author={{
|
||||
...author,
|
||||
// Handle author images using relative paths
|
||||
|
|
|
@ -7,13 +7,13 @@
|
|||
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Translate, {translate} from '@docusaurus/Translate';
|
||||
import Translate from '@docusaurus/Translate';
|
||||
import {
|
||||
PageMetadata,
|
||||
HtmlClassNameProvider,
|
||||
ThemeClassNames,
|
||||
usePluralForm,
|
||||
} from '@docusaurus/theme-common';
|
||||
import {useBlogTagsPostsPageTitle} from '@docusaurus/theme-common/internal';
|
||||
import Link from '@docusaurus/Link';
|
||||
import BlogLayout from '@theme/BlogLayout';
|
||||
import BlogListPaginator from '@theme/BlogListPaginator';
|
||||
|
@ -23,36 +23,6 @@ import BlogPostItems from '@theme/BlogPostItems';
|
|||
import Unlisted from '@theme/Unlisted';
|
||||
import Heading from '@theme/Heading';
|
||||
|
||||
// Very simple pluralization: probably good enough for now
|
||||
function useBlogPostsPlural() {
|
||||
const {selectMessage} = usePluralForm();
|
||||
return (count: number) =>
|
||||
selectMessage(
|
||||
count,
|
||||
translate(
|
||||
{
|
||||
id: 'theme.blog.post.plurals',
|
||||
description:
|
||||
'Pluralized label for "{count} posts". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
|
||||
message: 'One post|{count} posts',
|
||||
},
|
||||
{count},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function useBlogTagsPostsPageTitle(tag: Props['tag']): string {
|
||||
const blogPostsPlural = useBlogPostsPlural();
|
||||
return translate(
|
||||
{
|
||||
id: 'theme.blog.tagTitle',
|
||||
description: 'The title of the page for a blog tag',
|
||||
message: '{nPosts} tagged with "{tagName}"',
|
||||
},
|
||||
{nPosts: blogPostsPlural(tag.count), tagName: tag.label},
|
||||
);
|
||||
}
|
||||
|
||||
function BlogTagsPostsPageMetadata({tag}: Props): JSX.Element {
|
||||
const title = useBlogTagsPostsPageTitle(tag);
|
||||
return (
|
||||
|
|
|
@ -90,3 +90,10 @@ export {useLockBodyScroll} from './hooks/useLockBodyScroll';
|
|||
export {useCodeWordWrap} from './hooks/useCodeWordWrap';
|
||||
export {getPrismCssVariables} from './utils/codeBlockUtils';
|
||||
export {useBackToTopButton} from './hooks/useBackToTopButton';
|
||||
|
||||
export {
|
||||
useBlogTagsPostsPageTitle,
|
||||
useBlogAuthorPageTitle,
|
||||
translateBlogAuthorsListPageTitle,
|
||||
BlogAuthorsListViewAllLabel,
|
||||
} from './translations/blogTranslations';
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import React, {type ReactNode} from 'react';
|
||||
import Translate, {translate} from '@docusaurus/Translate';
|
||||
import {usePluralForm} from '../utils/usePluralForm';
|
||||
|
||||
// Only used locally
|
||||
function useBlogPostsPlural(): (count: number) => string {
|
||||
const {selectMessage} = usePluralForm();
|
||||
return (count: number) =>
|
||||
selectMessage(
|
||||
count,
|
||||
translate(
|
||||
{
|
||||
id: 'theme.blog.post.plurals',
|
||||
description:
|
||||
'Pluralized label for "{count} posts". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)',
|
||||
message: 'One post|{count} posts',
|
||||
},
|
||||
{count},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function useBlogTagsPostsPageTitle(tag: {
|
||||
label: string;
|
||||
count: number;
|
||||
}): string {
|
||||
const blogPostsPlural = useBlogPostsPlural();
|
||||
return translate(
|
||||
{
|
||||
id: 'theme.blog.tagTitle',
|
||||
description: 'The title of the page for a blog tag',
|
||||
message: '{nPosts} tagged with "{tagName}"',
|
||||
},
|
||||
{nPosts: blogPostsPlural(tag.count), tagName: tag.label},
|
||||
);
|
||||
}
|
||||
|
||||
export function useBlogAuthorPageTitle(author: {
|
||||
key: string;
|
||||
name?: string;
|
||||
count: number;
|
||||
}): string {
|
||||
const blogPostsPlural = useBlogPostsPlural();
|
||||
return translate(
|
||||
{
|
||||
id: 'theme.blog.author.pageTitle',
|
||||
description: 'The title of the page for a blog author',
|
||||
message: '{authorName} - {nPosts}',
|
||||
},
|
||||
{
|
||||
nPosts: blogPostsPlural(author.count),
|
||||
authorName: author.name || author.key,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const translateBlogAuthorsListPageTitle = (): string =>
|
||||
translate({
|
||||
id: 'theme.blog.authorsList.pageTitle',
|
||||
message: 'Authors',
|
||||
description: 'The title of the authors page',
|
||||
});
|
||||
|
||||
export function BlogAuthorsListViewAllLabel(): ReactNode {
|
||||
return (
|
||||
<Translate
|
||||
id="theme.blog.authorsList.viewAll"
|
||||
description="The label of the link targeting the blog authors page">
|
||||
View All Authors
|
||||
</Translate>
|
||||
);
|
||||
}
|
|
@ -18,6 +18,8 @@ export const ThemeClassNames = {
|
|||
blogPostPage: 'blog-post-page',
|
||||
blogTagsListPage: 'blog-tags-list-page',
|
||||
blogTagPostListPage: 'blog-tags-post-list-page',
|
||||
blogAuthorsListPage: 'blog-authors-list-page',
|
||||
blogAuthorsPostsPage: 'blog-authors-posts-page',
|
||||
|
||||
docsDocPage: 'docs-doc-page',
|
||||
docsTagsListPage: 'docs-tags-list-page',
|
||||
|
|
|
@ -7,42 +7,47 @@
|
|||
|
||||
import _ from 'lodash';
|
||||
import {listTagsByLetters} from '../tagsUtils';
|
||||
import type {TagsListItem} from '@docusaurus/utils';
|
||||
|
||||
describe('listTagsByLetters', () => {
|
||||
type Param = Parameters<typeof listTagsByLetters>[0];
|
||||
type Tag = Param[number];
|
||||
type Result = ReturnType<typeof listTagsByLetters>;
|
||||
|
||||
it('creates letters list', () => {
|
||||
const tag1: Tag = {
|
||||
const tag1: TagsListItem = {
|
||||
label: 'tag1',
|
||||
permalink: '/tag1',
|
||||
count: 1,
|
||||
description: '',
|
||||
};
|
||||
const tag2: Tag = {
|
||||
const tag2: TagsListItem = {
|
||||
label: 'Tag2',
|
||||
permalink: '/tag2',
|
||||
count: 11,
|
||||
description: '',
|
||||
};
|
||||
const tagZxy: Tag = {
|
||||
const tagZxy: TagsListItem = {
|
||||
label: 'zxy',
|
||||
permalink: '/zxy',
|
||||
count: 987,
|
||||
description: '',
|
||||
};
|
||||
const tagAbc: Tag = {
|
||||
const tagAbc: TagsListItem = {
|
||||
label: 'Abc',
|
||||
permalink: '/abc',
|
||||
count: 123,
|
||||
description: '',
|
||||
};
|
||||
const tagDef: Tag = {
|
||||
const tagDef: TagsListItem = {
|
||||
label: 'def',
|
||||
permalink: '/def',
|
||||
count: 1,
|
||||
description: '',
|
||||
};
|
||||
const tagAaa: Tag = {
|
||||
const tagAaa: TagsListItem = {
|
||||
label: 'aaa',
|
||||
permalink: '/aaa',
|
||||
count: 10,
|
||||
description: '',
|
||||
};
|
||||
|
||||
const expectedResult: Result = [
|
|
@ -41,6 +41,9 @@
|
|||
"theme.admonition.tip___DESCRIPTION": "The default label used for the Tip admonition (:::tip)",
|
||||
"theme.admonition.warning": "warning",
|
||||
"theme.admonition.warning___DESCRIPTION": "The default label used for the Warning admonition (:::warning)",
|
||||
"theme.blog.authorsList.pageTitle": "Authors",
|
||||
"theme.blog.authorsList.viewAll": "View All Authors",
|
||||
"theme.blog.author.pageTitle": "{authorName} - {nPosts}",
|
||||
"theme.blog.archive.description": "Archive",
|
||||
"theme.blog.archive.description___DESCRIPTION": "The page & hero description of the blog archive page",
|
||||
"theme.blog.archive.title": "Archive",
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
{"a": 2}
|
|
@ -1 +0,0 @@
|
|||
a: 2
|
|
@ -1 +0,0 @@
|
|||
{"a": 1}
|
|
@ -1 +0,0 @@
|
|||
a: 1
|
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.json
generated
Normal file
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.json
generated
Normal file
|
@ -0,0 +1 @@
|
|||
{"content": "json"}
|
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.yml
generated
Normal file
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/dataFile.yml
generated
Normal file
|
@ -0,0 +1 @@
|
|||
content: original yaml
|
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml
generated
Normal file
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml
generated
Normal file
|
@ -0,0 +1 @@
|
|||
}{{{{12434665¨£%£%%£%£}}}}
|
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/localized/dataFile.yml
generated
Normal file
1
packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/localized/dataFile.yml
generated
Normal file
|
@ -0,0 +1 @@
|
|||
content: localized yaml
|
|
@ -10,7 +10,7 @@ import {
|
|||
findFolderContainingFile,
|
||||
getFolderContainingFile,
|
||||
getDataFilePath,
|
||||
getDataFileData,
|
||||
readDataFile,
|
||||
} from '../dataFileUtils';
|
||||
|
||||
describe('getDataFilePath', () => {
|
||||
|
@ -125,46 +125,40 @@ describe('getDataFilePath', () => {
|
|||
});
|
||||
|
||||
describe('getDataFileData', () => {
|
||||
const fixturesDir = path.join(__dirname, '__fixtures__/dataFiles/actualData');
|
||||
function readDataFile(filePath: string) {
|
||||
return getDataFileData(
|
||||
{
|
||||
filePath,
|
||||
contentPaths: {contentPath: fixturesDir, contentPathLocalized: ''},
|
||||
fileType: 'test',
|
||||
},
|
||||
(content) => {
|
||||
// @ts-expect-error: good enough
|
||||
if (content.a !== 1) {
|
||||
throw new Error('Nope');
|
||||
}
|
||||
return content;
|
||||
},
|
||||
function testFile(filePath: string) {
|
||||
const contentPath = path.join(
|
||||
__dirname,
|
||||
'__fixtures__/dataFiles/dataFiles',
|
||||
);
|
||||
const contentPathLocalized = path.join(contentPath, 'localized');
|
||||
return readDataFile({
|
||||
filePath,
|
||||
contentPaths: {contentPath, contentPathLocalized},
|
||||
});
|
||||
}
|
||||
|
||||
it('returns undefined for nonexistent file', async () => {
|
||||
await expect(readDataFile('nonexistent.yml')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('read valid yml author file', async () => {
|
||||
await expect(readDataFile('valid.yml')).resolves.toEqual({a: 1});
|
||||
await expect(testFile('nonexistent.yml')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('read valid json author file', async () => {
|
||||
await expect(readDataFile('valid.json')).resolves.toEqual({a: 1});
|
||||
await expect(testFile('dataFile.json')).resolves.toEqual({
|
||||
content: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
it('fail to read invalid yml', async () => {
|
||||
await expect(
|
||||
readDataFile('bad.yml'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Nope"`);
|
||||
it('read valid yml author file using localized source in priority', async () => {
|
||||
await expect(testFile('dataFile.yml')).resolves.toEqual({
|
||||
content: 'localized yaml',
|
||||
});
|
||||
});
|
||||
|
||||
it('fail to read invalid json', async () => {
|
||||
it('throw for invalid file', async () => {
|
||||
await expect(
|
||||
readDataFile('bad.json'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Nope"`);
|
||||
testFile('invalid.yml'),
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"The file at "packages/docusaurus-utils/src/__tests__/__fixtures__/dataFiles/dataFiles/invalid.yml" looks invalid (not Yaml nor JSON)."`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -43,31 +43,28 @@ export async function getDataFilePath({
|
|||
}
|
||||
|
||||
/**
|
||||
* Looks up for a data file in the content paths, returns the object validated
|
||||
* and normalized according to the `validate` callback.
|
||||
* Looks up for a data file in the content paths
|
||||
* Favors the localized content path over the base content path
|
||||
* Currently supports Yaml and JSON data files
|
||||
* It is the caller responsibility to validate and normalize the resulting data
|
||||
*
|
||||
* @returns `undefined` when file not found
|
||||
* @throws Throws when validation fails, displaying a helpful context message.
|
||||
* @throws Throws when data file can't be parsed
|
||||
*/
|
||||
export async function getDataFileData<T>(
|
||||
params: DataFileParams & {
|
||||
/** Used for the "The X file looks invalid" message. */
|
||||
fileType: string;
|
||||
},
|
||||
validate: (content: unknown) => T,
|
||||
): Promise<T | undefined> {
|
||||
export async function readDataFile(params: DataFileParams): Promise<unknown> {
|
||||
const filePath = await getDataFilePath(params);
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
|
||||
const unsafeContent = Yaml.load(contentString);
|
||||
// TODO we shouldn't validate here: it makes validation harder to test
|
||||
return validate(unsafeContent);
|
||||
return Yaml.load(contentString);
|
||||
} catch (err) {
|
||||
logger.error`The ${params.fileType} file at path=${filePath} looks invalid.`;
|
||||
throw err;
|
||||
const msg = logger.interpolate`The file at path=${path.relative(
|
||||
process.cwd(),
|
||||
filePath,
|
||||
)} looks invalid (not Yaml nor JSON).`;
|
||||
throw new Error(msg, {cause: err as Error});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -109,7 +109,7 @@ export {escapeShellArg} from './shellUtils';
|
|||
export {loadFreshModule} from './moduleUtils';
|
||||
export {
|
||||
getDataFilePath,
|
||||
getDataFileData,
|
||||
readDataFile,
|
||||
getContentPathList,
|
||||
findFolderContainingFile,
|
||||
getFolderContainingFile,
|
||||
|
|
|
@ -45,6 +45,7 @@ export type TagsListItem = Tag & {
|
|||
/** What the tag's own page should know about the tag. */
|
||||
export type TagModule = TagsListItem & {
|
||||
/** The tags list page's permalink. */
|
||||
// TODO move this global value to a shared docs/blog bundle
|
||||
allTagsPath: string;
|
||||
/** Is this tag unlisted? (when it only contains unlisted items) */
|
||||
unlisted: boolean;
|
||||
|
|
|
@ -173,6 +173,7 @@ Lorber's
|
|||
lqip
|
||||
LQIP
|
||||
lunrjs
|
||||
marcey
|
||||
Marcey
|
||||
Marcey's
|
||||
markprompt
|
||||
|
|
|
@ -4,17 +4,15 @@ authors:
|
|||
- name: Sébastien Lorber
|
||||
imageURL: https://github.com/slorber.png
|
||||
socials:
|
||||
twitter: sebastienlorber
|
||||
github: slorber
|
||||
stackoverflow: 82609
|
||||
linkedin: sebastienlorber
|
||||
twitter: https://twitter.com/sebastienlorber
|
||||
github: https://github.com/slorber
|
||||
linkedin: https://www.linkedin.com/in/sebastienlorber/
|
||||
newsletter: https://thisweekinreact.com/newsletter
|
||||
- name: Sébastien Lorber
|
||||
imageURL: https://github.com/slorber.png
|
||||
socials:
|
||||
x: https://x.com/sebastienlorber
|
||||
github: https://github.com/slorber
|
||||
stackoverflow: 82609
|
||||
linkedin: https://www.linkedin.com/in/sebastienlorber/
|
||||
newsletter: https://thisweekinreact.com/newsletter
|
||||
---
|
||||
|
|
|
@ -8,7 +8,6 @@ authors:
|
|||
x: https://x.com/sebastienlorber
|
||||
twitter: https://twitter.com/sebastienlorber
|
||||
github: https://github.com/slorber
|
||||
stackoverflow: 82609
|
||||
linkedin: https://www.linkedin.com/in/sebastienlorber/
|
||||
newsletter: https://thisweekinreact.com/newsletter
|
||||
---
|
||||
|
|
|
@ -4,3 +4,8 @@ slorber:
|
|||
url: https://sebastienlorber.com
|
||||
image_url: https://github.com/slorber.png
|
||||
twitter: sebastienlorber
|
||||
page: true
|
||||
|
||||
ozaki:
|
||||
name: ozaki
|
||||
page: {permalink: '/custom/ozaki/permalink'}
|
||||
|
|
|
@ -3,7 +3,7 @@ JMarcey:
|
|||
title: Developer Advocate at Meta
|
||||
url: https://twitter.com/JoelMarcey
|
||||
image_url: https://github.com/JoelMarcey.png
|
||||
email: jimarcey@gmail.com
|
||||
page: true
|
||||
socials:
|
||||
x: joelmarcey
|
||||
github: JoelMarcey
|
||||
|
@ -13,6 +13,7 @@ zpao:
|
|||
title: Engineering Manager at Meta
|
||||
url: https://x.com/zpao
|
||||
image_url: https://github.com/zpao.png
|
||||
page: true
|
||||
socials:
|
||||
x: zpao
|
||||
github: zpao
|
||||
|
@ -22,6 +23,11 @@ slorber:
|
|||
title: Docusaurus maintainer, This Week In React editor
|
||||
url: https://thisweekinreact.com
|
||||
image_url: https://github.com/slorber.png
|
||||
page: true
|
||||
description: >
|
||||
A freelance React and React-Native developer near Paris and Docusaurus maintainer. Also runs ThisWeekInReact.com, a newsletter to stay updated with the React ecosystem.
|
||||
|
||||
|
||||
socials:
|
||||
x: sebastienlorber
|
||||
linkedin: sebastienlorber
|
||||
|
@ -33,7 +39,7 @@ yangshun:
|
|||
title: Front End Engineer at Meta
|
||||
url: https://github.com/yangshun
|
||||
image_url: https://github.com/yangshun.png
|
||||
email: tay.yang.shun@gmail.com
|
||||
page: true
|
||||
socials:
|
||||
x: yangshunz
|
||||
github: yangshun
|
||||
|
@ -44,6 +50,7 @@ lex111:
|
|||
url: https://github.com/lex111
|
||||
image_url: https://github.com/lex111.png
|
||||
email: lex@php.net
|
||||
page: true
|
||||
|
||||
Josh-Cena:
|
||||
name: Joshua Chen
|
||||
|
@ -51,6 +58,7 @@ Josh-Cena:
|
|||
url: https://joshcena.com/
|
||||
image_url: https://github.com/josh-cena.png
|
||||
email: sidachen2003@gmail.com
|
||||
page: true
|
||||
|
||||
endiliey:
|
||||
name: Endilie Yacop Sucipto
|
||||
|
|
|
@ -50,6 +50,7 @@ Accepted fields:
|
|||
| `tagsBasePath` | `string` | `'tags'` | URL route for the tags section of your blog. Will be appended to `routeBasePath`. |
|
||||
| `pageBasePath` | `string` | `'page'` | URL route for the pages section of your blog. Will be appended to `routeBasePath`. |
|
||||
| `archiveBasePath` | <code>string \| null</code> | `'archive'` | URL route for the archive section of your blog. Will be appended to `routeBasePath`. **DO NOT** include a trailing slash. Use `null` to disable generation of archive. |
|
||||
| `authorsBasePath` | `string` | `'authors'` | URL route for the authors pages of your blog. Will be appended to `path`. |
|
||||
| `include` | `string[]` | `['**/*.{md,mdx}']` | Array of glob patterns matching Markdown files to be built, relative to the content path. |
|
||||
| `exclude` | `string[]` | _See example configuration_ | Array of glob patterns matching Markdown files to be excluded. Serves as refinement based on the `include` option. |
|
||||
| `postsPerPage` | <code>number \| 'ALL'</code> | `10` | Number of posts to show per page in the listing page. Use `'ALL'` to display all posts on one listing page. |
|
||||
|
@ -58,6 +59,8 @@ Accepted fields:
|
|||
| `blogTagsListComponent` | `string` | `'@theme/BlogTagsListPage'` | Root component of the tags list page. |
|
||||
| `blogTagsPostsComponent` | `string` | `'@theme/BlogTagsPostsPage'` | Root component of the "posts containing tag" page. |
|
||||
| `blogArchiveComponent` | `string` | `'@theme/BlogArchivePage'` | Root component of the blog archive page. |
|
||||
| `blogAuthorsPostsComponent` | `string` | `'@theme/Blog/Pages/BlogAuthorsPostsPage'` | Root component of the blog author page. |
|
||||
| `blogAuthorsListComponent` | `string` | `'@theme/Blog/Pages/BlogAuthorsListPage'` | Root component of the blog authors page index. |
|
||||
| `remarkPlugins` | `any[]` | `[]` | Remark plugins passed to MDX. |
|
||||
| `rehypePlugins` | `any[]` | `[]` | Rehype plugins passed to MDX. |
|
||||
| `rehypePlugins` | `any[]` | `[]` | Recma plugins passed to MDX. |
|
||||
|
@ -298,6 +301,72 @@ import TagsFileApiRefSection from './_partial-tags-file-api-ref-section.mdx';
|
|||
|
||||
<TagsFileApiRefSection />
|
||||
|
||||
## Authors File {#authors-file}
|
||||
|
||||
Use the [`authors` plugin option](#authors) to configure the path of a YAML authors file.
|
||||
|
||||
By convention, the plugin will look for a `authors.yml` file at the root of your blog content folder(s).
|
||||
|
||||
This file can contain a list of predefined [global blog authors](../../blog.mdx#global-authors). You can reference these authors by their keys in Markdown files thanks to the [`authors` front matter](#markdown-front-matter).
|
||||
|
||||
### Types {#authors-file-types}
|
||||
|
||||
The YAML content of the provided authors file should respect the following shape:
|
||||
|
||||
```tsx
|
||||
type AuthorsMapInput = {
|
||||
[authorKey: string]: AuthorInput;
|
||||
};
|
||||
|
||||
type AuthorInput = {
|
||||
name?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageURL?: string;
|
||||
url?: string;
|
||||
email?: string;
|
||||
page?: boolean | {permalink: string};
|
||||
socials?: Record<string, string>;
|
||||
[customAuthorAttribute: string]: unknown;
|
||||
};
|
||||
```
|
||||
|
||||
### Example {#authors-file-example}
|
||||
|
||||
```yml title="tags.yml"
|
||||
slorber:
|
||||
name: Sébastien Lorber
|
||||
title: Docusaurus maintainer
|
||||
url: https://sebastienlorber.com
|
||||
image_url: https://github.com/slorber.png
|
||||
page: true
|
||||
socials:
|
||||
x: sebastienlorber
|
||||
github: slorber
|
||||
|
||||
jmarcey:
|
||||
name: Joel Marcey
|
||||
title: Co-creator of Docusaurus 1
|
||||
url: https://github.com/JoelMarcey
|
||||
image_url: https://github.com/JoelMarcey.png
|
||||
email: jimarcey@gmail.com
|
||||
page:
|
||||
permalink: '/joel-marcey'
|
||||
socials:
|
||||
x: joelmarcey
|
||||
github: JoelMarcey
|
||||
```
|
||||
|
||||
```md title="blog/my-blog-post.md"
|
||||
---
|
||||
authors: [slorber, jmarcey]
|
||||
---
|
||||
|
||||
# My Blog Post
|
||||
|
||||
Content
|
||||
```
|
||||
|
||||
## i18n {#i18n}
|
||||
|
||||
Read the [i18n introduction](../../i18n/i18n-introduction.mdx) first.
|
||||
|
|
|
@ -401,6 +401,39 @@ An author, either declared through front matter or through the authors map, need
|
|||
|
||||
:::
|
||||
|
||||
### Authors pages {#authors-pages}
|
||||
|
||||
The authors pages feature is optional, and mainly useful for multi-author blogs.
|
||||
|
||||
You can activate it independently for each author by adding a `page: true` attribute to the [global author configuration](#global-authors):
|
||||
|
||||
```yml title="website/blog/authors.yml"
|
||||
slorber:
|
||||
name: Sébastien Lorber
|
||||
// highlight-start
|
||||
page: true # Turns the feature on - route will be /authors/slorber
|
||||
// highlight-end
|
||||
|
||||
jmarcey:
|
||||
name: Joel Marcey
|
||||
// highlight-start
|
||||
page:
|
||||
# Turns the feature on - route will be /authors/custom-author-url
|
||||
permalink: '/custom-author-url'
|
||||
// highlight-end
|
||||
```
|
||||
|
||||
The blog plugin will now generate:
|
||||
|
||||
- a dedicated author page for each author ([example](/blog/authors/slorber)) listing all the blog posts they contributed to
|
||||
- an authors index page ([example](/blog/authors)) listing all these authors, in the order they appear in `authors.yml`
|
||||
|
||||
:::warning About inline authors
|
||||
|
||||
Only [global authors](#global-authors) can activate this feature. [Inline authors](#inline-authors) are not supported.
|
||||
|
||||
:::
|
||||
|
||||
## Blog post tags {#blog-post-tags}
|
||||
|
||||
Tags are declared in the front matter and introduce another dimension of categorization.
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import Link from '@docusaurus/Link';
|
||||
import type {Props} from '@theme/BlogPostItem/Header/Author';
|
||||
import type {Props} from '@theme/Blog/Components/Author';
|
||||
|
||||
import styles from './styles.module.css';
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue