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 d343a47aeb23..f9e92db7e69d 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/blogFrontMatter.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/blogFrontMatter.test.ts @@ -10,58 +10,255 @@ import { validateBlogPostFrontMatter, } from '../blogFrontMatter'; +function testField(params: { + fieldName: keyof BlogPostFrontMatter; + validFrontMatters: BlogPostFrontMatter[]; + convertibleFrontMatter?: [ + ConvertableFrontMatter: Record, + ConvertedFrontMatter: BlogPostFrontMatter, + ][]; + invalidFrontMatters?: [ + InvalidFrontMatter: Record, + ErrorMessage: string, + ][]; +}) { + describe(`"${params.fieldName}" field`, () => { + test('accept valid values', () => { + params.validFrontMatters.forEach((frontMatter) => { + expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter); + }); + }); + + test('convert valid values', () => { + params.convertibleFrontMatter?.forEach( + ([convertibleFrontMatter, convertedFrontMatter]) => { + expect(validateBlogPostFrontMatter(convertibleFrontMatter)).toEqual( + convertedFrontMatter, + ); + }, + ); + }); + + test('throw error for values', () => { + params.invalidFrontMatters?.forEach(([frontMatter, message]) => { + expect(() => validateBlogPostFrontMatter(frontMatter)).toThrow(message); + }); + }); + }); +} + describe('validateBlogPostFrontMatter', () => { test('accept empty object', () => { const frontMatter = {}; expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter); }); - test('accept valid values', () => { - const frontMatter: BlogPostFrontMatter = { - id: 'blog', - title: 'title', - description: 'description', - date: 'date', - slug: 'slug', - draft: true, - tags: ['hello', {label: 'tagLabel', permalink: '/tagPermalink'}], - }; + test('accept unknown field', () => { + const frontMatter = {abc: '1'}; expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter); }); - // See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398 - test('accept empty title', () => { - const frontMatter: BlogPostFrontMatter = {title: ''}; - expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter); + testField({ + fieldName: 'description', + validFrontMatters: [ + // See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398 + {description: ''}, + {description: 'description'}, + ], }); - // See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398 - test('accept empty description', () => { - const frontMatter: BlogPostFrontMatter = {description: ''}; - expect(validateBlogPostFrontMatter(frontMatter)).toEqual(frontMatter); + testField({ + fieldName: 'title', + validFrontMatters: [ + // See https://github.com/facebook/docusaurus/issues/4591#issuecomment-822372398 + {title: ''}, + {title: 'title'}, + ], }); - // See https://github.com/facebook/docusaurus/issues/4642 - test('convert tags as numbers', () => { - const frontMatter: BlogPostFrontMatter = { - tags: [ - // @ts-expect-error: number for test - 42, - { - // @ts-expect-error: number for test - label: 84, - permalink: '/tagPermalink', - }, + testField({ + fieldName: 'id', + validFrontMatters: [{id: '123'}, {id: 'id'}], + invalidFrontMatters: [[{id: ''}, 'is not allowed to be empty']], + }); + + testField({ + fieldName: 'author', + validFrontMatters: [{author: '123'}, {author: 'author'}], + invalidFrontMatters: [[{author: ''}, 'is not allowed to be empty']], + }); + + testField({ + fieldName: 'authorTitle', + validFrontMatters: [{authorTitle: '123'}, {authorTitle: 'authorTitle'}], + invalidFrontMatters: [[{authorTitle: ''}, 'is not allowed to be empty']], + }); + + testField({ + fieldName: 'author_title', + validFrontMatters: [{author_title: '123'}, {author_title: 'author_title'}], + invalidFrontMatters: [[{author_title: ''}, 'is not allowed to be empty']], + }); + + testField({ + fieldName: 'authorURL', + validFrontMatters: [{authorURL: 'https://docusaurus.io'}], + invalidFrontMatters: [ + [{authorURL: ''}, 'is not allowed to be empty'], + [{authorURL: '@site/api/author'}, 'must be a valid uri'], + [{authorURL: '../../api/author'}, 'must be a valid uri'], + ], + }); + + testField({ + fieldName: 'author_url', + validFrontMatters: [{author_url: 'https://docusaurus.io'}], + invalidFrontMatters: [ + [{author_url: ''}, 'is not allowed to be empty'], + [{author_url: '@site/api/author'}, 'must be a valid uri'], + [{author_url: '../../api/author'}, 'must be a valid uri'], + ], + }); + + testField({ + fieldName: 'authorImageURL', + validFrontMatters: [ + {authorImageURL: 'https://docusaurus.io/asset/image.png'}, + ], + invalidFrontMatters: [ + [{authorImageURL: ''}, 'is not allowed to be empty'], + [{authorImageURL: '@site/api/asset/image.png'}, 'must be a valid uri'], + [{authorImageURL: '../../api/asset/image.png'}, 'must be a valid uri'], + ], + }); + + testField({ + fieldName: 'author_image_url', + validFrontMatters: [ + {author_image_url: 'https://docusaurus.io/asset/image.png'}, + ], + invalidFrontMatters: [ + [{author_image_url: ''}, 'is not allowed to be empty'], + [{author_image_url: '@site/api/asset/image.png'}, 'must be a valid uri'], + [{author_image_url: '../../api/asset/image.png'}, 'must be a valid uri'], + ], + }); + + testField({ + fieldName: 'slug', + validFrontMatters: [ + {slug: 'blog/'}, + {slug: '/blog'}, + {slug: '/blog/'}, + {slug: './blog'}, + {slug: '../blog'}, + {slug: '../../blog'}, + {slug: '/api/plugins/@docusaurus/plugin-debug'}, + {slug: '@site/api/asset/image.png'}, + ], + invalidFrontMatters: [[{slug: ''}, 'is not allowed to be empty']], + }); + + testField({ + fieldName: 'image', + validFrontMatters: [ + {image: 'blog/'}, + {image: '/blog'}, + {image: '/blog/'}, + {image: './blog'}, + {image: '../blog'}, + {image: '../../blog'}, + {image: '/api/plugins/@docusaurus/plugin-debug'}, + {image: '@site/api/asset/image.png'}, + ], + invalidFrontMatters: [ + [{image: ''}, 'is not allowed to be empty'], + [{image: 'https://docusaurus.io'}, 'must be a valid relative uri'], + [ + {image: 'https://docusaurus.io/blog/image.png'}, + 'must be a valid relative uri', ], - }; - expect(validateBlogPostFrontMatter(frontMatter)).toEqual({ - tags: [ - '42', - { - label: '84', - permalink: '/tagPermalink', - }, + ], + }); + + testField({ + fieldName: 'tags', + validFrontMatters: [ + {tags: []}, + {tags: ['hello']}, + {tags: ['hello', 'world']}, + {tags: ['hello', 'world']}, + {tags: ['hello', {label: 'tagLabel', permalink: '/tagPermalink'}]}, + ], + invalidFrontMatters: [ + [{tags: ''}, 'must be an array'], + [{tags: ['']}, 'is not allowed to be empty'], + ], + // See https://github.com/facebook/docusaurus/issues/4642 + convertibleFrontMatter: [ + [{tags: [42]}, {tags: ['42']}], + [ + {tags: [{label: 84, permalink: '/tagPermalink'}]}, + {tags: [{label: '84', permalink: '/tagPermalink'}]}, ], - }); + ], + }); + + testField({ + fieldName: 'keywords', + validFrontMatters: [ + {keywords: ['hello']}, + {keywords: ['hello', 'world']}, + {keywords: ['hello', 'world']}, + {keywords: ['hello']}, + ], + invalidFrontMatters: [ + [{keywords: ''}, 'must be an array'], + [{keywords: ['']}, 'is not allowed to be empty'], + [{keywords: []}, 'does not contain 1 required value(s)'], + ], + }); + + testField({ + fieldName: 'draft', + validFrontMatters: [{draft: true}, {draft: false}], + convertibleFrontMatter: [ + [{draft: 'true'}, {draft: true}], + [{draft: 'false'}, {draft: false}], + ], + invalidFrontMatters: [ + [{draft: 'yes'}, 'must be a boolean'], + [{draft: 'no'}, 'must be a boolean'], + ], + }); + + testField({ + fieldName: 'hide_table_of_contents', + validFrontMatters: [ + {hide_table_of_contents: true}, + {hide_table_of_contents: false}, + ], + convertibleFrontMatter: [ + [{hide_table_of_contents: 'true'}, {hide_table_of_contents: true}], + [{hide_table_of_contents: 'false'}, {hide_table_of_contents: false}], + ], + invalidFrontMatters: [ + [{hide_table_of_contents: 'yes'}, 'must be a boolean'], + [{hide_table_of_contents: 'no'}, 'must be a boolean'], + ], + }); + + testField({ + fieldName: 'date', + validFrontMatters: [ + // @ts-expect-error: number for test + {date: new Date('2020-01-01')}, + {date: '2020-01-01'}, + {date: '2020'}, + ], + invalidFrontMatters: [ + [{date: 'abc'}, 'must be a valid date'], + [{date: ''}, 'must be a valid date'], + ], }); }); diff --git a/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts b/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts index ed051aacba73..ca52a62efdb9 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts @@ -5,13 +5,14 @@ * LICENSE file in the root directory of this source tree. */ +/* eslint-disable camelcase */ + import { JoiFrontMatter as Joi, // Custom instance for frontmatter validateFrontMatter, } from '@docusaurus/utils-validation'; import {Tag} from './types'; -// TODO complete this frontmatter + add unit tests export type BlogPostFrontMatter = { id?: string; title?: string; @@ -19,7 +20,21 @@ export type BlogPostFrontMatter = { tags?: (string | Tag)[]; slug?: string; draft?: boolean; - date?: string; + date?: Date; + + author?: string; + author_title?: string; + author_url?: string; + author_image_url?: string; + + image?: string; + keywords?: string[]; + hide_table_of_contents?: boolean; + + /** @deprecated */ + authorTitle?: string; + authorURL?: string; + authorImageURL?: string; }; // NOTE: we don't add any default value on purpose here @@ -39,10 +54,31 @@ const BlogFrontMatterSchema = Joi.object({ title: Joi.string().allow(''), description: Joi.string().allow(''), tags: Joi.array().items(BlogTagSchema), - slug: Joi.string(), draft: Joi.boolean(), - date: Joi.string().allow(''), // TODO validate the date better! -}).unknown(); + date: Joi.date().raw(), + + author: Joi.string(), + author_title: Joi.string(), + author_url: Joi.string().uri(), + author_image_url: Joi.string().uri(), + slug: Joi.string(), + image: Joi.string().uri({relativeOnly: true}), + 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 + authorURL: Joi.string().uri(), + // .warning('deprecate.error', { alternative: '"author_url"'}), + authorTitle: Joi.string(), + // .warning('deprecate.error', { alternative: '"author_title"'}), + authorImageURL: Joi.string().uri(), + // .warning('deprecate.error', { alternative: '"author_image_url"'}), +}) + .unknown() + .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 759fb74973fc..dbcb29eefd5d 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts @@ -46,7 +46,7 @@ export function getSourceToPermalink( // YYYY-MM-DD-{name}.mdx? // Prefer named capture, but older Node versions do not support it. -const FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/; +const DATE_FILENAME_PATTERN = /^(\d{4}-\d{1,2}-\d{1,2})-?(.*?).mdx?$/; function toUrl({date, link}: DateLink) { return `${date @@ -165,24 +165,24 @@ export async function generateBlogPosts( ); } - let date; + let date: Date | undefined; // Extract date and title from filename. - const match = blogFileName.match(FILENAME_PATTERN); + const dateFilenameMatch = blogFileName.match(DATE_FILENAME_PATTERN); let linkName = blogFileName.replace(/\.mdx?$/, ''); - if (match) { - const [, dateString, name] = match; + if (dateFilenameMatch) { + const [, dateString, name] = dateFilenameMatch; date = new Date(dateString); linkName = name; } // Prefer user-defined date. if (frontMatter.date) { - date = new Date(frontMatter.date); + date = frontMatter.date; } // Use file create time for blog. - date = date || (await fs.stat(source)).birthtime; + date = date ?? (await fs.stat(source)).birthtime; const formattedDate = new Intl.DateTimeFormat(i18n.currentLocale, { day: 'numeric', month: 'long', @@ -193,7 +193,8 @@ export async function generateBlogPosts( const description = frontMatter.description ?? excerpt ?? ''; const slug = - frontMatter.slug || (match ? toUrl({date, link: linkName}) : linkName); + frontMatter.slug || + (dateFilenameMatch ? toUrl({date, link: linkName}) : linkName); const permalink = normalizeUrl([baseUrl, routeBasePath, slug]); diff --git a/packages/docusaurus-utils-validation/src/__tests__/validationUtils.test.ts b/packages/docusaurus-utils-validation/src/__tests__/validationUtils.test.ts index 45bc5ec1386b..7ecb1c081c96 100644 --- a/packages/docusaurus-utils-validation/src/__tests__/validationUtils.test.ts +++ b/packages/docusaurus-utils-validation/src/__tests__/validationUtils.test.ts @@ -31,7 +31,7 @@ describe('validateFrontMatter', () => { validateFrontMatter(frontMatter, schema), ).toThrowErrorMatchingInlineSnapshot(`"\\"test\\" must be a string"`); expect(consoleError).toHaveBeenCalledWith( - expect.stringContaining('FrontMatter contains invalid values: '), + expect.stringContaining('The following FrontMatter'), ); }); diff --git a/packages/docusaurus-utils-validation/src/validationUtils.ts b/packages/docusaurus-utils-validation/src/validationUtils.ts index 72a0f75a8907..4ad56e53ab6d 100644 --- a/packages/docusaurus-utils-validation/src/validationUtils.ts +++ b/packages/docusaurus-utils-validation/src/validationUtils.ts @@ -106,21 +106,38 @@ export function validateFrontMatter( frontMatter: Record, schema: Joi.ObjectSchema, ): T { - try { - return JoiFrontMatter.attempt(frontMatter, schema, { - convert: true, - allowUnknown: true, - }); - } catch (e) { + const {value, error, warning} = schema.validate(frontMatter, { + convert: true, + allowUnknown: true, + abortEarly: false, + }); + + if (error) { + const frontMatterString = JSON.stringify(frontMatter, null, 2); + const errorDetails = error.details; + const invalidFields = errorDetails.map(({path}) => path).join(', '); + const errorMessages = errorDetails + .map(({message}) => ` - ${message}`) + .join('\n'); + + logValidationBugReportHint(); + console.error( chalk.red( - `FrontMatter contains invalid values: ${JSON.stringify( - frontMatter, - null, - 2, - )}`, + `The following FrontMatter:\n${chalk.yellow( + frontMatterString, + )}\ncontains invalid values for field(s): ${invalidFields}.\n${errorMessages}\n`, ), ); - throw e; + throw error; + } + + if (warning) { + const warningMessages = warning.details + .map(({message}) => message) + .join('\n'); + console.log(chalk.yellow(warningMessages)); } + + return value; } diff --git a/website/docs/blog.md b/website/docs/blog.md index 2aa5d2d649e9..91278891340d 100644 --- a/website/docs/blog.md +++ b/website/docs/blog.md @@ -55,16 +55,19 @@ A whole bunch of exploration to follow. The only required field is `title`; however, we provide options to add author information to your blog post as well along with other options. -- `author` - The author name to be displayed. -- `author_url` - The URL that the author's name will be linked to. This could be a GitHub, Twitter, Facebook profile URL, etc. -- `author_image_url` - The URL to the author's thumbnail image. -- `author_title` - A description of the author. -- `title` - The blog post title. -- `tags` - A list of strings to tag to your post. -- `draft` - A boolean flag to indicate that the blog post is work-in-progress and therefore should not be published yet. However, draft blog posts will be displayed during development. +- `author`: The author name to be displayed. +- `author_url`: The URL that the author's name will be linked to. This could be a GitHub, Twitter, Facebook profile URL, etc. +- `author_image_url`: The URL to the author's thumbnail image. +- `author_title`: A description of the author. +- `title`: The blog post title. +- `slug`: Allows to customize the blog post url (`//`). Support multiple patterns: `slug: my-blog-post`, `slug: /my/path/to/blog/post`, slug: `/`. +- `date`: The blog post creation date. If not specified, this could be extracted from the file name, e.g, `2021-04-15-blog-post.mdx`. By default, it is the markdown file creation time. +- `tags`: A list of strings or objects of two string fields `label` and `permalink` to tag to your post. +- `draft`: A boolean flag to indicate that the blog post is work-in-progress and therefore should not be published yet. However, draft blog posts will be displayed during development. - `description`: The description of your post, which will become the `` and `` in ``, used by search engines. If this field is not present, it will default to the first line of the contents. +- `keywords`: Keywords meta tag, which will become the `` in ``, used by search engines. - `image`: Cover or thumbnail image that will be used when displaying the link to your post. -- `hide_table_of_contents`: Whether to hide the table of contents to the right. By default it is `false`. +- `hide_table_of_contents`: Whether to hide the table of contents to the right. By default, it is `false`. ## Summary truncation {#summary-truncation}