feat(plugin-blog): multi-authors support + authors.yml global configuration (#5396)

* Complete function

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* A lot of blank lines

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* More lenient validation

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Remove or

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Simpler logic

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Expand docs

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Better docs

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Dogfood

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* More writeup

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Polish

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Polish

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Move mergeAuthorMap to authors.ts

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Unbreak relative assets

* Update docs

* Clarify in docs

* simplify feed authors

* rename authorMap -> authorsMap

* mergeAuthorsMap -> getBlogPostAuthors

* website => 5 blog posts per page

* improve authors map file

* Extract new theme authors components + display in row

* add comment for meta array syntaxes

* blog => getPathsToWatch should watch authorsMap file

* remove useless v1 blog FBID frontmatter

* keep older frontmatter syntax for now

* revert blog frontmatter

* Better console message

* better blog authors frontmatter impl

* add multi authors to beta blog post + fix some authors margins

* fix React key

* Refactor: mdx loader should support a more flexible assets system (poc, not documented yet)

* better display of blog post authors: adapt layout to authors count + add line clamp

* smaller local image

* fix blog feed tests

* fix blog frontmatter tests + improve validation schema

* add more frontmatter tests

* add tests for getAuthorsMapFilePath

* tests for validateAuthorsMapFile

* add tests for readAuthorsMapFile

* test getAuthorsMap

* exhaustive tests for getBlogPostAuthors

* fix remaining tests

* missing blog plugin author tests

* fix windows tests

* improve blog multi-author's doc

* Use new format in init template

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* Improve error message

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* update feed snapshot

* blog authors: limit to 2 cols + fix margins for no authors

* minor doc improvements

* better init template blog posts, demonstrating Blog features

* replace the legacy blog author frontmatter in remaining places

* Prefer using clsx

Signed-off-by: Josh-Cena <sidachen2003@gmail.com>

* cleanup getColClassName

* remove blog author name/title line-clamping

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Joshua Chen 2021-08-26 18:21:58 +08:00 committed by GitHub
parent 8779c8ff4a
commit 493225a3c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 1871 additions and 285 deletions

View file

@ -21,33 +21,10 @@ declare module '@theme/BlogSidebar' {
}
declare module '@theme/BlogPostPage' {
import type {FrontMatterTag} from '@docusaurus/utils';
import type {BlogSidebar} from '@theme/BlogSidebar';
export type FrontMatter = {
/* eslint-disable camelcase */
readonly title: string;
readonly author?: string;
readonly image?: string;
readonly tags?: readonly FrontMatterTag[];
readonly keywords?: readonly string[];
readonly author_url?: string;
readonly authorURL?: string;
readonly author_title?: string;
readonly authorTitle?: string;
readonly author_image_url?: string;
readonly authorImageURL?: string;
readonly hide_table_of_contents?: boolean;
/* eslint-enable camelcase */
};
export type FrontMatterAssets = {
/* eslint-disable camelcase */
readonly image?: string;
readonly author_image_url?: string;
readonly authorImageURL?: string;
/* eslint-enable camelcase */
};
export type FrontMatter = import('./src/blogFrontMatter').BlogPostFrontMatter;
export type Assets = import('./src/types').Assets;
export type Metadata = {
readonly title: string;
@ -60,6 +37,7 @@ declare module '@theme/BlogPostPage' {
readonly truncated?: string;
readonly nextItem?: {readonly title: string; readonly permalink: string};
readonly prevItem?: {readonly title: string; readonly permalink: string};
readonly authors: import('./src/types').Author[];
readonly tags: readonly {
readonly label: string;
readonly permalink: string;
@ -68,7 +46,7 @@ declare module '@theme/BlogPostPage' {
export type Content = {
readonly frontMatter: FrontMatter;
readonly frontMatterAssets: FrontMatterAssets;
readonly assets: Assets;
readonly metadata: Metadata;
readonly toc: readonly TOCItem[];
(): JSX.Element;

View file

@ -28,6 +28,7 @@
"feed": "^4.2.2",
"fs-extra": "^10.0.0",
"globby": "^11.0.2",
"js-yaml": "^4.0.0",
"loader-utils": "^2.0.0",
"lodash": "^4.17.20",
"reading-time": "^1.3.0",

View file

@ -0,0 +1,29 @@
{
"JMarcey": {
"name": "Joel Marcey",
"title": "Technical Lead & Developer Advocate at Facebook",
"url": "http://twitter.com/JoelMarcey",
"image_url": "https://github.com/JoelMarcey.png",
"twitter": "JoelMarcey"
},
"slorber": {
"name": "Sébastien Lorber",
"title": "Docusaurus maintainer",
"url": "https://sebastienlorber.com",
"image_url": "https://github.com/slorber.png",
"twitter": "sebastienlorber"
},
"yangshun": {
"name": "Yangshun Tay",
"title": "Front End Engineer at Facebook",
"url": "https://github.com/yangshun",
"image_url": "https://github.com/yangshun.png",
"twitter": "yangshunz"
},
"lex111": {
"name": "Alexey Pyltsyn",
"title": "Open-source enthusiast",
"url": "https://github.com/lex111",
"image_url": "https://github.com/lex111.png"
}
}

View file

@ -0,0 +1,27 @@
JMarcey:
name: Joel Marcey
title: Technical Lead & Developer Advocate at Facebook
url: http://twitter.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
twitter: JoelMarcey
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
twitter: sebastienlorber
yangshun:
name: Yangshun Tay
title: Front End Engineer at Facebook
url: https://github.com/yangshun
image_url: https://github.com/yangshun.png
twitter: yangshunz
lex111:
name: Alexey Pyltsyn
title: Open-source enthusiast
url: https://github.com/lex111
image_url: https://github.com/lex111.png

View file

@ -0,0 +1,5 @@
{
"slorber": {
"title": "Docusaurus maintainer"
}
}

View file

@ -0,0 +1,3 @@
slorber:
title: Docusaurus maintainer

View file

@ -0,0 +1,3 @@
{
"name": "Sébastien Lorber"
}

View file

@ -0,0 +1,2 @@
name: Sébastien Lorber

View file

@ -0,0 +1,8 @@
[
{
"name": "Sébastien Lorber"
},
{
"name": "Joel Marcey"
}
]

View file

@ -0,0 +1,3 @@
- name: Sébastien Lorber
- name: Joel Marcey

View file

@ -1,5 +1,8 @@
---
title: Happy 1st Birthday Slash!
authors:
- name: Yangshun Tay
- slorber
---
Happy birthday!

View file

@ -0,0 +1,4 @@
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer

View file

@ -2,6 +2,10 @@
slug: /simple/slug
title: Simple Slug
date: 2020-08-15
author: Sébastien Lorber
author_title: Docusaurus maintainer
author_url: https://sebastienlorber.com
---
simple url slug

View file

@ -1,5 +1,8 @@
---
title: Happy 1st Birthday Slash! (translated)
authors:
- name: Yangshun Tay (translated)
- slorber
---
Happy birthday! (translated)

View file

@ -0,0 +1,5 @@
slorber:
name: Sébastien Lorber (translated)
title: Docusaurus maintainer (translated)

View file

@ -26,6 +26,10 @@ exports[`blogFeed atom shows feed item for each post 1`] = `
<link href=\\"https://docusaurus.io/myBaseUrl/blog/simple/slug\\"/>
<updated>2020-08-15T00:00:00.000Z</updated>
<summary type=\\"html\\"><![CDATA[simple url slug]]></summary>
<author>
<name>Sébastien Lorber</name>
<uri>https://sebastienlorber.com</uri>
</author>
</entry>
<entry>
<title type=\\"html\\"><![CDATA[draft]]></title>
@ -53,6 +57,12 @@ exports[`blogFeed atom shows feed item for each post 1`] = `
<link href=\\"https://docusaurus.io/myBaseUrl/blog/2018/12/14/Happy-First-Birthday-Slash\\"/>
<updated>2018-12-14T00:00:00.000Z</updated>
<summary type=\\"html\\"><![CDATA[Happy birthday! (translated)]]></summary>
<author>
<name>Yangshun Tay (translated)</name>
</author>
<author>
<name>Sébastien Lorber (translated)</name>
</author>
</entry>
</feed>"
`;

View file

@ -0,0 +1,608 @@
/**
* 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 {
AuthorsMap,
getAuthorsMapFilePath,
validateAuthorsMapFile,
readAuthorsMapFile,
getAuthorsMap,
getBlogPostAuthors,
} from '../authors';
import path from 'path';
describe('getBlogPostAuthors', () => {
test('can read no authors', () => {
expect(
getBlogPostAuthors({
frontMatter: {},
authorsMap: undefined,
}),
).toEqual([]);
expect(
getBlogPostAuthors({
frontMatter: {
authors: [],
},
authorsMap: undefined,
}),
).toEqual([]);
});
test('can read author from legacy frontmatter', () => {
expect(
getBlogPostAuthors({
frontMatter: {
author: 'Sébastien Lorber',
},
authorsMap: undefined,
}),
).toEqual([{name: 'Sébastien Lorber'}]);
expect(
getBlogPostAuthors({
frontMatter: {
authorTitle: 'maintainer',
},
authorsMap: undefined,
}),
).toEqual([{title: 'maintainer'}]);
expect(
getBlogPostAuthors({
frontMatter: {
authorImageURL: 'https://github.com/slorber.png',
},
authorsMap: undefined,
}),
).toEqual([{imageURL: 'https://github.com/slorber.png'}]);
expect(
getBlogPostAuthors({
frontMatter: {
author: 'Sébastien Lorber',
author_title: 'maintainer1',
authorTitle: 'maintainer2',
author_image_url: 'https://github.com/slorber1.png',
authorImageURL: 'https://github.com/slorber2.png',
author_url: 'https://github.com/slorber1',
authorURL: 'https://github.com/slorber2',
},
authorsMap: undefined,
}),
).toEqual([
{
name: 'Sébastien Lorber',
title: 'maintainer1',
imageURL: 'https://github.com/slorber1.png',
url: 'https://github.com/slorber1',
},
]);
});
test('can read authors string', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: 'slorber',
},
authorsMap: {slorber: {name: 'Sébastien Lorber'}},
}),
).toEqual([{key: 'slorber', name: 'Sébastien Lorber'}]);
});
test('can read authors string[]', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: ['slorber', 'yangshun'],
},
authorsMap: {
slorber: {name: 'Sébastien Lorber', title: 'maintainer'},
yangshun: {name: 'Yangshun Tay'},
},
}),
).toEqual([
{key: 'slorber', name: 'Sébastien Lorber', title: 'maintainer'},
{key: 'yangshun', name: 'Yangshun Tay'},
]);
});
test('can read authors Author', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: {name: 'Sébastien Lorber', title: 'maintainer'},
},
authorsMap: undefined,
}),
).toEqual([{name: 'Sébastien Lorber', title: 'maintainer'}]);
});
test('can read authors Author[]', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: [
{name: 'Sébastien Lorber', title: 'maintainer'},
{name: 'Yangshun Tay'},
],
},
authorsMap: undefined,
}),
).toEqual([
{name: 'Sébastien Lorber', title: 'maintainer'},
{name: 'Yangshun Tay'},
]);
});
test('can read authors complex (string | Author)[] setup with keys and local overrides', () => {
expect(
getBlogPostAuthors({
frontMatter: {
authors: [
'slorber',
{
key: 'yangshun',
title: 'Yangshun title local override',
extra: 42,
},
{name: 'Alexey'},
],
},
authorsMap: {
slorber: {name: 'Sébastien Lorber', title: 'maintainer'},
yangshun: {name: 'Yangshun Tay', title: 'Yangshun title original'},
},
}),
).toEqual([
{key: 'slorber', name: 'Sébastien Lorber', title: 'maintainer'},
{
key: 'yangshun',
name: 'Yangshun Tay',
title: 'Yangshun title local override',
extra: 42,
},
{name: 'Alexey'},
]);
});
test('throw when using author key with no authorsMap', () => {
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: 'slorber',
},
authorsMap: undefined,
}),
).toThrowErrorMatchingInlineSnapshot(`
"Can't reference blog post authors by a key (such as 'slorber') because no authors map file could be loaded.
Please double-check your blog plugin config (in particular 'authorsMapPath'), ensure the file exists at the configured path, is not empty, and is valid!"
`);
});
test('throw when using author key with empty authorsMap', () => {
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: 'slorber',
},
authorsMap: {},
}),
).toThrowErrorMatchingInlineSnapshot(`
"Can't reference blog post authors by a key (such as 'slorber') because no authors map file could be loaded.
Please double-check your blog plugin config (in particular 'authorsMapPath'), ensure the file exists at the configured path, is not empty, and is valid!"
`);
});
test('throw when using bad author key in string', () => {
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: 'slorber',
},
authorsMap: {
yangshun: {name: 'Yangshun Tay'},
jmarcey: {name: 'Joel Marcey'},
},
}),
).toThrowErrorMatchingInlineSnapshot(`
"Blog author with key \\"slorber\\" not found in the authors map file.
Valid author keys are:
- yangshun
- jmarcey"
`);
});
test('throw when using bad author key in string[]', () => {
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: ['yangshun', 'jmarcey', 'slorber'],
},
authorsMap: {
yangshun: {name: 'Yangshun Tay'},
jmarcey: {name: 'Joel Marcey'},
},
}),
).toThrowErrorMatchingInlineSnapshot(`
"Blog author with key \\"slorber\\" not found in the authors map file.
Valid author keys are:
- yangshun
- jmarcey"
`);
});
test('throw when using bad author key in Author[].key', () => {
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: [{key: 'yangshun'}, {key: 'jmarcey'}, {key: 'slorber'}],
},
authorsMap: {
yangshun: {name: 'Yangshun Tay'},
jmarcey: {name: 'Joel Marcey'},
},
}),
).toThrowErrorMatchingInlineSnapshot(`
"Blog author with key \\"slorber\\" not found in the authors map file.
Valid author keys are:
- yangshun
- jmarcey"
`);
});
test('throw when mixing legacy/new authors frontmatter', () => {
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: [{name: 'Sébastien Lorber'}],
author: 'Yangshun Tay',
},
authorsMap: undefined,
}),
).toThrowErrorMatchingInlineSnapshot(`
"To declare blog post authors, use the 'authors' FrontMatter in priority.
Don't mix 'authors' with other existing 'author_*' FrontMatter. Choose one or the other, not both at the same time."
`);
expect(() =>
getBlogPostAuthors({
frontMatter: {
authors: [{key: 'slorber'}],
author_title: 'legacy title',
},
authorsMap: {slorber: {name: 'Sébastien Lorber'}},
}),
).toThrowErrorMatchingInlineSnapshot(`
"To declare blog post authors, use the 'authors' FrontMatter in priority.
Don't mix 'authors' with other existing 'author_*' FrontMatter. Choose one or the other, not both at the same time."
`);
});
});
describe('readAuthorsMapFile', () => {
const fixturesDir = path.join(__dirname, '__fixtures__/authorsMapFiles');
test('read valid yml author file', async () => {
const filePath = path.join(fixturesDir, 'authors.yml');
expect(await readAuthorsMapFile(filePath)).toBeDefined();
});
test('read valid json author file', async () => {
const filePath = path.join(fixturesDir, 'authors.json');
expect(await readAuthorsMapFile(filePath)).toBeDefined();
});
test('read yml and json should lead to the same result', async () => {
const content1 = await readAuthorsMapFile(
path.join(fixturesDir, 'authors.yml'),
);
const content2 = await readAuthorsMapFile(
path.join(fixturesDir, 'authors.json'),
);
expect(content1).toEqual(content2);
});
test('fail to read invalid yml 1', async () => {
const filePath = path.join(fixturesDir, 'authorsBad1.yml');
await expect(
readAuthorsMapFile(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"slorber.name\\" is required"`,
);
});
test('fail to read invalid json 1', async () => {
const filePath = path.join(fixturesDir, 'authorsBad1.json');
await expect(
readAuthorsMapFile(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"slorber.name\\" is required"`,
);
});
test('fail to read invalid yml 2', async () => {
const filePath = path.join(fixturesDir, 'authorsBad2.yml');
await expect(
readAuthorsMapFile(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"name\\" must be of type object"`,
);
});
test('fail to read invalid json 2', async () => {
const filePath = path.join(fixturesDir, 'authorsBad2.json');
await expect(
readAuthorsMapFile(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"name\\" must be of type object"`,
);
});
test('fail to read invalid yml 3', async () => {
const filePath = path.join(fixturesDir, 'authorsBad3.yml');
await expect(
readAuthorsMapFile(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"value\\" must be of type object"`,
);
});
test('fail to read invalid json 3', async () => {
const filePath = path.join(fixturesDir, 'authorsBad3.json');
await expect(
readAuthorsMapFile(filePath),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"value\\" must be of type object"`,
);
});
});
describe('getAuthorsMap', () => {
const fixturesDir = path.join(__dirname, '__fixtures__/authorsMapFiles');
const contentPaths = {
contentPathLocalized: fixturesDir,
contentPath: fixturesDir,
};
test('getAuthorsMap can read yml file', async () => {
expect(
await getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.yml',
}),
).toBeDefined();
});
test('getAuthorsMap can read json file', async () => {
expect(
await getAuthorsMap({
contentPaths,
authorsMapPath: 'authors.json',
}),
).toBeDefined();
});
test('getAuthorsMap can return undefined if yaml file not found', async () => {
expect(
await getAuthorsMap({
contentPaths,
authorsMapPath: 'authors_does_not_exist.yml',
}),
).toBeUndefined();
});
});
describe('validateAuthorsMapFile', () => {
test('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(validateAuthorsMapFile(authorsMap)).toEqual(authorsMap);
});
test('rename snake case image_url to camelCase imageURL', () => {
const authorsMap: AuthorsMap = {
slorber: {
name: 'Sébastien Lorber',
image_url: 'https://github.com/slorber.png',
},
};
expect(validateAuthorsMapFile(authorsMap)).toEqual({
slorber: {
name: 'Sébastien Lorber',
imageURL: 'https://github.com/slorber.png',
},
});
});
test('reject author without name', () => {
const authorsMap: AuthorsMap = {
slorber: {
image_url: 'https://github.com/slorber.png',
},
};
expect(() =>
validateAuthorsMapFile(authorsMap),
).toThrowErrorMatchingInlineSnapshot(`"\\"slorber.name\\" is required"`);
});
test('reject undefined author', () => {
expect(() =>
validateAuthorsMapFile({
slorber: undefined,
}),
).toThrowErrorMatchingInlineSnapshot(`"\\"slorber\\" is required"`);
});
test('reject null author', () => {
expect(() =>
validateAuthorsMapFile({
slorber: null,
}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"slorber\\" must be of type object"`,
);
});
test('reject array author', () => {
expect(() =>
validateAuthorsMapFile({slorber: []}),
).toThrowErrorMatchingInlineSnapshot(
`"\\"slorber\\" must be of type object"`,
);
});
test('reject array content', () => {
expect(() => validateAuthorsMapFile([])).toThrowErrorMatchingInlineSnapshot(
// TODO improve this error message
`"\\"value\\" must be of type object"`,
);
});
test('reject flat author', () => {
expect(() =>
validateAuthorsMapFile({name: 'Sébastien'}),
).toThrowErrorMatchingInlineSnapshot(
// TODO improve this error message
`"\\"name\\" must be of type object"`,
);
});
test('reject non-map author', () => {
const authorsMap: AuthorsMap = {
// @ts-expect-error: for tests
slorber: [],
};
expect(() =>
validateAuthorsMapFile(authorsMap),
).toThrowErrorMatchingInlineSnapshot(
`"\\"slorber\\" must be of type object"`,
);
});
});
describe('getAuthorsMapFilePath', () => {
const fixturesDir = path.join(
__dirname,
'__fixtures__/getAuthorsMapFilePath',
);
const contentPathYml1 = path.join(fixturesDir, 'contentPathYml1');
const contentPathYml2 = path.join(fixturesDir, 'contentPathYml2');
const contentPathJson1 = path.join(fixturesDir, 'contentPathJson1');
const contentPathJson2 = path.join(fixturesDir, 'contentPathJson2');
const contentPathEmpty = path.join(fixturesDir, 'contentPathEmpty');
const contentPathNestedYml = path.join(fixturesDir, 'contentPathNestedYml');
test('getAuthorsMapFilePath returns localized Yml path in priority', async () => {
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.yml',
contentPaths: {
contentPathLocalized: contentPathYml1,
contentPath: contentPathYml2,
},
}),
).toEqual(path.join(contentPathYml1, 'authors.yml'));
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.yml',
contentPaths: {
contentPathLocalized: contentPathYml2,
contentPath: contentPathYml1,
},
}),
).toEqual(path.join(contentPathYml2, 'authors.yml'));
});
test('getAuthorsMapFilePath returns localized Json path in priority', async () => {
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.json',
contentPaths: {
contentPathLocalized: contentPathJson1,
contentPath: contentPathJson2,
},
}),
).toEqual(path.join(contentPathJson1, 'authors.json'));
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.json',
contentPaths: {
contentPathLocalized: contentPathJson2,
contentPath: contentPathJson1,
},
}),
).toEqual(path.join(contentPathJson2, 'authors.json'));
});
test('getAuthorsMapFilePath returns unlocalized Yml path as fallback', async () => {
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.yml',
contentPaths: {
contentPathLocalized: contentPathEmpty,
contentPath: contentPathYml2,
},
}),
).toEqual(path.join(contentPathYml2, 'authors.yml'));
});
test('getAuthorsMapFilePath returns unlocalized Json path as fallback', async () => {
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.json',
contentPaths: {
contentPathLocalized: contentPathEmpty,
contentPath: contentPathJson1,
},
}),
).toEqual(path.join(contentPathJson1, 'authors.json'));
});
test('getAuthorsMapFilePath can return undefined (file not found)', async () => {
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.json',
contentPaths: {
contentPathLocalized: contentPathEmpty,
contentPath: contentPathYml1,
},
}),
).toBeUndefined();
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'authors.yml',
contentPaths: {
contentPathLocalized: contentPathEmpty,
contentPath: contentPathJson1,
},
}),
).toBeUndefined();
});
test('getAuthorsMapFilePath can return nested path', async () => {
expect(
await getAuthorsMapFilePath({
authorsMapPath: 'sub/folder/authors.yml',
contentPaths: {
contentPathLocalized: contentPathEmpty,
contentPath: contentPathNestedYml,
},
}),
).toEqual(path.join(contentPathNestedYml, 'sub/folder/authors.yml'));
});
});

View file

@ -11,6 +11,8 @@ import {
} from '../blogFrontMatter';
import escapeStringRegexp from 'escape-string-regexp';
// TODO this abstraction reduce verbosity but it makes it harder to debug
// It would be preferable to just expose helper methods
function testField(params: {
fieldName: keyof BlogPostFrontMatter;
validFrontMatters: BlogPostFrontMatter[];
@ -99,7 +101,30 @@ describe('validateBlogPostFrontMatter id', () => {
testField({
fieldName: 'id',
validFrontMatters: [{id: '123'}, {id: 'id'}],
invalidFrontMatters: [[{id: ''}, 'is not allowed to be empty']],
invalidFrontMatters: [[{id: ''}, 'not allowed to be empty']],
});
});
describe('validateBlogPostFrontMatter handles legacy/new author frontmatter', () => {
test('allow legacy author frontmatter', () => {
const frontMatter: BlogPostFrontMatter = {
author: 'Sebastien',
author_url: 'https://sebastienlorber.com',
author_title: 'maintainer',
author_image_url: 'https://github.com/slorber.png',
};
expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter);
});
test('allow new authors frontmatter', () => {
const frontMatter: BlogPostFrontMatter = {
authors: [
'slorber',
{name: 'Yangshun'},
{key: 'JMarcey', title: 'creator', random: '42'},
],
};
expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter);
});
});
@ -107,21 +132,24 @@ describe('validateBlogPostFrontMatter author', () => {
testField({
fieldName: 'author',
validFrontMatters: [{author: '123'}, {author: 'author'}],
invalidFrontMatters: [[{author: ''}, 'is not allowed to be empty']],
invalidFrontMatters: [[{author: ''}, 'not allowed to be empty']],
});
});
describe('validateBlogPostFrontMatter author_title', () => {
testField({
fieldName: 'author_title',
validFrontMatters: [{author_title: '123'}, {author_title: 'author_title'}],
invalidFrontMatters: [[{author_title: ''}, 'is not allowed to be empty']],
validFrontMatters: [
{author: '123', author_title: '123'},
{author: '123', author_title: 'author_title'},
],
invalidFrontMatters: [[{author_title: ''}, 'not allowed to be empty']],
});
testField({
fieldName: 'authorTitle',
validFrontMatters: [{authorTitle: '123'}, {authorTitle: 'authorTitle'}],
invalidFrontMatters: [[{authorTitle: ''}, 'is not allowed to be empty']],
invalidFrontMatters: [[{authorTitle: ''}, 'not allowed to be empty']],
});
});
@ -136,7 +164,7 @@ describe('validateBlogPostFrontMatter author_url', () => {
invalidFrontMatters: [
[
{author_url: ''},
'"author_url" does not match any of the allowed types',
'"author_url" does not look like a valid url (value=\'\')',
],
],
});
@ -150,7 +178,10 @@ describe('validateBlogPostFrontMatter author_url', () => {
],
invalidFrontMatters: [
[{authorURL: ''}, '"authorURL" does not match any of the allowed types'],
[
{authorURL: ''},
'"authorURL" does not look like a valid url (value=\'\')',
],
],
});
});
@ -166,7 +197,7 @@ describe('validateBlogPostFrontMatter author_image_url', () => {
invalidFrontMatters: [
[
{author_image_url: ''},
'"author_image_url" does not match any of the allowed types',
'"author_image_url" does not look like a valid url (value=\'\')',
],
],
});
@ -181,7 +212,55 @@ describe('validateBlogPostFrontMatter author_image_url', () => {
invalidFrontMatters: [
[
{authorImageURL: ''},
'"authorImageURL" does not match any of the allowed types',
'"authorImageURL" does not look like a valid url (value=\'\')',
],
],
});
});
describe('validateBlogPostFrontMatter authors', () => {
testField({
fieldName: 'author',
validFrontMatters: [
{authors: []},
{authors: 'authorKey'},
{authors: ['authorKey1', 'authorKey2']},
{
authors: {
name: 'Author Name',
imageURL: '/absolute',
},
},
{
authors: {
key: 'authorKey',
title: 'Author title',
},
},
{
authors: [
'authorKey1',
{key: 'authorKey3'},
'authorKey3',
{name: 'Author Name 4'},
{key: 'authorKey5'},
],
},
],
invalidFrontMatters: [
[{authors: ''}, '"authors" is not allowed to be empty'],
[
{authors: [undefined]},
'"authors[0]" does not look like a valid blog post author. Please use an author key or an author object (with a key and/or name).',
],
[
{authors: [null]},
'"authors[0]" does not look like a valid blog post author. Please use an author key or an author object (with a key and/or name).',
],
[
{authors: [{}]},
'"authors[0]" does not look like a valid blog post author. Please use an author key or an author object (with a key and/or name).',
],
],
});
@ -200,7 +279,7 @@ describe('validateBlogPostFrontMatter slug', () => {
{slug: '/api/plugins/@docusaurus/plugin-debug'},
{slug: '@site/api/asset/image.png'},
],
invalidFrontMatters: [[{slug: ''}, 'is not allowed to be empty']],
invalidFrontMatters: [[{slug: ''}, 'not allowed to be empty']],
});
});
@ -219,7 +298,7 @@ describe('validateBlogPostFrontMatter image', () => {
{image: '@site/api/asset/image.png'},
],
invalidFrontMatters: [
[{image: ''}, '"image" does not match any of the allowed types'],
[{image: ''}, '"image" does not look like a valid url (value=\'\')'],
],
});
});
@ -236,7 +315,7 @@ describe('validateBlogPostFrontMatter tags', () => {
],
invalidFrontMatters: [
[{tags: ''}, 'must be an array'],
[{tags: ['']}, 'is not allowed to be empty'],
[{tags: ['']}, 'not allowed to be empty'],
],
// See https://github.com/facebook/docusaurus/issues/4642
convertibleFrontMatter: [
@ -260,7 +339,7 @@ describe('validateBlogPostFrontMatter keywords', () => {
],
invalidFrontMatters: [
[{keywords: ''}, 'must be an array'],
[{keywords: ['']}, 'is not allowed to be empty'],
[{keywords: ['']}, 'not allowed to be empty'],
[{keywords: []}, 'does not contain 1 required value(s)'],
],
});
@ -304,9 +383,7 @@ describe('validateBlogPostFrontMatter date', () => {
fieldName: 'date',
validFrontMatters: [
{date: new Date('2020-01-01')},
// @ts-expect-error: string for test
{date: '2020-01-01'},
// @ts-expect-error: string for test
{date: '2020'},
],
invalidFrontMatters: [

View file

@ -52,6 +52,7 @@ describe('blogFeed', () => {
{
path: 'invalid-blog-path',
routeBasePath: 'blog',
authorsMapPath: 'authors.yml',
include: ['*.md', '*.mdx'],
feedOptions: {
type: [feedType],
@ -85,6 +86,7 @@ describe('blogFeed', () => {
{
path: 'blog',
routeBasePath: 'blog',
authorsMapPath: 'authors.yml',
include: DEFAULT_OPTIONS.include,
exclude: DEFAULT_OPTIONS.exclude,
feedOptions: {

View file

@ -14,6 +14,7 @@ import {DocusaurusConfig, LoadContext, I18n} from '@docusaurus/types';
import {PluginOptionSchema} from '../pluginOptionSchema';
import {PluginOptions, EditUrlFunction, BlogPost} from '../types';
import {Joi} from '@docusaurus/utils-validation';
import {posixPath} from '@docusaurus/utils';
function findByTitle(
blogPosts: BlogPost[],
@ -60,7 +61,7 @@ describe('loadBlog', () => {
const BaseEditUrl = 'https://baseEditUrl.com/edit';
const getBlogPosts = async (
const getPlugin = async (
siteDir: string,
pluginOptions: Partial<PluginOptions> = {},
i18n: I18n = DefaultI18N,
@ -71,7 +72,7 @@ describe('loadBlog', () => {
baseUrl: '/',
url: 'https://docusaurus.io',
} as DocusaurusConfig;
const plugin = pluginContentBlog(
return pluginContentBlog(
{
siteDir,
siteConfig,
@ -84,11 +85,32 @@ describe('loadBlog', () => {
...pluginOptions,
}),
);
const {blogPosts} = (await plugin.loadContent!())!;
};
const getBlogPosts = async (
siteDir: string,
pluginOptions: Partial<PluginOptions> = {},
i18n: I18n = DefaultI18N,
) => {
const plugin = await getPlugin(siteDir, pluginOptions, i18n);
const {blogPosts} = (await plugin.loadContent!())!;
return blogPosts;
};
test('getPathsToWatch', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const plugin = await getPlugin(siteDir);
const pathsToWatch = plugin.getPathsToWatch!();
const relativePathsToWatch = pathsToWatch.map((p) =>
posixPath(path.relative(siteDir, p)),
);
expect(relativePathsToWatch).toEqual([
'blog/authors.yml',
'i18n/en/docusaurus-plugin-content-blog/**/*.{md,mdx}',
'blog/**/*.{md,mdx}',
]);
});
test('simple website', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const blogPosts = await getBlogPosts(siteDir);
@ -103,6 +125,7 @@ describe('loadBlog', () => {
source: path.posix.join('@site', PluginPath, 'date-matter.md'),
title: 'date-matter',
description: `date inside front matter`,
authors: [],
date: new Date('2019-01-01'),
formattedDate: 'January 1, 2019',
prevItem: undefined,
@ -128,6 +151,16 @@ describe('loadBlog', () => {
),
title: 'Happy 1st Birthday Slash! (translated)',
description: `Happy birthday! (translated)`,
authors: [
{
name: 'Yangshun Tay (translated)',
},
{
key: 'slorber',
name: 'Sébastien Lorber (translated)',
title: 'Docusaurus maintainer (translated)',
},
],
date: new Date('2018-12-14'),
formattedDate: 'December 14, 2018',
tags: [],
@ -148,6 +181,7 @@ describe('loadBlog', () => {
source: path.posix.join('@site', PluginPath, 'complex-slug.md'),
title: 'Complex Slug',
description: `complex url slug`,
authors: [],
prevItem: undefined,
nextItem: {
permalink: '/blog/simple/slug',
@ -169,6 +203,14 @@ describe('loadBlog', () => {
source: path.posix.join('@site', PluginPath, 'simple-slug.md'),
title: 'Simple Slug',
description: `simple url slug`,
authors: [
{
name: 'Sébastien Lorber',
title: 'Docusaurus maintainer',
url: 'https://sebastienlorber.com',
imageURL: undefined,
},
],
prevItem: undefined,
nextItem: {
permalink: '/blog/draft',
@ -190,6 +232,7 @@ describe('loadBlog', () => {
source: path.posix.join('@site', PluginPath, 'heading-as-title.md'),
title: 'some heading',
description: '',
authors: [],
date: new Date('2019-01-02'),
formattedDate: 'January 2, 2019',
prevItem: undefined,
@ -325,6 +368,7 @@ describe('loadBlog', () => {
source: noDateSource,
title: 'no date',
description: `no date`,
authors: [],
date: noDateSourceBirthTime,
formattedDate,
tags: [],

View file

@ -0,0 +1,202 @@
/**
* 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 fs from 'fs-extra';
import chalk from 'chalk';
import path from 'path';
import {Author, BlogContentPaths} from './types';
import {findFolderContainingFile} from '@docusaurus/utils';
import {Joi, URISchema} from '@docusaurus/utils-validation';
import {
BlogPostFrontMatter,
BlogPostFrontMatterAuthor,
BlogPostFrontMatterAuthors,
} from './blogFrontMatter';
import {getContentPathList} from './blogUtils';
import Yaml from 'js-yaml';
export type AuthorsMap = Record<string, Author>;
const AuthorsMapSchema = Joi.object<AuthorsMap>().pattern(
Joi.string(),
Joi.object({
name: Joi.string().required(),
url: URISchema,
imageURL: URISchema,
title: Joi.string(),
})
.rename('image_url', 'imageURL')
.unknown()
.required(),
);
export function validateAuthorsMapFile(content: unknown): AuthorsMap {
return Joi.attempt(content, AuthorsMapSchema);
}
export async function readAuthorsMapFile(
filePath: string,
): Promise<AuthorsMap | undefined> {
if (await fs.pathExists(filePath)) {
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
const parse =
filePath.endsWith('.yml') || filePath.endsWith('.yaml')
? Yaml.load
: JSON.parse;
try {
const unsafeContent = parse(contentString);
return validateAuthorsMapFile(unsafeContent);
} catch (e) {
// TODO replace later by error cause: see https://v8.dev/features/error-cause
console.error(chalk.red('The author list file looks invalid!'));
throw e;
}
}
return undefined;
}
type AuthorsMapParams = {
authorsMapPath: string;
contentPaths: BlogContentPaths;
};
export async function getAuthorsMapFilePath({
authorsMapPath,
contentPaths,
}: AuthorsMapParams): Promise<string | undefined> {
// Useful to load an eventually localize authors map
const contentPath = await findFolderContainingFile(
getContentPathList(contentPaths),
authorsMapPath,
);
if (contentPath) {
return path.join(contentPath, authorsMapPath);
}
return undefined;
}
export async function getAuthorsMap(
params: AuthorsMapParams,
): Promise<AuthorsMap | undefined> {
const filePath = await getAuthorsMapFilePath(params);
if (!filePath) {
return undefined;
}
try {
return await readAuthorsMapFile(filePath);
} catch (e) {
// TODO replace later by error cause, see https://v8.dev/features/error-cause
console.error(
chalk.red(`Couldn't read blog authors map at path ${filePath}`),
);
throw e;
}
}
type AuthorsParam = {
frontMatter: BlogPostFrontMatter;
authorsMap: AuthorsMap | undefined;
};
// Legacy v1/early-v2 frontmatter fields
// We may want to deprecate those in favor of using only frontMatter.authors
function getFrontMatterAuthorLegacy(
frontMatter: BlogPostFrontMatter,
): BlogPostFrontMatterAuthor | undefined {
const name = frontMatter.author;
const title = frontMatter.author_title ?? frontMatter.authorTitle;
const url = frontMatter.author_url ?? frontMatter.authorURL;
const imageURL = frontMatter.author_image_url ?? frontMatter.authorImageURL;
// Shouldn't we require at least an author name?
if (name || title || url || imageURL) {
return {
name,
title,
url,
imageURL,
};
}
return undefined;
}
function normalizeFrontMatterAuthors(
frontMatterAuthors: BlogPostFrontMatterAuthors = [],
): BlogPostFrontMatterAuthor[] {
function normalizeAuthor(
authorInput: string | BlogPostFrontMatterAuthor,
): BlogPostFrontMatterAuthor {
if (typeof authorInput === 'string') {
// Technically, we could allow users to provide an author's name here
// IMHO it's better to only support keys here
// Reason: a typo in a key would fallback to becoming a name and may end-up un-noticed
return {key: authorInput};
}
return authorInput;
}
return Array.isArray(frontMatterAuthors)
? frontMatterAuthors.map(normalizeAuthor)
: [normalizeAuthor(frontMatterAuthors)];
}
function getFrontMatterAuthors(params: AuthorsParam): Author[] {
const {authorsMap} = params;
const frontMatterAuthors = normalizeFrontMatterAuthors(
params.frontMatter.authors,
);
function getAuthorsMapAuthor(key: string | undefined): Author | undefined {
if (key) {
if (!authorsMap || Object.keys(authorsMap).length === 0) {
throw new Error(`Can't reference blog post authors by a key (such as '${key}') because no authors map file could be loaded.
Please double-check your blog plugin config (in particular 'authorsMapPath'), ensure the file exists at the configured path, is not empty, and is valid!`);
}
const author = authorsMap[key];
if (!author) {
throw Error(`Blog author with key "${key}" not found in the authors map file.
Valid author keys are:
${Object.keys(authorsMap)
.map((validKey) => `- ${validKey}`)
.join('\n')}`);
}
return author;
}
return undefined;
}
function toAuthor(frontMatterAuthor: BlogPostFrontMatterAuthor): Author {
return {
// Author def from authorsMap can be locally overridden by frontmatter
...getAuthorsMapAuthor(frontMatterAuthor.key),
...frontMatterAuthor,
};
}
return frontMatterAuthors.map(toAuthor);
}
export function getBlogPostAuthors(params: AuthorsParam): Author[] {
const authorLegacy = getFrontMatterAuthorLegacy(params.frontMatter);
const authors = getFrontMatterAuthors(params);
if (authorLegacy) {
// Technically, we could allow mixing legacy/authors frontmatter, but do we really want to?
if (authors.length > 0) {
throw new Error(
`To declare blog post authors, use the 'authors' FrontMatter in priority.
Don't mix 'authors' with other existing 'author_*' FrontMatter. Choose one or the other, not both at the same time.`,
);
}
return [authorLegacy];
}
return authors;
}

View file

@ -13,6 +13,30 @@ import {
} from '@docusaurus/utils-validation';
import type {FrontMatterTag} from '@docusaurus/utils';
export type BlogPostFrontMatterAuthor = Record<string, unknown> & {
key?: string;
name?: string;
imageURL?: string;
url?: string;
title?: string;
};
// All the possible variants that the user can use for convenience
export type BlogPostFrontMatterAuthors =
| string
| BlogPostFrontMatterAuthor
| (string | BlogPostFrontMatterAuthor)[];
const BlogPostFrontMatterAuthorSchema = Joi.object({
key: Joi.string(),
name: Joi.string(),
title: Joi.string(),
url: URISchema,
imageURL: Joi.string(),
})
.or('key', 'name')
.rename('image_url', 'imageURL', {alias: true});
export type BlogPostFrontMatter = {
/* eslint-disable camelcase */
id?: string;
@ -23,22 +47,30 @@ export type BlogPostFrontMatter = {
draft?: boolean;
date?: Date | string; // Yaml automagically convert some string patterns as Date, but not all
authors?: BlogPostFrontMatterAuthors;
// We may want to deprecate those older author frontmatter fields later:
author?: string;
author_title?: string;
author_url?: string;
author_image_url?: string;
/** @deprecated */
authorTitle?: string;
/** @deprecated */
authorURL?: string;
/** @deprecated */
authorImageURL?: string;
image?: string;
keywords?: string[];
hide_table_of_contents?: boolean;
/** @deprecated */
authorTitle?: string;
authorURL?: string;
authorImageURL?: string;
/* eslint-enable camelcase */
};
const FrontMatterAuthorErrorMessage =
'{{#label}} does not look like a valid blog post author. Please use an author key or an author object (with a key and/or name).';
const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
id: Joi.string(),
title: Joi.string().allow(''),
@ -47,28 +79,42 @@ const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
draft: Joi.boolean(),
date: Joi.date().raw(),
// New multi-authors frontmatter:
authors: Joi.alternatives()
.try(
Joi.string(),
BlogPostFrontMatterAuthorSchema,
Joi.array()
.items(Joi.string(), BlogPostFrontMatterAuthorSchema)
.messages({
'array.sparse': FrontMatterAuthorErrorMessage,
'array.includes': FrontMatterAuthorErrorMessage,
}),
)
.messages({
'alternatives.match': FrontMatterAuthorErrorMessage,
}),
// Legacy author frontmatter
author: Joi.string(),
author_title: Joi.string(),
author_url: URISchema,
author_image_url: URISchema,
slug: Joi.string(),
image: URISchema,
keywords: Joi.array().items(Joi.string().required()),
hide_table_of_contents: Joi.boolean(),
// TODO re-enable warnings later, our v1 blog posts use those older frontmatter fields
// TODO enable deprecation warnings later
authorURL: URISchema,
// .warning('deprecate.error', { alternative: '"author_url"'}),
authorTitle: Joi.string(),
// .warning('deprecate.error', { alternative: '"author_title"'}),
authorImageURL: URISchema,
// .warning('deprecate.error', { alternative: '"author_image_url"'}),
})
.unknown()
.messages({
'deprecate.error':
'{#label} blog frontMatter field is deprecated. Please use {#alternative} instead.',
});
slug: Joi.string(),
image: URISchema,
keywords: Joi.array().items(Joi.string().required()),
hide_table_of_contents: Joi.boolean(),
}).messages({
'deprecate.error':
'{#label} blog frontMatter field is deprecated. Please use {#alternative} instead.',
});
export function validateBlogPostFrontMatter(
frontMatter: Record<string, unknown>,

View file

@ -9,7 +9,7 @@ import fs from 'fs-extra';
import chalk from 'chalk';
import path from 'path';
import readingTime from 'reading-time';
import {Feed} from 'feed';
import {Feed, Author as FeedAuthor} from 'feed';
import {compact, keyBy, mapValues} from 'lodash';
import {
PluginOptions,
@ -17,6 +17,7 @@ import {
BlogContentPaths,
BlogMarkdownLoaderOptions,
BlogTags,
Author,
} from './types';
import {
parseMarkdownFile,
@ -32,6 +33,7 @@ import {
} from '@docusaurus/utils';
import {LoadContext} from '@docusaurus/types';
import {validateBlogPostFrontMatter} from './blogFrontMatter';
import {AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
export function truncate(fileString: string, truncateMarker: RegExp): string {
return fileString.split(truncateMarker, 1).shift()!;
@ -135,10 +137,16 @@ export async function generateBlogFeed(
copyright: feedOptions.copyright,
});
function toFeedAuthor(author: Author): FeedAuthor {
// TODO ask author emails?
// RSS feed requires email to render authors
return {name: author.name, link: author.url};
}
blogPosts.forEach((post) => {
const {
id,
metadata: {title: metadataTitle, permalink, date, description},
metadata: {title: metadataTitle, permalink, date, description, authors},
} = post;
feed.addItem({
title: metadataTitle,
@ -146,6 +154,7 @@ export async function generateBlogFeed(
link: normalizeUrl([siteUrl, permalink]),
date,
description,
author: authors.map(toFeedAuthor),
});
});
@ -167,6 +176,7 @@ async function processBlogSourceFile(
contentPaths: BlogContentPaths,
context: LoadContext,
options: PluginOptions,
authorsMap?: AuthorsMap,
): Promise<BlogPost | undefined> {
const {
siteConfig: {baseUrl},
@ -258,6 +268,7 @@ async function processBlogSourceFile(
}
const tagsBasePath = normalizeUrl([baseUrl, options.routeBasePath, 'tags']); // make this configurable?
const authors = getBlogPostAuthors({authorsMap, frontMatter});
return {
id: frontMatter.slug ?? title,
@ -272,6 +283,7 @@ async function processBlogSourceFile(
tags: normalizeFrontMatterTags(tagsBasePath, frontMatter.tags),
readingTime: showReadingTime ? readingTime(content).minutes : undefined,
truncated: truncateMarker?.test(content) || false,
authors,
},
};
}
@ -292,6 +304,11 @@ export async function generateBlogPosts(
ignore: exclude,
});
const authorsMap = await getAuthorsMap({
contentPaths,
authorsMapPath: options.authorsMapPath,
});
const blogPosts: BlogPost[] = compact(
await Promise.all(
blogSourceFiles.map(async (blogSourceFile: string) => {
@ -301,6 +318,7 @@ export async function generateBlogPosts(
contentPaths,
context,
options,
authorsMap,
);
} catch (e) {
console.error(

View file

@ -32,9 +32,10 @@ import {
BlogItemsToMetadata,
TagsModule,
BlogPaginated,
BlogPost,
BlogContentPaths,
BlogMarkdownLoaderOptions,
MetaData,
Assets,
} from './types';
import {PluginOptionSchema} from './pluginOptionSchema';
import {
@ -54,6 +55,7 @@ import {
getSourceToPermalink,
getBlogTags,
} from './blogUtils';
import {BlogPostFrontMatter} from './blogFrontMatter';
export default function pluginContentBlog(
context: LoadContext,
@ -95,12 +97,22 @@ export default function pluginContentBlog(
name: 'docusaurus-plugin-content-blog',
getPathsToWatch() {
const {include = []} = options;
return flatten(
const {include, authorsMapPath} = options;
const contentMarkdownGlobs = flatten(
getContentPathList(contentPaths).map((contentPath) => {
return include.map((pattern) => `${contentPath}/${pattern}`);
}),
);
// TODO: we should read this path in plugin! but plugins do not support async init for now :'(
// const authorsMapFilePath = await getAuthorsMapFilePath({authorsMapPath,contentPaths,});
// simplified impl, better than nothing for now:
const authorsMapFilePath = path.join(
contentPaths.contentPath,
authorsMapPath,
);
return [authorsMapFilePath, ...contentMarkdownGlobs];
},
async getTranslationFiles() {
@ -117,11 +129,7 @@ export default function pluginContentBlog(
blogSidebarTitle,
} = options;
const blogPosts: BlogPost[] = await generateBlogPosts(
contentPaths,
context,
options,
);
const blogPosts = await generateBlogPosts(contentPaths, context, options);
if (!blogPosts.length) {
return {
@ -454,12 +462,22 @@ export default function pluginContentBlog(
// For blog posts a title in markdown is always removed
// Blog posts title are rendered separately
removeContentTitle: true,
// those frontMatter fields will be exported as "frontMatterAssets" and eventually be converted to require() calls for relative file paths
frontMatterAssetKeys: [
'image',
'authorImageURL',
'author_image_URL',
],
// Assets allow to convert some relative images paths to require() calls
createAssets: ({
frontMatter,
metadata,
}: {
frontMatter: BlogPostFrontMatter;
metadata: MetaData;
}): Assets => {
return {
image: frontMatter.image,
authorsImageUrls: metadata.authors.map(
(author) => author.imageURL,
),
};
},
},
},
{

View file

@ -38,6 +38,7 @@ export const DEFAULT_OPTIONS: PluginOptions = {
routeBasePath: 'blog',
path: 'blog',
editLocalizedFiles: false,
authorsMapPath: 'authors.yml',
};
export const PluginOptionSchema = Joi.object<PluginOptions>({
@ -107,4 +108,5 @@ export const PluginOptionSchema = Joi.object<PluginOptions>({
}),
language: Joi.string(),
}).default(DEFAULT_OPTIONS.feedOptions),
authorsMapPath: Joi.string().default(DEFAULT_OPTIONS.authorsMapPath),
});

View file

@ -58,6 +58,7 @@ export interface PluginOptions extends RemarkAndRehypePluginOptions {
editUrl?: string | EditUrlFunction;
editLocalizedFiles?: boolean;
admonitions: Record<string, unknown>;
authorsMapPath: string;
}
export interface BlogTags {
@ -92,6 +93,14 @@ export interface BlogPaginated {
items: string[];
}
// We allow passing custom fields to authors, e.g., twitter
export interface Author extends Record<string, unknown> {
name?: string;
imageURL?: string;
url?: string;
title?: string;
}
export interface MetaData {
permalink: string;
source: string;
@ -105,6 +114,12 @@ export interface MetaData {
nextItem?: Paginator;
truncated: boolean;
editUrl?: string;
authors: Author[];
}
export interface Assets {
image?: string;
authorsImageUrls: (string | undefined)[]; // Array of same size as the original MetaData.authors array
}
export interface Paginator {