feat(v2): option and config validation life cycle method for official plugins (#2943)

* add validation for blog plugin

* fix wrong default component

* fix test and add yup to package.json

* remove console.log

* add validation for classic theme and code block theme

* add yup to packages

* remove console.log

* fix build

* fix logo required

* replaced yup with joi

* fix test

* remove hapi from docusuars core

* replace joi with @hapi/joi

* fix eslint

* fix remark plugin type

* change remark plugin validation to match documentation

* move schema to it's own file

* allow unknown only on outer theme object

* fix type for schema type

* fix yarn.lock

* support both commonjs and ES modules

* add docs for new lifecycle method
This commit is contained in:
Anshul Goyal 2020-06-24 23:38:16 +05:30 committed by GitHub
parent ce10646606
commit 81d855355e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 490 additions and 63 deletions

View file

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`throw Error in case of invalid feedtype 1`] = `[ValidationError: "feedOptions.type" does not match any of the allowed types]`;
exports[`throw Error in case of invalid options 1`] = `[ValidationError: "postsPerPage" must be larger than or equal to 1]`;

View file

@ -9,6 +9,16 @@ import fs from 'fs-extra';
import path from 'path';
import pluginContentBlog from '../index';
import {DocusaurusConfig, LoadContext} from '@docusaurus/types';
import {PluginOptionSchema} from '../validation';
function validateAndNormalize(schema, options) {
const {value, error} = schema.validate(options);
if (error) {
throw error;
} else {
return value;
}
}
describe('loadBlog', () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
@ -26,11 +36,11 @@ describe('loadBlog', () => {
siteConfig,
generatedFilesDir,
} as LoadContext,
{
validateAndNormalize(PluginOptionSchema, {
path: pluginPath,
editUrl:
'https://github.com/facebook/docusaurus/edit/master/website-1x',
},
}),
);
const {blogPosts} = await plugin.loadContent();

View file

@ -0,0 +1,60 @@
/**
* 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 {PluginOptionSchema, DefaultOptions} from '../validation';
test('normalize options', () => {
const {value} = PluginOptionSchema.validate({});
expect(value).toEqual(DefaultOptions);
});
test('validate options', () => {
const {value} = PluginOptionSchema.validate({
path: 'not_blog',
postsPerPage: 5,
include: ['api/*', 'docs/*'],
routeBasePath: 'not_blog',
});
expect(value).toEqual({
...DefaultOptions,
postsPerPage: 5,
include: ['api/*', 'docs/*'],
routeBasePath: 'not_blog',
path: 'not_blog',
});
});
test('throw Error in case of invalid options', () => {
const {error} = PluginOptionSchema.validate({
path: 'not_blog',
postsPerPage: -1,
include: ['api/*', 'docs/*'],
routeBasePath: 'not_blog',
});
expect(error).toMatchSnapshot();
});
test('throw Error in case of invalid feedtype', () => {
const {error} = PluginOptionSchema.validate({
feedOptions: {
type: 'none',
},
});
expect(error).toMatchSnapshot();
});
test('convert all feed type to array with other feed type', () => {
const {value} = PluginOptionSchema.validate({
feedOptions: {type: 'all'},
});
expect(value).toEqual({
...DefaultOptions,
feedOptions: {type: ['rss', 'atom']},
});
});

View file

@ -10,6 +10,7 @@ import kebabCase from 'lodash.kebabcase';
import path from 'path';
import admonitions from 'remark-admonitions';
import {normalizeUrl, docuHash, aliasedSitePath} from '@docusaurus/utils';
import {ValidationError} from '@hapi/joi';
import {
PluginOptions,
@ -18,9 +19,9 @@ import {
BlogItemsToMetadata,
TagsModule,
BlogPaginated,
FeedType,
BlogPost,
} from './types';
import {PluginOptionSchema} from './validation';
import {
LoadContext,
PluginContentLoadedActions,
@ -28,56 +29,19 @@ import {
Props,
Plugin,
HtmlTags,
OptionValidationContext,
ValidationResult,
} from '@docusaurus/types';
import {Configuration, Loader} from 'webpack';
import {generateBlogFeed, generateBlogPosts} from './blogUtils';
const DEFAULT_OPTIONS: PluginOptions = {
path: 'blog', // Path to data on filesystem, relative to site dir.
routeBasePath: 'blog', // URL Route.
include: ['*.md', '*.mdx'], // Extensions to include.
postsPerPage: 10, // How many posts per page.
blogListComponent: '@theme/BlogListPage',
blogPostComponent: '@theme/BlogPostPage',
blogTagsListComponent: '@theme/BlogTagsListPage',
blogTagsPostsComponent: '@theme/BlogTagsPostsPage',
showReadingTime: true,
remarkPlugins: [],
rehypePlugins: [],
editUrl: undefined,
truncateMarker: /<!--\s*(truncate)\s*-->/, // Regex.
admonitions: {},
};
function assertFeedTypes(val: any): asserts val is FeedType {
if (typeof val !== 'string' && !['rss', 'atom', 'all'].includes(val)) {
throw new Error(
`Invalid feedOptions type: ${val}. It must be either 'rss', 'atom', or 'all'`,
);
}
}
const getFeedTypes = (type?: FeedType) => {
assertFeedTypes(type);
let feedTypes: ('rss' | 'atom')[] = [];
if (type === 'all') {
feedTypes = ['rss', 'atom'];
} else {
feedTypes.push(type);
}
return feedTypes;
};
export default function pluginContentBlog(
context: LoadContext,
opts: Partial<PluginOptions>,
): Plugin<BlogContent | null> {
const options: PluginOptions = {...DEFAULT_OPTIONS, ...opts};
options: PluginOptions,
): Plugin<BlogContent | null, typeof PluginOptionSchema> {
if (options.admonitions) {
options.remarkPlugins = options.remarkPlugins.concat([
[admonitions, opts.admonitions || {}],
[admonitions, options.admonitions],
]);
}
@ -426,7 +390,7 @@ export default function pluginContentBlog(
},
async postBuild({outDir}: Props) {
if (!options.feedOptions) {
if (!options.feedOptions?.type) {
return;
}
@ -436,7 +400,7 @@ export default function pluginContentBlog(
return;
}
const feedTypes = getFeedTypes(options.feedOptions?.type);
const feedTypes = options.feedOptions.type;
await Promise.all(
feedTypes.map(async (feedType) => {
@ -456,11 +420,10 @@ export default function pluginContentBlog(
},
injectHtmlTags() {
if (!options.feedOptions) {
if (!options.feedOptions?.type) {
return {};
}
const feedTypes = getFeedTypes(options.feedOptions?.type);
const feedTypes = options.feedOptions.type;
const {
siteConfig: {title},
baseUrl,
@ -509,3 +472,14 @@ export default function pluginContentBlog(
},
};
}
export function validateOptions({
validate,
options,
}: OptionValidationContext<PluginOptions, ValidationError>): ValidationResult<
PluginOptions,
ValidationError
> {
const validatedOptions = validate(PluginOptionSchema, options);
return validatedOptions;
}

View file

@ -17,7 +17,7 @@ export interface DateLink {
link: string;
}
export type FeedType = 'rss' | 'atom' | 'all';
export type FeedType = 'rss' | 'atom';
export interface PluginOptions {
path: string;
@ -32,8 +32,8 @@ export interface PluginOptions {
rehypePlugins: string[];
truncateMarker: RegExp;
showReadingTime: boolean;
feedOptions?: {
type: FeedType;
feedOptions: {
type: [FeedType];
title?: string;
description?: string;
copyright: string;

View file

@ -0,0 +1,80 @@
/**
* 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 * as Joi from '@hapi/joi';
export const DefaultOptions = {
feedOptions: {},
beforeDefaultRehypePlugins: [],
beforeDefaultRemarkPlugins: [],
admonitions: {},
truncateMarker: /<!--\s*(truncate)\s*-->/,
rehypePlugins: [],
remarkPlugins: [],
showReadingTime: true,
blogTagsPostsComponent: '@theme/BlogTagsPostsPage',
blogTagsListComponent: '@theme/BlogTagsListPage',
blogPostComponent: '@theme/BlogPostPage',
blogListComponent: '@theme/BlogListPage',
postsPerPage: 10,
include: ['*.md', '*.mdx'],
routeBasePath: 'blog',
path: 'blog',
};
export const PluginOptionSchema = Joi.object({
path: Joi.string().default(DefaultOptions.path),
routeBasePath: Joi.string().default(DefaultOptions.routeBasePath),
include: Joi.array().items(Joi.string()).default(DefaultOptions.include),
postsPerPage: Joi.number()
.integer()
.min(1)
.default(DefaultOptions.postsPerPage),
blogListComponent: Joi.string().default(DefaultOptions.blogListComponent),
blogPostComponent: Joi.string().default(DefaultOptions.blogPostComponent),
blogTagsListComponent: Joi.string().default(
DefaultOptions.blogTagsListComponent,
),
blogTagsPostsComponent: Joi.string().default(
DefaultOptions.blogTagsPostsComponent,
),
showReadingTime: Joi.bool().default(DefaultOptions.showReadingTime),
remarkPlugins: Joi.array()
.items(
Joi.alternatives().try(
Joi.function(),
Joi.array()
.items(Joi.function().required(), Joi.object().required())
.length(2),
),
)
.default(DefaultOptions.remarkPlugins),
rehypePlugins: Joi.array()
.items(Joi.string())
.default(DefaultOptions.rehypePlugins),
editUrl: Joi.string().uri(),
truncateMarker: Joi.object().default(DefaultOptions.truncateMarker),
admonitions: Joi.object().default(DefaultOptions.admonitions),
beforeDefaultRemarkPlugins: Joi.array()
.items(Joi.object())
.default(DefaultOptions.beforeDefaultRemarkPlugins),
beforeDefaultRehypePlugins: Joi.array()
.items(Joi.object())
.default(DefaultOptions.beforeDefaultRehypePlugins),
feedOptions: Joi.object({
type: Joi.alternatives().conditional(
Joi.string().equal('all', 'rss', 'atom'),
{
then: Joi.custom((val) => (val === 'all' ? ['rss', 'atom'] : [val])),
},
),
title: Joi.string(),
description: Joi.string(),
copyright: Joi.string(),
language: Joi.string(),
}).default(DefaultOptions.feedOptions),
});