mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-29 18:27:56 +02:00
fix(v2): improve BlogPostFrontMatter schema validation (#4759)
* fix(v2): improve BlogPostFrontMatter schema validation * Edit doc * Add deprecate warning message * minor changes, disable warnings temporarily * only disable warnings + fix frontmatter date type Co-authored-by: Nam Hoang Le <nam.hoang.le@mgm-tp.com> Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
parent
b33ee8226d
commit
e092910627
6 changed files with 326 additions and 72 deletions
|
@ -10,58 +10,255 @@ import {
|
|||
validateBlogPostFrontMatter,
|
||||
} from '../blogFrontMatter';
|
||||
|
||||
function testField(params: {
|
||||
fieldName: keyof BlogPostFrontMatter;
|
||||
validFrontMatters: BlogPostFrontMatter[];
|
||||
convertibleFrontMatter?: [
|
||||
ConvertableFrontMatter: Record<string, unknown>,
|
||||
ConvertedFrontMatter: BlogPostFrontMatter,
|
||||
][];
|
||||
invalidFrontMatters?: [
|
||||
InvalidFrontMatter: Record<string, unknown>,
|
||||
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'],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<BlogPostFrontMatter>({
|
|||
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<string, unknown>,
|
||||
|
|
|
@ -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]);
|
||||
|
||||
|
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -106,21 +106,38 @@ export function validateFrontMatter<T>(
|
|||
frontMatter: Record<string, unknown>,
|
||||
schema: Joi.ObjectSchema<T>,
|
||||
): 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;
|
||||
}
|
||||
|
|
|
@ -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 (`/<routeBasePath>/<slug>`). 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 `<meta name="description" content="..."/>` and `<meta property="og:description" content="..."/>` in `<head>`, 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 `<meta name="keywords" content="keyword1,keyword2,..."/>` in `<head>`, 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}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue