diff --git a/.gitignore b/.gitignore index 202d5775b0..640b8f236a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ coverage .cache-loader types test-website +test-website-in-workspace packages/docusaurus/lib/ packages/docusaurus-*/lib/* diff --git a/package.json b/package.json index c6d8dab064..f163a5fefd 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "workspaces": [ "packages/*", "website", + "test-website-in-workspace", "packages/docusaurus-init/templates/*", "admin/new.docusaurus.io" ], diff --git a/packages/docusaurus-init/README.md b/packages/docusaurus-init/README.md index 27ce71c066..798fc6302c 100644 --- a/packages/docusaurus-init/README.md +++ b/packages/docusaurus-init/README.md @@ -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 +``` diff --git a/packages/docusaurus-init/templates/shared/blog/2019-05-28-hola.md b/packages/docusaurus-init/templates/shared/blog/2019-05-28-first-blog-post.md similarity index 53% rename from packages/docusaurus-init/templates/shared/blog/2019-05-28-hola.md rename to packages/docusaurus-init/templates/shared/blog/2019-05-28-first-blog-post.md index 4adbc327fc..02f3f81bd2 100644 --- a/packages/docusaurus-init/templates/shared/blog/2019-05-28-hola.md +++ b/packages/docusaurus-init/templates/shared/blog/2019-05-28-first-blog-post.md @@ -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] --- diff --git a/packages/docusaurus-init/templates/shared/blog/2019-05-29-hello-world.md b/packages/docusaurus-init/templates/shared/blog/2019-05-29-hello-world.md deleted file mode 100644 index 6fd208307b..0000000000 --- a/packages/docusaurus-init/templates/shared/blog/2019-05-29-hello-world.md +++ /dev/null @@ -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/). - - - -This is a test post. - -A whole bunch of other information. diff --git a/packages/docusaurus-init/templates/shared/blog/2019-05-29-long-blog-post.md b/packages/docusaurus-init/templates/shared/blog/2019-05-29-long-blog-post.md new file mode 100644 index 0000000000..26ffb1b1f6 --- /dev/null +++ b/packages/docusaurus-init/templates/shared/blog/2019-05-29-long-blog-post.md @@ -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 `` comment to limit blog post size in the list view. + + + +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 diff --git a/packages/docusaurus-init/templates/shared/blog/2019-05-30-welcome.md b/packages/docusaurus-init/templates/shared/blog/2019-05-30-welcome.md deleted file mode 100644 index d35d57b7dc..0000000000 --- a/packages/docusaurus-init/templates/shared/blog/2019-05-30-welcome.md +++ /dev/null @@ -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! diff --git a/packages/docusaurus-init/templates/shared/blog/2021-08-01-mdx-blog-post.mdx b/packages/docusaurus-init/templates/shared/blog/2021-08-01-mdx-blog-post.mdx new file mode 100644 index 0000000000..c04ebe323e --- /dev/null +++ b/packages/docusaurus-init/templates/shared/blog/2021-08-01-mdx-blog-post.mdx @@ -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 + +``` + + + +::: diff --git a/packages/docusaurus-init/templates/shared/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg b/packages/docusaurus-init/templates/shared/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg new file mode 100644 index 0000000000..11bda09284 Binary files /dev/null and b/packages/docusaurus-init/templates/shared/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg differ diff --git a/packages/docusaurus-init/templates/shared/blog/2021-08-26-welcome/index.md b/packages/docusaurus-init/templates/shared/blog/2021-08-26-welcome/index.md new file mode 100644 index 0000000000..9455168f17 --- /dev/null +++ b/packages/docusaurus-init/templates/shared/blog/2021-08-26-welcome/index.md @@ -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. diff --git a/packages/docusaurus-init/templates/shared/blog/authors.yml b/packages/docusaurus-init/templates/shared/blog/authors.yml new file mode 100644 index 0000000000..bcb2991563 --- /dev/null +++ b/packages/docusaurus-init/templates/shared/blog/authors.yml @@ -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 diff --git a/packages/docusaurus-init/templates/shared/docs/tutorial-basics/create-a-blog-post.md b/packages/docusaurus-init/templates/shared/docs/tutorial-basics/create-a-blog-post.md index d893e1c74f..0d50aaf316 100644 --- a/packages/docusaurus-init/templates/shared/docs/tutorial-basics/create-a-blog-post.md +++ b/packages/docusaurus-init/templates/shared/docs/tutorial-basics/create-a-blog-post.md @@ -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] --- diff --git a/packages/docusaurus-mdx-loader/src/index.ts b/packages/docusaurus-mdx-loader/src/index.ts index cd02f41262..26f98c657a 100644 --- a/packages/docusaurus-mdx-loader/src/index.ts +++ b/packages/docusaurus-mdx-loader/src/index.ts @@ -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, - frontMatterAssetKeys: string[] = [], -) { - if (frontMatterAssetKeys.length === 0) { +function createAssetsExportCode(assets: Record) { + 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} `; diff --git a/packages/docusaurus-plugin-content-blog/index.d.ts b/packages/docusaurus-plugin-content-blog/index.d.ts index 5fa42268b5..e2e5d15c02 100644 --- a/packages/docusaurus-plugin-content-blog/index.d.ts +++ b/packages/docusaurus-plugin-content-blog/index.d.ts @@ -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; diff --git a/packages/docusaurus-plugin-content-blog/package.json b/packages/docusaurus-plugin-content-blog/package.json index 417db325cb..c367508105 100644 --- a/packages/docusaurus-plugin-content-blog/package.json +++ b/packages/docusaurus-plugin-content-blog/package.json @@ -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", diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authors.json b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authors.json new file mode 100644 index 0000000000..1cca9c9f0a --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authors.json @@ -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" + } +} diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authors.yml new file mode 100644 index 0000000000..5bce126cef --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authors.yml @@ -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 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad1.json b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad1.json new file mode 100644 index 0000000000..bd2c8659fc --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad1.json @@ -0,0 +1,5 @@ +{ + "slorber": { + "title": "Docusaurus maintainer" + } +} diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad1.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad1.yml new file mode 100644 index 0000000000..edd8e7ce7c --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad1.yml @@ -0,0 +1,3 @@ + +slorber: + title: Docusaurus maintainer diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad2.json b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad2.json new file mode 100644 index 0000000000..40128533eb --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad2.json @@ -0,0 +1,3 @@ +{ + "name": "Sébastien Lorber" +} diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad2.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad2.yml new file mode 100644 index 0000000000..b0a0c84088 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad2.yml @@ -0,0 +1,2 @@ + +name: Sébastien Lorber diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad3.json b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad3.json new file mode 100644 index 0000000000..60f1312989 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad3.json @@ -0,0 +1,8 @@ +[ + { + "name": "Sébastien Lorber" + }, + { + "name": "Joel Marcey" + } +] diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad3.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad3.yml new file mode 100644 index 0000000000..15d2d9a2ec --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/authorsMapFiles/authorsBad3.yml @@ -0,0 +1,3 @@ + +- name: Sébastien Lorber +- name: Joel Marcey diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathEmpty/empty b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathEmpty/empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathJson1/authors.json b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathJson1/authors.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathJson2/authors.json b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathJson2/authors.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathNestedYml/sub/folder/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathNestedYml/sub/folder/authors.yml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathYml1/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathYml1/authors.yml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathYml2/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/getAuthorsMapFilePath/contentPathYml2/authors.yml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md index a66bb945bf..5a8f32cea1 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/2018-12-14-Happy-First-Birthday-Slash.md @@ -1,5 +1,8 @@ --- title: Happy 1st Birthday Slash! +authors: + - name: Yangshun Tay + - slorber --- Happy birthday! diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml new file mode 100644 index 0000000000..cb6c296b21 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/authors.yml @@ -0,0 +1,4 @@ + +slorber: + name: Sébastien Lorber + title: Docusaurus maintainer diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/simple-slug.md b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/simple-slug.md index 9bee9390a9..7bea4b3849 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/simple-slug.md +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/blog/simple-slug.md @@ -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 diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md index 0f8fcd88e8..b015e77fb5 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/2018-12-14-Happy-First-Birthday-Slash.md @@ -1,5 +1,8 @@ --- title: Happy 1st Birthday Slash! (translated) +authors: + - name: Yangshun Tay (translated) + - slorber --- Happy birthday! (translated) diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml new file mode 100644 index 0000000000..f42af6257f --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__fixtures__/website/i18n/en/docusaurus-plugin-content-blog/authors.yml @@ -0,0 +1,5 @@ + + +slorber: + name: Sébastien Lorber (translated) + title: Docusaurus maintainer (translated) diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap index 8d4ac07201..2d3b7db474 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/__snapshots__/generateBlogFeed.test.ts.snap @@ -26,6 +26,10 @@ exports[`blogFeed atom shows feed item for each post 1`] = ` 2020-08-15T00:00:00.000Z + + Sébastien Lorber + https://sebastienlorber.com + <![CDATA[draft]]> @@ -53,6 +57,12 @@ exports[`blogFeed atom shows feed item for each post 1`] = ` 2018-12-14T00:00:00.000Z + + Yangshun Tay (translated) + + + Sébastien Lorber (translated) + " `; diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts new file mode 100644 index 0000000000..3275b57244 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/authors.test.ts @@ -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')); + }); +}); diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/blogFrontMatter.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/blogFrontMatter.test.ts index 86afc79f94..eca414be39 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/blogFrontMatter.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/blogFrontMatter.test.ts @@ -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: [ diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts index a99b9c33aa..c21974fd1d 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/generateBlogFeed.test.ts @@ -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: { diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts index 898efa421b..eee9cf72c3 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts @@ -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 = {}, 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 = {}, + 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: [], diff --git a/packages/docusaurus-plugin-content-blog/src/authors.ts b/packages/docusaurus-plugin-content-blog/src/authors.ts new file mode 100644 index 0000000000..3bca2ebef3 --- /dev/null +++ b/packages/docusaurus-plugin-content-blog/src/authors.ts @@ -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; + +const AuthorsMapSchema = Joi.object().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 { + 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 { + // 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 { + 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; +} diff --git a/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts b/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts index 4d0787d055..0ea91930a0 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts @@ -13,6 +13,30 @@ import { } from '@docusaurus/utils-validation'; import type {FrontMatterTag} from '@docusaurus/utils'; +export type BlogPostFrontMatterAuthor = Record & { + 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({ id: Joi.string(), title: Joi.string().allow(''), @@ -47,28 +79,42 @@ const BlogFrontMatterSchema = Joi.object({ 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, diff --git a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts index f63527cf24..c26e0e4e8c 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts @@ -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 { 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( diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index 76134402ec..52bb3abe3e 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -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, + ), + }; + }, }, }, { diff --git a/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts b/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts index fe02b17d1e..90401e4b9d 100644 --- a/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts +++ b/packages/docusaurus-plugin-content-blog/src/pluginOptionSchema.ts @@ -38,6 +38,7 @@ export const DEFAULT_OPTIONS: PluginOptions = { routeBasePath: 'blog', path: 'blog', editLocalizedFiles: false, + authorsMapPath: 'authors.yml', }; export const PluginOptionSchema = Joi.object({ @@ -107,4 +108,5 @@ export const PluginOptionSchema = Joi.object({ }), language: Joi.string(), }).default(DEFAULT_OPTIONS.feedOptions), + authorsMapPath: Joi.string().default(DEFAULT_OPTIONS.authorsMapPath), }); diff --git a/packages/docusaurus-plugin-content-blog/src/types.ts b/packages/docusaurus-plugin-content-blog/src/types.ts index 43289dad4a..2f308e4630 100644 --- a/packages/docusaurus-plugin-content-blog/src/types.ts +++ b/packages/docusaurus-plugin-content-blog/src/types.ts @@ -58,6 +58,7 @@ export interface PluginOptions extends RemarkAndRehypePluginOptions { editUrl?: string | EditUrlFunction; editLocalizedFiles?: boolean; admonitions: Record; + 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 { + 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 { diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts index f3997d6fcb..543316e077 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/docFrontMatter.test.ts @@ -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=\'\')'], ], }); }); diff --git a/packages/docusaurus-theme-bootstrap/src/theme/BlogPostItem/index.tsx b/packages/docusaurus-theme-bootstrap/src/theme/BlogPostItem/index.tsx index f2fece5284..c2735c96e9 100644 --- a/packages/docusaurus-theme-bootstrap/src/theme/BlogPostItem/index.tsx +++ b/packages/docusaurus-theme-bootstrap/src/theme/BlogPostItem/index.tsx @@ -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 {
{authorImageURL && ( - {author} + {author )}
{author && (
- + {author}
diff --git a/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx index 27d61f3186..2179aeaca3 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogListPage/index.tsx @@ -38,7 +38,7 @@ function BlogListPage(props: Props): JSX.Element { diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostAuthor/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostAuthor/index.tsx new file mode 100644 index 0000000000..79f285f59f --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostAuthor/index.tsx @@ -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 ( +
+ {imageURL && ( + + {name} + + )} + + { + // Note: only legacy author frontmatter allow empty name (not frontMatter.authors) + name && ( + + ) + } +
+ ); +} + +export default BlogPostAuthor; diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostAuthor/styles.module.css b/packages/docusaurus-theme-classic/src/theme/BlogPostAuthor/styles.module.css new file mode 100644 index 0000000000..aae60add2d --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostAuthor/styles.module.css @@ -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; +} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostAuthors/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostAuthors/index.tsx new file mode 100644 index 0000000000..0b091c1416 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostAuthors/index.tsx @@ -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 ( +
+ {authors.map((author, idx) => ( +
+ +
+ ))} +
+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostItem/index.tsx index b8062a37c7..ae45640ef7 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostItem/index.tsx @@ -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} - {readingTime && ( + {typeof readingTime !== 'undefined' && ( <> {' · '} {readingTimePlural(readingTime)} )}
-
- {authorImageURL && ( - - {author} - - )} - {author && ( - - )} -
+ ); }; diff --git a/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx index d6cf19c4c7..8cabdad67d 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogPostPage/index.tsx @@ -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 ( - {authorURL && } + + {/* TODO double check those article metas array syntaxes, see https://ogp.me/#array */} + {authors.some((author) => author.url) && ( + author.url) + .filter(Boolean) + .join(',')} + /> + )} {tags.length > 0 && ( diff --git a/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx b/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx index e64a5efa59..cb304821c0 100644 --- a/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/BlogTagsPostsPage/index.tsx @@ -71,7 +71,7 @@ function BlogTagsPostPage(props: Props): JSX.Element { diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index a878b2cb93..e4dbbdba12 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -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}; diff --git a/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap b/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap index 4d9ff283cd..bd89b0a59f 100644 --- a/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap +++ b/packages/docusaurus-utils-validation/src/__tests__/__snapshots__/validationSchemas.test.ts.snap @@ -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='')"`; diff --git a/packages/docusaurus-utils-validation/src/validationSchemas.ts b/packages/docusaurus-utils-validation/src/validationSchemas.ts index 0496147b73..5812891d8c 100644 --- a/packages/docusaurus-utils-validation/src/validationSchemas.ts +++ b/packages/docusaurus-utils-validation/src/validationSchemas.ts @@ -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) => { diff --git a/website/_dogfooding/_blog tests/2020-08-03-second-blog-intro.md b/website/_dogfooding/_blog tests/2020-08-03-second-blog-intro.md index 77115e1275..25f84187c0 100644 --- a/website/_dogfooding/_blog tests/2020-08-03-second-blog-intro.md +++ b/website/_dogfooding/_blog tests/2020-08-03-second-blog-intro.md @@ -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] --- diff --git a/website/_dogfooding/_blog tests/2021-08-22-no-author.md b/website/_dogfooding/_blog tests/2021-08-22-no-author.md new file mode 100644 index 0000000000..4be979f1f1 --- /dev/null +++ b/website/_dogfooding/_blog tests/2021-08-22-no-author.md @@ -0,0 +1,3 @@ +# Hmmm! + +This is a blog post from an anonymous author! diff --git a/website/_dogfooding/_blog tests/2021-08-23-multiple-authors.md b/website/_dogfooding/_blog tests/2021-08-23-multiple-authors.md new file mode 100644 index 0000000000..5777def030 --- /dev/null +++ b/website/_dogfooding/_blog tests/2021-08-23-multiple-authors.md @@ -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! diff --git a/website/_dogfooding/_blog tests/authors.yml b/website/_dogfooding/_blog tests/authors.yml new file mode 100644 index 0000000000..fc6a50b90b --- /dev/null +++ b/website/_dogfooding/_blog tests/authors.yml @@ -0,0 +1,6 @@ +slorber: + name: Sebastien Lorber + title: Docusaurus maintainer + url: https://sebastienlorber.com + image_url: https://github.com/slorber.png + twitter: sebastienlorber diff --git a/website/blog/2017-12-14-introducing-docusaurus.md b/website/blog/2017-12-14-introducing-docusaurus.md index 850e1c5993..872e5d637b 100644 --- a/website/blog/2017-12-14-introducing-docusaurus.md +++ b/website/blog/2017-12-14-introducing-docusaurus.md @@ -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] --- diff --git a/website/blog/2018-04-30-How-I-Converted-Profilo-To-Docusaurus.md b/website/blog/2018-04-30-How-I-Converted-Profilo-To-Docusaurus.md index 44b8e4bda0..cf42f021b6 100644 --- a/website/blog/2018-04-30-How-I-Converted-Profilo-To-Docusaurus.md +++ b/website/blog/2018-04-30-How-I-Converted-Profilo-To-Docusaurus.md @@ -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] --- diff --git a/website/blog/2018-09-11-Towards-Docusaurus-2.md b/website/blog/2018-09-11-Towards-Docusaurus-2.md index 905719b4cd..8f9c5c1355 100644 --- a/website/blog/2018-09-11-Towards-Docusaurus-2.md +++ b/website/blog/2018-09-11-Towards-Docusaurus-2.md @@ -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] --- diff --git a/website/blog/2018-12-14-Happy-First-Birthday-Slash.md b/website/blog/2018-12-14-Happy-First-Birthday-Slash.md index d44215ceb2..81c8f0289f 100644 --- a/website/blog/2018-12-14-Happy-First-Birthday-Slash.md +++ b/website/blog/2018-12-14-Happy-First-Birthday-Slash.md @@ -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] --- diff --git a/website/blog/2019-12-30-docusaurus-2019-recap.md b/website/blog/2019-12-30-docusaurus-2019-recap.md index 850754a990..fb1cba3bbc 100644 --- a/website/blog/2019-12-30-docusaurus-2019-recap.md +++ b/website/blog/2019-12-30-docusaurus-2019-recap.md @@ -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] --- diff --git a/website/blog/2020-01-07-tribute-to-endi.md b/website/blog/2020-01-07-tribute-to-endi.md index 5c03384ab8..3ebe100539 100644 --- a/website/blog/2020-01-07-tribute-to-endi.md +++ b/website/blog/2020-01-07-tribute-to-endi.md @@ -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] --- diff --git a/website/blog/2021-01-19-docusaurus-2020-recap.md b/website/blog/2021-01-19-docusaurus-2020-recap.md index d40d416d34..85d252681f 100644 --- a/website/blog/2021-01-19-docusaurus-2020-recap.md +++ b/website/blog/2021-01-19-docusaurus-2020-recap.md @@ -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 --- diff --git a/website/blog/2021-03-09-releasing-docusaurus-i18n.md b/website/blog/2021-03-09-releasing-docusaurus-i18n.md index 61e8cdcf65..719e99a075 100644 --- a/website/blog/2021-03-09-releasing-docusaurus-i18n.md +++ b/website/blog/2021-03-09-releasing-docusaurus-i18n.md @@ -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 --- diff --git a/website/blog/2021-05-12-announcing-docusaurus-two-beta/img/author.jpeg b/website/blog/2021-05-12-announcing-docusaurus-two-beta/img/author.jpeg deleted file mode 100644 index be9fdfdbf4..0000000000 Binary files a/website/blog/2021-05-12-announcing-docusaurus-two-beta/img/author.jpeg and /dev/null differ diff --git a/website/blog/2021-05-12-announcing-docusaurus-two-beta/img/slorber.png b/website/blog/2021-05-12-announcing-docusaurus-two-beta/img/slorber.png new file mode 100644 index 0000000000..a2959a335c Binary files /dev/null and b/website/blog/2021-05-12-announcing-docusaurus-two-beta/img/slorber.png differ diff --git a/website/blog/2021-05-12-announcing-docusaurus-two-beta/index.md b/website/blog/2021-05-12-announcing-docusaurus-two-beta/index.md index c25cc2f616..4d4be53bc8 100644 --- a/website/blog/2021-05-12-announcing-docusaurus-two-beta/index.md +++ b/website/blog/2021-05-12-announcing-docusaurus-two-beta/index.md @@ -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 --- diff --git a/website/blog/authors.yml b/website/blog/authors.yml new file mode 100644 index 0000000000..6303083427 --- /dev/null +++ b/website/blog/authors.yml @@ -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 diff --git a/website/docs/api/plugins/plugin-content-blog.md b/website/docs/api/plugins/plugin-content-blog.md index efd3bf06a1..26ef631a62 100644 --- a/website/docs/api/plugins/plugin-content-blog.md +++ b/website/docs/api/plugins/plugin-content-blog.md @@ -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` | string | EditUrlFunction | `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` | `//` | 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` | 'rss' | 'atom' | 'all' (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//docusaurus-plugin-content-blog │ │ # translations for website/blog +├── authors.yml ├── first-blog-post.md ├── second-blog-post.md │ diff --git a/website/docs/blog.md b/website/docs/blog.mdx similarity index 65% rename from website/docs/blog.md rename to website/docs/blog.mdx index 8488920631..96dee72d9d 100644 --- a/website/docs/blog.md +++ b/website/docs/blog.mdx @@ -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 + + + +```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 +--- +``` + + + + +```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 +--- +``` + + + +```` + +:::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 + + + +```yml title="my-blog-post.md" +--- +authors: jmarcey +--- +``` + + + + +```yml title="my-blog-post.md" +--- +authors: [jmarcey, slorber] +--- +``` + + + +```` + +:::info + +The `authors` system is very flexible and can suit more advanced use-case: + +
+ Mix inline authors and global authors + +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 +--- + +``` + +
+ +
+ Local override of global authors + +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 +--- + +``` + +
+ +
+ Localize the author's configuration file + +The configuration file can be localized, just create a localized copy of it at: + +```bash +website/i18n//docusaurus-plugin-content-blog/authors.yml +``` + +
::: diff --git a/website/docs/guides/docs/docs-introduction.md b/website/docs/guides/docs/docs-introduction.md index b2cd257e91..0612e7b089 100644 --- a/website/docs/guides/docs/docs-introduction.md +++ b/website/docs/guides/docs/docs-introduction.md @@ -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). ::: diff --git a/website/docs/installation.md b/website/docs/installation.md index 8e99c39d00..c632bc83e6 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -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) diff --git a/website/docs/migration/migration-manual.md b/website/docs/migration/migration-manual.md index 5c477cf38e..e915c815e6 100644 --- a/website/docs/migration/migration-manual.md +++ b/website/docs/migration/migration-manual.md @@ -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} diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index fcd2e81eec..4c3a7a462e 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -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.`,