Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(v2): exhaustive BlogPostFrontMatter schema validation #4759

Merged
merged 5 commits into from
May 14, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
],
});
});
32 changes: 29 additions & 3 deletions packages/docusaurus-plugin-content-blog/src/blogFrontMatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +21,18 @@ export type BlogPostFrontMatter = {
slug?: string;
draft?: boolean;
date?: string;

author?: string;
authorTitle?: string;
nam-hle marked this conversation as resolved.
Show resolved Hide resolved
author_title?: string;
authorURL?: string;
author_url?: string;
authorImageURL?: string;
author_image_url?: string;

image?: string;
keywords?: string[];
hide_table_of_contents?: boolean;
};

// NOTE: we don't add any default value on purpose here
Expand All @@ -39,9 +52,22 @@ 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!
date: Joi.date().raw(),

author: Joi.string(),
authorTitle: Joi.string(),
nam-hle marked this conversation as resolved.
Show resolved Hide resolved
author_title: Joi.string(),

authorURL: Joi.string().uri(),
author_url: Joi.string().uri(),
authorImageURL: 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(),
}).unknown();

export function validateBlogPostFrontMatter(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
);
});

Expand Down
18 changes: 13 additions & 5 deletions packages/docusaurus-utils-validation/src/validationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,23 @@ export function validateFrontMatter<T>(
return JoiFrontMatter.attempt(frontMatter, schema, {
convert: true,
allowUnknown: true,
abortEarly: false,
});
} catch (e) {
const frontMatterString = JSON.stringify(frontMatter, null, 2);
const errorDetails = (e as Joi.ValidationError).details;
const invalidFields = errorDetails.map(({path}) => path).join(', ');
const errorMessages = errorDetails
.map(({message}) => ` - ${message}`)
.join('\n');

logValidationBugReportHint();
slorber marked this conversation as resolved.
Show resolved Hide resolved

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;
Expand Down
Loading