mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-19 20:17:06 +02:00
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:
parent
8779c8ff4a
commit
493225a3c6
79 changed files with 1871 additions and 285 deletions
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"slorber": {
|
||||
"title": "Docusaurus maintainer"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
slorber:
|
||||
title: Docusaurus maintainer
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "Sébastien Lorber"
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
name: Sébastien Lorber
|
|
@ -0,0 +1,8 @@
|
|||
[
|
||||
{
|
||||
"name": "Sébastien Lorber"
|
||||
},
|
||||
{
|
||||
"name": "Joel Marcey"
|
||||
}
|
||||
]
|
|
@ -0,0 +1,3 @@
|
|||
|
||||
- name: Sébastien Lorber
|
||||
- name: Joel Marcey
|
|
@ -1,5 +1,8 @@
|
|||
---
|
||||
title: Happy 1st Birthday Slash!
|
||||
authors:
|
||||
- name: Yangshun Tay
|
||||
- slorber
|
||||
---
|
||||
|
||||
Happy birthday!
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
slorber:
|
||||
name: Sébastien Lorber
|
||||
title: Docusaurus maintainer
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
---
|
||||
title: Happy 1st Birthday Slash! (translated)
|
||||
authors:
|
||||
- name: Yangshun Tay (translated)
|
||||
- slorber
|
||||
---
|
||||
|
||||
Happy birthday! (translated)
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
|
||||
slorber:
|
||||
name: Sébastien Lorber (translated)
|
||||
title: Docusaurus maintainer (translated)
|
|
@ -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>"
|
||||
`;
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
|
@ -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: [
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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: [],
|
||||
|
|
202
packages/docusaurus-plugin-content-blog/src/authors.ts
Normal file
202
packages/docusaurus-plugin-content-blog/src/authors.ts
Normal 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;
|
||||
}
|
|
@ -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>,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue