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

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ coverage
.cache-loader
types
test-website
test-website-in-workspace
packages/docusaurus/lib/
packages/docusaurus-*/lib/*

View file

@ -3,6 +3,7 @@
"workspaces": [
"packages/*",
"website",
"test-website-in-workspace",
"packages/docusaurus-init/templates/*",
"admin/new.docusaurus.io"
],

View file

@ -5,3 +5,27 @@ Create Docusaurus apps easily.
## Usage
Please see the [installation documentation](https://docusaurus.io/docs/installation).
## For maintainers
For Docusaurus maintainers, templates can be tested with:
```bash
cd `git rev-parse --show-toplevel` # Back to repo root
rm -rf test-website
yarn docusaurus-init init test-website classic
cd test-website
yarn start
```
Note: `test-website` is not part of the workspace and use packages from npm.
Use the following to test the templates against local packages:
```bash
cd `git rev-parse --show-toplevel` # Back to repo root
rm -rf test-website-in-workspace
yarn docusaurus-init init test-website-in-workspace classic
cd test-website-in-workspace
yarn start
```

View file

@ -1,10 +1,11 @@
---
slug: hola
title: Hola
author: Gao Wei
author_title: Docusaurus Core Team
author_url: https://github.com/wgao19
author_image_url: https://avatars1.githubusercontent.com/u/2055384?v=4
slug: first-blog-post
title: First Blog Post
authors:
name: Gao Wei
title: Docusaurus Core Team
url: https://github.com/wgao19
image_url: https://github.com/wgao19.png
tags: [hola, docusaurus]
---

View file

@ -1,17 +0,0 @@
---
slug: hello-world
title: Hello
author: Endilie Yacop Sucipto
author_title: Maintainer of Docusaurus
author_url: https://github.com/endiliey
author_image_url: https://avatars1.githubusercontent.com/u/17883920?s=460&v=4
tags: [hello, docusaurus]
---
Welcome to this blog. This blog is created with [**Docusaurus 2**](https://docusaurus.io/).
<!--truncate-->
This is a test post.
A whole bunch of other information.

View file

@ -0,0 +1,44 @@
---
slug: long-blog-post
title: Long Blog Post
authors: endi
tags: [hello, docusaurus]
---
This is the summary of a very long blog post,
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
<!--truncate-->
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet

View file

@ -1,13 +0,0 @@
---
slug: welcome
title: Welcome
author: Yangshun Tay
author_title: Front End Engineer @ Facebook
author_url: https://github.com/yangshun
author_image_url: https://avatars0.githubusercontent.com/u/1315101?s=400&v=4
tags: [facebook, hello, docusaurus]
---
Blog features are powered by the blog plugin. Simply add files to the `blog` directory. It supports tags as well!
Delete the whole directory if you don't want the blog features. As simple as that!

View file

@ -0,0 +1,20 @@
---
slug: mdx-blog-post
title: MDX Blog Post
authors: [slorber]
tags: [docusaurus]
---
Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
:::tip
Use the power of React to create interactive blog posts.
```js
<button onClick={() => alert('button clicked!')}>Click me!</button>
```
<button onClick={() => alert('button clicked!')}>Click me!</button>
:::

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -0,0 +1,25 @@
---
slug: welcome
title: Welcome
authors: [slorber, yangshun]
tags: [facebook, hello, docusaurus]
---
[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
Simply add Markdown files (or folders) to the `blog` directory.
Regular blog authors can be added to `authors.yml`.
The blog post date can be extracted from filenames, such as:
- `2019-05-30-welcome.md`
- `2019-05-30-welcome/index.md`
A blog post folder can be convenient to co-locate blog post images:
![Docusaurus Plushie](./docusaurus-plushie-banner.jpeg)
The blog supports tags as well!
**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.

View file

@ -0,0 +1,17 @@
endi:
name: Endilie Yacop Sucipto
title: Maintainer of Docusaurus
url: https://github.com/endiliey
image_url: https://github.com/endiliey.png
yangshun:
name: Yangshun Tay
title: Front End Engineer @ Facebook
url: https://github.com/yangshun
image_url: https://github.com/yangshun.png
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png

View file

@ -14,10 +14,15 @@ Create a file at `blog/2021-02-28-greetings.md`:
---
slug: greetings
title: Greetings!
author: Steven Hansel
author_title: Docusaurus Contributor
author_url: https://github.com/ShinteiMai
author_image_url: https://github.com/ShinteiMai.png
authors:
- name: Joel Marcey
title: Co-creator of Docusaurus 1
url: https://github.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
- name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
tags: [greetings]
---

View file

@ -53,43 +53,45 @@ async function readMetadataPath(metadataPath: string) {
}
}
// For some specific FrontMatter fields, we want to allow referencing local relative assets so that they enter the Webpack asset pipeline
// We don't do that for all frontMatters, only for the configured keys
// Converts assets an object with Webpack require calls code
// This is useful for mdx files to reference co-located assets using relative paths
// Those assets should enter the Webpack assets pipeline and be hashed
// For now, we only handle that for images and paths starting with ./
// {image: "./myImage.png"} => {image: require("./myImage.png")}
function createFrontMatterAssetsExportCode(
frontMatter: Record<string, unknown>,
frontMatterAssetKeys: string[] = [],
) {
if (frontMatterAssetKeys.length === 0) {
function createAssetsExportCode(assets: Record<string, unknown>) {
if (Object.keys(assets).length === 0) {
return 'undefined';
}
function createFrontMatterAssetRequireCode(value: unknown) {
// TODO implementation can be completed/enhanced
function createAssetValueCode(assetValue: unknown): string | undefined {
if (Array.isArray(assetValue)) {
const arrayItemCodes = assetValue.map(
(item) => createAssetValueCode(item) ?? 'undefined',
);
return `[${arrayItemCodes.join(', ')}]`;
}
// Only process string values starting with ./
// We could enhance this logic and check if file exists on disc?
if (typeof value === 'string' && value.startsWith('./')) {
if (typeof assetValue === 'string' && assetValue.startsWith('./')) {
// TODO do we have other use-cases than image assets?
// Probably not worth adding more support, as we want to move to Webpack 5 new asset system (https://github.com/facebook/docusaurus/pull/4708)
const inlineLoader = inlineMarkdownImageFileLoader;
return `require("${inlineLoader}${escapePath(value)}").default`;
return `require("${inlineLoader}${escapePath(assetValue)}").default`;
}
return undefined;
}
const frontMatterAssetEntries = Object.entries(frontMatter).filter(([key]) =>
frontMatterAssetKeys.includes(key),
);
const assetEntries = Object.entries(assets);
const lines = frontMatterAssetEntries
const codeLines = assetEntries
.map(([key, value]) => {
const assetRequireCode = createFrontMatterAssetRequireCode(value);
const assetRequireCode = createAssetValueCode(value);
return assetRequireCode ? `"${key}": ${assetRequireCode},` : undefined;
})
.filter(Boolean);
const exportValue = `{\n${lines.join('\n')}\n}`;
return exportValue;
return `{\n${codeLines.join('\n')}\n}`;
}
const docusaurusMdxLoader: Loader = async function (fileString) {
@ -130,18 +132,9 @@ const docusaurusMdxLoader: Loader = async function (fileString) {
return callback(err);
}
let exportStr = `
export const frontMatter = ${stringifyObject(frontMatter)};
export const frontMatterAssets = ${createFrontMatterAssetsExportCode(
frontMatter,
reqOptions.frontMatterAssetKeys,
)};
export const contentTitle = ${stringifyObject(contentTitle)};`;
// MDX partials are MDX files starting with _ or in a folder starting with _
// Partial are not expected to have an associated metadata file or frontmatter
const isMDXPartial = options.isMDXPartial && options.isMDXPartial(filePath);
if (isMDXPartial && hasFrontMatter) {
const errorMessage = `Docusaurus MDX partial files should not contain FrontMatter.
Those partial files use the _ prefix as a convention by default, but this is configurable.
@ -158,26 +151,46 @@ ${JSON.stringify(frontMatter, null, 2)}`;
}
}
if (!isMDXPartial) {
// Read metadata for this MDX and export it.
if (options.metadataPath && typeof options.metadataPath === 'function') {
const metadataPath = options.metadataPath(filePath);
if (metadataPath) {
const metadata = await readMetadataPath(metadataPath);
exportStr += `\nexport const metadata = ${metadata};`;
// Add as dependency of this loader result so that we can
// recompile if metadata is changed.
this.addDependency(metadataPath);
function getMetadataPath(): string | undefined {
if (!isMDXPartial) {
// Read metadata for this MDX and export it.
if (options.metadataPath && typeof options.metadataPath === 'function') {
return options.metadataPath(filePath);
}
}
return undefined;
}
const metadataPath = getMetadataPath();
if (metadataPath) {
this.addDependency(metadataPath);
}
const metadataJsonString = metadataPath
? await readMetadataPath(metadataPath)
: undefined;
const metadata = metadataJsonString
? JSON.parse(metadataJsonString)
: undefined;
const assets =
reqOptions.createAssets && metadata
? reqOptions.createAssets({frontMatter, metadata})
: undefined;
const exportsCode = `
export const frontMatter = ${stringifyObject(frontMatter)};
export const contentTitle = ${stringifyObject(contentTitle)};
${metadataJsonString ? `export const metadata = ${metadataJsonString};` : ''}
${assets ? `export const assets = ${createAssetsExportCode(assets)};` : ''}
`;
const code = `
import React from 'react';
import { mdx } from '@mdx-js/react';
${exportStr}
${exportsCode}
${result}
`;

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 {

View file

@ -151,7 +151,7 @@ describe('validateDocFrontMatter image', () => {
{image: '../relative/image.png'},
],
invalidFrontMatters: [
[{image: ''}, 'does not match any of the allowed types'],
[{image: ''}, '"image" does not look like a valid url (value=\'\')'],
],
});
});

View file

@ -40,11 +40,7 @@ function BlogPostItem(props: Props): JSX.Element {
} = props;
const {date, readingTime, tags, permalink, editUrl} = metadata;
const {author, title} = frontMatter;
const authorURL = frontMatter.author_url || frontMatter.authorURL;
const authorImageURL =
frontMatter.author_image_url || frontMatter.authorImageURL;
const {author, title, authorURL, authorImageURL} = frontMatter;
const match = date.substring(0, 10).split('-');
const year = match[0];
@ -56,13 +52,17 @@ function BlogPostItem(props: Props): JSX.Element {
<div className="row no-gutters rows-col-2 m-3">
<div className="col-xs mr-3">
{authorImageURL && (
<img style={{width: '50px'}} src={authorImageURL} alt={author} />
<img
style={{width: '50px'}}
src={authorImageURL}
alt={author as string}
/>
)}
</div>
<div className="col">
{author && (
<h5>
<a href={authorURL} rel={author}>
<a href={authorURL} rel={author as string}>
{author}
</a>
</h5>

View file

@ -38,7 +38,7 @@ function BlogListPage(props: Props): JSX.Element {
<BlogPostItem
key={BlogPostContent.metadata.permalink}
frontMatter={BlogPostContent.frontMatter}
frontMatterAssets={BlogPostContent.frontMatterAssets}
assets={BlogPostContent.assets}
metadata={BlogPostContent.metadata}
truncated={BlogPostContent.metadata.truncated}>
<BlogPostContent />

View file

@ -0,0 +1,49 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import Link from '@docusaurus/Link';
import type {Props} from '@theme/BlogPostAuthor';
import styles from './styles.module.css';
function BlogPostAuthor({author}: Props): JSX.Element {
const {name, title, url, imageURL} = author;
return (
<div className="avatar margin-bottom--sm">
{imageURL && (
<Link className="avatar__photo-link avatar__photo" href={url}>
<img className={styles.image} src={imageURL} alt={name} />
</Link>
)}
{
// Note: only legacy author frontmatter allow empty name (not frontMatter.authors)
name && (
<div
className="avatar__intro"
itemProp="author"
itemScope
itemType="https://schema.org/Person">
<div className="avatar__name">
<Link href={url} itemProp="url">
<span itemProp="name">{name}</span>
</Link>
</div>
{title && (
<small className="avatar__subtitle" itemProp="description">
{title}
</small>
)}
</div>
)
}
</div>
);
}
export default BlogPostAuthor;

View file

@ -0,0 +1,12 @@
/**
* 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.
*/
.image {
width: 100%;
height: 100%;
object-fit: cover;
}

View file

@ -0,0 +1,55 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import clsx from 'clsx';
import type {Props} from '@theme/BlogPostAuthors';
import BlogPostAuthor from '@theme/BlogPostAuthor';
function getAuthorsPerLine(authorsCount: number): 1 | 2 {
switch (authorsCount) {
case 0:
case 1:
return 1;
default:
return 2;
}
}
function getColClassName(authorsCount: number): string {
switch (getAuthorsPerLine(authorsCount)) {
case 1:
return 'col--12';
case 2:
return 'col--6';
default:
throw Error('unexpected');
}
}
// Component responsible for the authors layout
export default function BlogPostAuthors({authors, assets}: Props): JSX.Element {
const authorsCount = authors.length;
if (authorsCount === 0) {
return <></>;
}
return (
<div className="row margin-top--md margin-bottom--sm">
{authors.map((author, idx) => (
<div className={clsx('col', getColClassName(authorsCount))} key={idx}>
<BlogPostAuthor
author={{
...author,
// Handle author images using relative paths
imageURL: assets.authorsImageUrls[idx] ?? author.imageURL,
}}
/>
</div>
))}
</div>
);
}

View file

@ -18,6 +18,7 @@ import type {Props} from '@theme/BlogPostItem';
import styles from './styles.module.css';
import TagsListInline from '@theme/TagsListInline';
import BlogPostAuthors from '@theme/BlogPostAuthors';
// Very simple pluralization: probably good enough for now
function useReadingTimePlural() {
@ -45,7 +46,7 @@ function BlogPostItem(props: Props): JSX.Element {
const {
children,
frontMatter,
frontMatterAssets,
assets,
metadata,
truncated,
isBlogPostPage = false,
@ -58,18 +59,10 @@ function BlogPostItem(props: Props): JSX.Element {
readingTime,
title,
editUrl,
authors,
} = metadata;
const {author} = frontMatter;
const image = frontMatterAssets.image ?? frontMatter.image;
const authorURL = frontMatter.author_url || frontMatter.authorURL;
const authorTitle = frontMatter.author_title || frontMatter.authorTitle;
const authorImageURL =
frontMatterAssets.author_image_url ||
frontMatterAssets.authorImageURL ||
frontMatter.author_image_url ||
frontMatter.authorImageURL;
const image = assets.image ?? frontMatter.image;
const renderPostHeader = () => {
const TitleHeading = isBlogPostPage ? 'h1' : 'h2';
@ -90,39 +83,14 @@ function BlogPostItem(props: Props): JSX.Element {
{formattedDate}
</time>
{readingTime && (
{typeof readingTime !== 'undefined' && (
<>
{' · '}
{readingTimePlural(readingTime)}
</>
)}
</div>
<div className="avatar margin-vert--md">
{authorImageURL && (
<Link className="avatar__photo-link avatar__photo" href={authorURL}>
<img src={authorImageURL} alt={author} />
</Link>
)}
{author && (
<div
className="avatar__intro"
itemProp="author"
itemScope
itemType="https://schema.org/Person">
<div className="avatar__name">
<Link href={authorURL} itemProp="url">
<span itemProp="name">{author}</span>
</Link>
</div>
{authorTitle && (
<small className="avatar__subtitle" itemProp="description">
{authorTitle}
</small>
)}
</div>
)}
</div>
<BlogPostAuthors authors={authors} assets={assets} />
</header>
);
};

View file

@ -15,12 +15,19 @@ import {ThemeClassNames} from '@docusaurus/theme-common';
function BlogPostPage(props: Props): JSX.Element {
const {content: BlogPostContents, sidebar} = props;
const {frontMatter, frontMatterAssets, metadata} = BlogPostContents;
const {title, description, nextItem, prevItem, date, tags} = metadata;
const {frontMatter, assets, metadata} = BlogPostContents;
const {
title,
description,
nextItem,
prevItem,
date,
tags,
authors,
} = metadata;
const {hide_table_of_contents: hideTableOfContents, keywords} = frontMatter;
const image = frontMatterAssets.image ?? frontMatter.image;
const authorURL = frontMatter.author_url || frontMatter.authorURL;
const image = assets.image ?? frontMatter.image;
return (
<BlogLayout
@ -41,7 +48,17 @@ function BlogPostPage(props: Props): JSX.Element {
image={image}>
<meta property="og:type" content="article" />
<meta property="article:published_time" content={date} />
{authorURL && <meta property="article:author" content={authorURL} />}
{/* TODO double check those article metas array syntaxes, see https://ogp.me/#array */}
{authors.some((author) => author.url) && (
<meta
property="article:author"
content={authors
.map((author) => author.url)
.filter(Boolean)
.join(',')}
/>
)}
{tags.length > 0 && (
<meta
property="article:tag"
@ -52,7 +69,7 @@ function BlogPostPage(props: Props): JSX.Element {
<BlogPostItem
frontMatter={frontMatter}
frontMatterAssets={frontMatterAssets}
assets={assets}
metadata={metadata}
isBlogPostPage>
<BlogPostContents />

View file

@ -71,7 +71,7 @@ function BlogTagsPostPage(props: Props): JSX.Element {
<BlogPostItem
key={BlogPostContent.metadata.permalink}
frontMatter={BlogPostContent.frontMatter}
frontMatterAssets={BlogPostContent.frontMatterAssets}
assets={BlogPostContent.assets}
metadata={BlogPostContent.metadata}
truncated>
<BlogPostContent />

View file

@ -27,15 +27,11 @@ declare module '@theme/BlogListPaginator' {
}
declare module '@theme/BlogPostItem' {
import type {
FrontMatter,
FrontMatterAssets,
Metadata,
} from '@theme/BlogPostPage';
import type {FrontMatter, Assets, Metadata} from '@theme/BlogPostPage';
export type Props = {
readonly frontMatter: FrontMatter;
readonly frontMatterAssets: FrontMatterAssets;
readonly assets: Assets;
readonly metadata: Metadata;
readonly truncated?: string | boolean;
readonly isBlogPostPage?: boolean;
@ -46,6 +42,27 @@ declare module '@theme/BlogPostItem' {
export default BlogPostItem;
}
declare module '@theme/BlogPostAuthor' {
import type {Metadata} from '@theme/BlogPostPage';
export type Props = {
readonly author: Metadata['authors'][number];
};
export default function BlogPostAuthor(props: Props): JSX.Element;
}
declare module '@theme/BlogPostAuthors' {
import type {Metadata, Assets} from '@theme/BlogPostPage';
export type Props = {
readonly authors: Metadata['authors'];
readonly assets: Assets;
};
export default function BlogPostAuthors(props: Props): JSX.Element;
}
declare module '@theme/BlogPostPaginator' {
type Item = {readonly title: string; readonly permalink: string};

View file

@ -60,4 +60,4 @@ exports[`validation schemas RemarkPluginsSchema: for value=false 1`] = `"\\"valu
exports[`validation schemas RemarkPluginsSchema: for value=null 1`] = `"\\"value\\" must be an array"`;
exports[`validation schemas URISchema: for value="spaces are invalid in a URL" 1`] = `"\\"value\\" does not match any of the allowed types"`;
exports[`validation schemas URISchema: for value="spaces are invalid in a URL" 1`] = `"\\"value\\" does not look like a valid url (value='')"`;

View file

@ -44,7 +44,10 @@ export const URISchema = Joi.alternatives(
return helpers.error('any.invalid');
}
}),
);
).messages({
'alternatives.match':
"{{#label}} does not look like a valid url (value='{{.value}}')",
});
export const PathnameSchema = Joi.string()
.custom((val) => {

View file

@ -1,10 +1,6 @@
---
title: Using twice the blog plugin
author: Sebastien Lorber
authorURL: https://sebastienlorber.com
authorImageURL: https://github.com/slorber.png
authorFBID: 611217057
authorTwitter: sebastienlorber
authors: [slorber]
tags: [blog, docusaurus]
---

View file

@ -0,0 +1,3 @@
# Hmmm!
This is a blog post from an anonymous author!

View file

@ -0,0 +1,11 @@
---
authors:
- slorber
- name: Josh-Cena
image_url: https://avatars.githubusercontent.com/u/55398995?v=4
url: https://joshcena.com
---
# Multiple authors
You can have multiple authors for one blog post!

View file

@ -0,0 +1,6 @@
slorber:
name: Sebastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
twitter: sebastienlorber

View file

@ -1,10 +1,6 @@
---
title: Introducing Docusaurus
author: Joel Marcey
authorURL: http://twitter.com/JoelMarcey
authorImageURL: https://graph.facebook.com/611217057/picture/?height=200&width=200
authorFBID: 611217057
authorTwitter: JoelMarcey
authors: JMarcey
tags: [documentation, blog, docusaurus]
---

View file

@ -2,8 +2,7 @@
title: How I Converted Profilo to Docusaurus in Under 2 Hours
author: Christine Abernathy
authorURL: http://twitter.com/abernathyca
authorImageURL: https://graph.facebook.com/1424840234/picture/?height=200&width=200
authorFBID: 1424840234
authorImageURL: https://github.com/caabernathy.png
authorTwitter: abernathyca
tags: [profilo, adoption]
---

View file

@ -3,7 +3,7 @@ title: Towards Docusaurus 2
author: Endilie Yacop Sucipto
authorTitle: Maintainer of Docusaurus
authorURL: https://github.com/endiliey
authorImageURL: https://avatars1.githubusercontent.com/u/17883920?s=460&v=4
authorImageURL: https://github.com/endiliey.png
authorTwitter: endiliey
tags: [new, adoption]
---

View file

@ -1,11 +1,8 @@
---
title: Happy 1st Birthday Slash!
author: Joel Marcey
authorTitle: Co-creator of Docusaurus
authorURL: https://github.com/JoelMarcey
authorImageURL: https://graph.facebook.com/611217057/picture/?height=200&width=200
authorFBID: 611217057
authorTwitter: JoelMarcey
authors:
- key: JMarcey
title: Co-creator of Docusaurus
tags: [birth]
---

View file

@ -1,10 +1,6 @@
---
title: Docusaurus 2019 Recap
author: Yangshun Tay
authorTitle: Front End Engineer at Facebook
authorURL: https://github.com/yangshun
authorImageURL: https://avatars1.githubusercontent.com/u/1315101?s=460&v=4
authorTwitter: yangshunz
authors: yangshun
tags: [recap]
---

View file

@ -1,10 +1,6 @@
---
title: Tribute to Endi
author: Joel Marcey
authorTitle: Technical Lead and Developer Advocate at Facebook
authorURL: https://github.com/JoelMarcey
authorImageURL: https://graph.facebook.com/611217057/picture/?height=200&width=200
authorTwitter: JoelMarcey
authors: JMarcey
tags: [endi, tribute]
---

View file

@ -1,10 +1,6 @@
---
title: Docusaurus 2020 Recap
author: Sébastien Lorber
authorTitle: Docusaurus maintainer
authorURL: https://sebastienlorber.com
authorImageURL: https://github.com/slorber.png
authorTwitter: sebastienlorber
authors: [slorber]
tags: [recap]
image: /img/docusaurus-2020-recap.png
---

View file

@ -1,10 +1,6 @@
---
title: Releasing Docusaurus i18n
author: Sébastien Lorber
authorTitle: Docusaurus maintainer
authorURL: https://sebastienlorber.com
authorImageURL: https://github.com/slorber.png
authorTwitter: sebastienlorber
authors: [slorber]
tags: [release, i18n]
image: /img/blog/2021-03-09-releasing-docusaurus-i18n/social-card.png
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -1,10 +1,12 @@
---
title: Announcing Docusaurus 2 Beta
author: Sébastien Lorber
authorTitle: Docusaurus maintainer
authorURL: https://sebastienlorber.com
authorImageURL: ./img/author.jpeg
authorTwitter: sebastienlorber
authors:
- key: slorber
image_url: ./img/slorber.png
- JMarcey
- yangshun
- lex111
tags: [release, beta]
image: ./img/social-card.png
---

26
website/blog/authors.yml Normal file
View file

@ -0,0 +1,26 @@
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

@ -4,7 +4,7 @@ title: '📦 plugin-content-blog'
slug: '/api/plugins/@docusaurus/plugin-content-blog'
---
Provides the [Blog](blog.md) feature and is the default blog plugin for Docusaurus.
Provides the [Blog](blog.mdx) feature and is the default blog plugin for Docusaurus.
## Installation {#installation}
@ -28,7 +28,7 @@ Accepted fields:
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `path` | `string` | `'blog'` | Path to data on filesystem relative to site dir. |
| `path` | `string` | `'blog'` | Path to the blog content directory on the filesystem, relative to site dir. |
| `editUrl` | <code>string &#124; EditUrlFunction</code> | `undefined` | Base URL to edit your site. The final URL is computed by `editUrl + relativeDocPath`. Using a function allows more nuanced control for each file. Omitting this variable entirely will disable edit links. |
| `editLocalizedFiles` | `boolean` | `false` | The edit URL will target the localized file, instead of the original unlocalized file. Ignored when `editUrl` is a function. |
| `blogTitle` | `string` | `'Blog'` | Blog page title for better SEO. |
@ -49,6 +49,7 @@ Accepted fields:
| `beforeDefaultRehypePlugins` | `any[]` | `[]` | Custom Rehype plugins passed to MDX before the default Docusaurus Rehype plugins. |
| `truncateMarker` | `string` | `/<!--\s*(truncate)\s*-->/` | Truncate marker, can be a regex or string. |
| `showReadingTime` | `boolean` | `true` | Show estimated reading time for the blog post. |
| `authorsMapPath` | `string` | `'authors.yml'` | Path to the authors map file, relative to the blog content directory specified with `path`. Can also be a `json` file. |
| `feedOptions` | _See below_ | `{type: ['rss', 'atom']}` | Blog feed. If undefined, no rss feed will be generated. |
| `feedOptions.type` | <code>'rss' &#124; 'atom' &#124; 'all'</code> (or array of multiple options) | **Required** | Type of feed to be generated. |
| `feedOptions.title` | `string` | `siteConfig.title` | Title of the feed. |
@ -174,10 +175,11 @@ Accepted fields:
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `author` | `string` | `undefined` | The author name to be displayed. |
| `author_url` | `string` | `undefined` | The URL that the author's name will be linked to. This could be a GitHub, Twitter, Facebook profile URL, etc. |
| `author_image_url` | `string` | `undefined` | The URL to the author's thumbnail image. |
| `author_title` | `string` | `undefined` | A description of the author. |
| `authors` | `Authors` | `undefined` | List of blog post authors (or unique author). Read the [`authors` guide](../../blog.mdx#blog-post-authors) for more explanations. Prefer `authors` over the `author_*` FrontMatter fields, even for single author blog posts. |
| `author` | `string` | `undefined` | ⚠️ Prefer using `authors`. The blog post author's name. |
| `author_url` | `string` | `undefined` | ⚠️ Prefer using `authors`. The URL that the author's name will be linked to. This could be a GitHub, Twitter, Facebook profile URL, etc. |
| `author_image_url` | `string` | `undefined` | ⚠️ Prefer using `authors`. The URL to the author's thumbnail image. |
| `author_title` | `string` | `undefined` | ⚠️ Prefer using `authors`. A description of the author. |
| `title` | `string` | Markdown title | The blog post title. |
| `date` | `string` | File name or file creation time | The blog post creation date. If not specified, this can be extracted from the file or folder name, e.g, `2021-04-15-blog-post.mdx`, `2021-04-15-blog-post/index.mdx`, `2021/04/15/blog-post.mdx`. Otherwise, it is the Markdown file creation time. |
| `tags` | `Tag[]` | `undefined` | A list of strings or objects of two string fields `label` and `permalink` to tag to your post. |
@ -192,6 +194,20 @@ Accepted fields:
```typescript
type Tag = string | {label: string; permalink: string};
// An author key references an author from the global plugin authors.yml file
type AuthorKey = string;
type Author = {
key?: AuthorKey;
name: string;
title?: string;
url?: string;
image_url?: string;
};
// The FrontMatter authors field allows various possible shapes
type Authors = AuthorKey | Author | (AuthorKey | Author)[];
```
Example:
@ -199,10 +215,13 @@ Example:
```yml
---
title: Welcome Docusaurus v2
author: Joel Marcey
author_title: Co-creator of Docusaurus 1
author_url: https://github.com/JoelMarcey
author_image_url: https://graph.facebook.com/611217057/picture/?height=200&width=200
authors:
- slorber
- yangshun
- name: Joel Marcey
title: Co-creator of Docusaurus 1
url: https://github.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
tags: [hello, docusaurus-v2]
description: This is my first post on Docusaurus 2.
image: https://i.imgur.com/mErPwqL.png
@ -228,6 +247,7 @@ Read the [i18n introduction](../../i18n/i18n-introduction.md) first.
website/i18n/<locale>/docusaurus-plugin-content-blog
│ # translations for website/blog
├── authors.yml
├── first-blog-post.md
├── second-blog-post.md

View file

@ -3,6 +3,9 @@ id: blog
title: Blog
---
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
The blog feature enables you to deploy in no time a full-featured blog.
:::info
@ -36,17 +39,23 @@ module.exports = {
To publish in the blog, create a Markdown file within the blog directory.
For example, create a file at `my-website/blog/2019-09-05-hello-docusaurus-v2.md`:
For example, create a file at `website/blog/2019-09-05-hello-docusaurus-v2.md`:
```yml
```yml title="website/blog/2019-09-05-hello-docusaurus-v2.md"
---
title: Welcome Docusaurus v2
author: Joel Marcey
author_title: Co-creator of Docusaurus 1
author_url: https://github.com/JoelMarcey
author_image_url: https://graph.facebook.com/611217057/picture/?height=200&width=200
tags: [hello, docusaurus-v2]
description: This is my first post on Docusaurus 2.
slug: welcome-docusaurus-v2
authors:
- name: Joel Marcey
title: Co-creator of Docusaurus 1
url: https://github.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
- name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
tags: [hello, docusaurus-v2]
image: https://i.imgur.com/mErPwqL.png
hide_table_of_contents: false
---
@ -158,9 +167,184 @@ module.exports = {
};
```
:::note
## Blog post authors {#blog-post-authors}
Because the sidebar title is hard-coded in the configuration file, it is currently untranslatable.
Use the `authors` FrontMatter field to declare blog post authors.
### Inline authors {#inline-authors}
Blog post authors can be declared directly inside the FrontMatter:
````mdx-code-block
<Tabs
defaultValue="single"
values={[
{label: 'Single author', value: 'single'},
{label: 'Multiple authors', value: 'multiple'},
]}>
<TabItem value="single">
```yml title="my-blog-post.md"
---
authors:
name: Joel Marcey
title: Co-creator of Docusaurus 1
url: https://github.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
---
```
</TabItem>
<TabItem value="multiple">
```yml title="my-blog-post.md"
---
authors:
- name: Joel Marcey
title: Co-creator of Docusaurus 1
url: https://github.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
- name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
---
```
</TabItem>
</Tabs>
````
:::tip
This option works best to get started, or for casual, irregular authors.
:::
:::info
Prefer usage of the `authors` FrontMatter, but the legacy `author_*` FrontMatter remains supported:
```yml title="my-blog-post.md"
---
author: Joel Marcey
author_title: Co-creator of Docusaurus 1
author_url: https://github.com/JoelMarcey
author_image_url: https://github.com/JoelMarcey.png
---
```
:::
### Global authors {#global-authors}
For regular blog post authors, it can be tedious to maintain authors information inlined in each blog post.
It is possible declare those authors globally in a configuration file:
```yml title="website/blog/authors.yml"
jmarcey:
name: Joel Marcey
title: Co-creator of Docusaurus 1
url: https://github.com/JoelMarcey
image_url: https://github.com/JoelMarcey.png
slorber:
name: Sébastien Lorber
title: Docusaurus maintainer
url: https://sebastienlorber.com
image_url: https://github.com/slorber.png
```
:::tip
Use the `authorsMapPath` plugin option to configure the path. JSON is also supported.
:::
In blog posts FrontMatter, you can reference the authors declared in the global configuration file:
````mdx-code-block
<Tabs
defaultValue="single"
values={[
{label: 'Single author', value: 'single'},
{label: 'Multiple authors', value: 'multiple'},
]}>
<TabItem value="single">
```yml title="my-blog-post.md"
---
authors: jmarcey
---
```
</TabItem>
<TabItem value="multiple">
```yml title="my-blog-post.md"
---
authors: [jmarcey, slorber]
---
```
</TabItem>
</Tabs>
````
:::info
The `authors` system is very flexible and can suit more advanced use-case:
<details>
<summary>Mix inline authors and global authors</summary>
You can use global authors most of the time, and still use inline authors:
```yml title="my-blog-post.md"
---
authors:
- jmarcey
- slorber
- name: Inline Author name
title: Inline Author Title
url: https://github.com/inlineAuthor
image_url: https://github.com/inlineAuthor
---
```
</details>
<details>
<summary>Local override of global authors</summary>
You can customize the global author's data on per-blog-post basis
```yml title="my-blog-post.md"
---
authors:
- key: jmarcey
title: Joel Marcey's new title
- key: slorber
name: Sébastien Lorber's new name
---
```
</details>
<details>
<summary>Localize the author's configuration file</summary>
The configuration file can be localized, just create a localized copy of it at:
```bash
website/i18n/<locale>/docusaurus-plugin-content-blog/authors.yml
```
</details>
:::

View file

@ -81,6 +81,6 @@ You should delete the existing homepage at `./src/pages/index.js`, or else there
:::tip
There's also a "blog-only mode" for those who only want to use the blog feature of Docusaurus 2. You can use the same method detailed above. Follow the setup instructions on [Blog-only mode](../../blog.md#blog-only-mode).
There's also a "blog-only mode" for those who only want to use the blog feature of Docusaurus 2. You can use the same method detailed above. Follow the setup instructions on [Blog-only mode](../../blog.mdx#blog-only-mode).
:::

View file

@ -90,7 +90,7 @@ my-website
### Project structure rundown {#project-structure-rundown}
- `/blog/` - Contains the blog Markdown files. You can delete the directory if you do not want/need a blog. More details can be found in the [blog guide](blog.md)
- `/blog/` - Contains the blog Markdown files. You can delete the directory if you do not want/need a blog. More details can be found in the [blog guide](blog.mdx)
- `/docs/` - Contains the Markdown files for the docs. Customize the order of the docs sidebar in `sidebars.js`. More details can be found in the [docs guide](./guides/docs/docs-markdown-features.mdx)
- `/src/` - Non-documentation files like pages or custom React components. You don't have to strictly put your non-documentation files in here but putting them under a centralized directory makes it easier to specify in case you need to do some sort of linting/processing
- `/src/pages` - Any files within this directory will be converted into a website page. More details can be found in the [pages guide](guides/creating-pages.md)

View file

@ -576,7 +576,7 @@ Refer to the [multi-language support code blocks](../guides/markdown-features/ma
The Docusaurus front matter fields for the blog have been changed from camelCase to snake_case to be consistent with the docs.
The fields `authorFBID` and `authorTwitter` have been deprecated. They are only used for generating the profile image of the author which can be done via the `author_image_url` field.
The fields `authorFBID` and `authorTwitter` have been deprecated. They are only used for generating the profile image of the author which can be done via the `authors` field.
## Deployment {#deployment}

View file

@ -270,7 +270,7 @@ const TwitterSvg =
}
return `https://github.com/facebook/docusaurus/edit/main/website/${blogDirPath}/${blogPath}`;
},
postsPerPage: 3,
postsPerPage: 5,
feedOptions: {
type: 'all',
copyright: `Copyright © ${new Date().getFullYear()} Facebook, Inc.`,