mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-10 07:37:19 +02:00
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:
parent
ce10646606
commit
81d855355e
18 changed files with 490 additions and 63 deletions
|
@ -11,8 +11,11 @@ import {RedirectMetadata} from './types';
|
||||||
|
|
||||||
export const PathnameValidator = Joi.string()
|
export const PathnameValidator = Joi.string()
|
||||||
.custom((val) => {
|
.custom((val) => {
|
||||||
if (!isValidPathname(val)) throw new Error();
|
if (!isValidPathname(val)) {
|
||||||
else return val;
|
throw new Error();
|
||||||
|
} else {
|
||||||
|
return val;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.message(
|
.message(
|
||||||
'{{#label}} is not a valid pathname. Pathname should start with / and not contain any domain or query string',
|
'{{#label}} is not a valid pathname. Pathname should start with / and not contain any domain or query string',
|
||||||
|
|
|
@ -11,10 +11,14 @@
|
||||||
"access": "public"
|
"access": "public"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/hapi__joi": "^17.1.2"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/mdx-loader": "^2.0.0-alpha.58",
|
"@docusaurus/mdx-loader": "^2.0.0-alpha.58",
|
||||||
"@docusaurus/types": "^2.0.0-alpha.58",
|
"@docusaurus/types": "^2.0.0-alpha.58",
|
||||||
"@docusaurus/utils": "^2.0.0-alpha.58",
|
"@docusaurus/utils": "^2.0.0-alpha.58",
|
||||||
|
"@hapi/joi": "^17.1.1",
|
||||||
"feed": "^4.1.0",
|
"feed": "^4.1.0",
|
||||||
"fs-extra": "^8.1.0",
|
"fs-extra": "^8.1.0",
|
||||||
"globby": "^10.0.1",
|
"globby": "^10.0.1",
|
||||||
|
|
|
@ -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]`;
|
|
@ -9,6 +9,16 @@ import fs from 'fs-extra';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import pluginContentBlog from '../index';
|
import pluginContentBlog from '../index';
|
||||||
import {DocusaurusConfig, LoadContext} from '@docusaurus/types';
|
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', () => {
|
describe('loadBlog', () => {
|
||||||
const siteDir = path.join(__dirname, '__fixtures__', 'website');
|
const siteDir = path.join(__dirname, '__fixtures__', 'website');
|
||||||
|
@ -26,11 +36,11 @@ describe('loadBlog', () => {
|
||||||
siteConfig,
|
siteConfig,
|
||||||
generatedFilesDir,
|
generatedFilesDir,
|
||||||
} as LoadContext,
|
} as LoadContext,
|
||||||
{
|
validateAndNormalize(PluginOptionSchema, {
|
||||||
path: pluginPath,
|
path: pluginPath,
|
||||||
editUrl:
|
editUrl:
|
||||||
'https://github.com/facebook/docusaurus/edit/master/website-1x',
|
'https://github.com/facebook/docusaurus/edit/master/website-1x',
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
const {blogPosts} = await plugin.loadContent();
|
const {blogPosts} = await plugin.loadContent();
|
||||||
|
|
||||||
|
|
|
@ -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']},
|
||||||
|
});
|
||||||
|
});
|
|
@ -10,6 +10,7 @@ import kebabCase from 'lodash.kebabcase';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import admonitions from 'remark-admonitions';
|
import admonitions from 'remark-admonitions';
|
||||||
import {normalizeUrl, docuHash, aliasedSitePath} from '@docusaurus/utils';
|
import {normalizeUrl, docuHash, aliasedSitePath} from '@docusaurus/utils';
|
||||||
|
import {ValidationError} from '@hapi/joi';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PluginOptions,
|
PluginOptions,
|
||||||
|
@ -18,9 +19,9 @@ import {
|
||||||
BlogItemsToMetadata,
|
BlogItemsToMetadata,
|
||||||
TagsModule,
|
TagsModule,
|
||||||
BlogPaginated,
|
BlogPaginated,
|
||||||
FeedType,
|
|
||||||
BlogPost,
|
BlogPost,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import {PluginOptionSchema} from './validation';
|
||||||
import {
|
import {
|
||||||
LoadContext,
|
LoadContext,
|
||||||
PluginContentLoadedActions,
|
PluginContentLoadedActions,
|
||||||
|
@ -28,56 +29,19 @@ import {
|
||||||
Props,
|
Props,
|
||||||
Plugin,
|
Plugin,
|
||||||
HtmlTags,
|
HtmlTags,
|
||||||
|
OptionValidationContext,
|
||||||
|
ValidationResult,
|
||||||
} from '@docusaurus/types';
|
} from '@docusaurus/types';
|
||||||
import {Configuration, Loader} from 'webpack';
|
import {Configuration, Loader} from 'webpack';
|
||||||
import {generateBlogFeed, generateBlogPosts} from './blogUtils';
|
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(
|
export default function pluginContentBlog(
|
||||||
context: LoadContext,
|
context: LoadContext,
|
||||||
opts: Partial<PluginOptions>,
|
options: PluginOptions,
|
||||||
): Plugin<BlogContent | null> {
|
): Plugin<BlogContent | null, typeof PluginOptionSchema> {
|
||||||
const options: PluginOptions = {...DEFAULT_OPTIONS, ...opts};
|
|
||||||
|
|
||||||
if (options.admonitions) {
|
if (options.admonitions) {
|
||||||
options.remarkPlugins = options.remarkPlugins.concat([
|
options.remarkPlugins = options.remarkPlugins.concat([
|
||||||
[admonitions, opts.admonitions || {}],
|
[admonitions, options.admonitions],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -426,7 +390,7 @@ export default function pluginContentBlog(
|
||||||
},
|
},
|
||||||
|
|
||||||
async postBuild({outDir}: Props) {
|
async postBuild({outDir}: Props) {
|
||||||
if (!options.feedOptions) {
|
if (!options.feedOptions?.type) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -436,7 +400,7 @@ export default function pluginContentBlog(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedTypes = getFeedTypes(options.feedOptions?.type);
|
const feedTypes = options.feedOptions.type;
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
feedTypes.map(async (feedType) => {
|
feedTypes.map(async (feedType) => {
|
||||||
|
@ -456,11 +420,10 @@ export default function pluginContentBlog(
|
||||||
},
|
},
|
||||||
|
|
||||||
injectHtmlTags() {
|
injectHtmlTags() {
|
||||||
if (!options.feedOptions) {
|
if (!options.feedOptions?.type) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
const feedTypes = options.feedOptions.type;
|
||||||
const feedTypes = getFeedTypes(options.feedOptions?.type);
|
|
||||||
const {
|
const {
|
||||||
siteConfig: {title},
|
siteConfig: {title},
|
||||||
baseUrl,
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ export interface DateLink {
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeedType = 'rss' | 'atom' | 'all';
|
export type FeedType = 'rss' | 'atom';
|
||||||
|
|
||||||
export interface PluginOptions {
|
export interface PluginOptions {
|
||||||
path: string;
|
path: string;
|
||||||
|
@ -32,8 +32,8 @@ export interface PluginOptions {
|
||||||
rehypePlugins: string[];
|
rehypePlugins: string[];
|
||||||
truncateMarker: RegExp;
|
truncateMarker: RegExp;
|
||||||
showReadingTime: boolean;
|
showReadingTime: boolean;
|
||||||
feedOptions?: {
|
feedOptions: {
|
||||||
type: FeedType;
|
type: [FeedType];
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
copyright: string;
|
copyright: string;
|
||||||
|
|
80
packages/docusaurus-plugin-content-blog/src/validation.ts
Normal file
80
packages/docusaurus-plugin-content-blog/src/validation.ts
Normal 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),
|
||||||
|
});
|
|
@ -51,7 +51,7 @@ function assertItem<K extends string>(
|
||||||
): asserts item is Record<K, any> {
|
): asserts item is Record<K, any> {
|
||||||
const unknownKeys = Object.keys(item).filter(
|
const unknownKeys = Object.keys(item).filter(
|
||||||
// @ts-expect-error: key is always string
|
// @ts-expect-error: key is always string
|
||||||
(key) => !keys.includes(key) && key !== 'type',
|
(key) => !keys.includes(key as string) && key !== 'type',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (unknownKeys.length) {
|
if (unknownKeys.length) {
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hapi/joi": "^17.1.1",
|
||||||
"@mdx-js/mdx": "^1.5.8",
|
"@mdx-js/mdx": "^1.5.8",
|
||||||
"@mdx-js/react": "^1.5.8",
|
"@mdx-js/react": "^1.5.8",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
|
@ -27,5 +28,8 @@
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.15.1"
|
"node": ">=10.15.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/hapi__joi": "^17.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const Module = require('module');
|
const Module = require('module');
|
||||||
|
const Joi = require('@hapi/joi');
|
||||||
|
|
||||||
const createRequire = Module.createRequire || Module.createRequireFromPath;
|
const createRequire = Module.createRequire || Module.createRequireFromPath;
|
||||||
const requireFromDocusaurusCore = createRequire(
|
const requireFromDocusaurusCore = createRequire(
|
||||||
|
@ -109,3 +110,71 @@ module.exports = function (context, options) {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const NavbarLinkSchema = Joi.object({
|
||||||
|
items: Joi.array().optional().items(Joi.link('#navbarLinkSchema')),
|
||||||
|
to: Joi.string(),
|
||||||
|
href: Joi.string().uri(),
|
||||||
|
prependBaseUrlToHref: Joi.bool().default(true),
|
||||||
|
label: Joi.string(),
|
||||||
|
position: Joi.string().equal('left', 'right').default('left'),
|
||||||
|
activeBasePath: Joi.string(),
|
||||||
|
activeBaseRegex: Joi.string(),
|
||||||
|
className: Joi.string(),
|
||||||
|
'aria-label': Joi.string(),
|
||||||
|
})
|
||||||
|
.xor('href', 'to')
|
||||||
|
.id('navbarLinkSchema');
|
||||||
|
|
||||||
|
const ThemeConfigSchema = Joi.object({
|
||||||
|
disableDarkMode: Joi.bool().default(false),
|
||||||
|
image: Joi.string(),
|
||||||
|
announcementBar: Joi.object({
|
||||||
|
id: Joi.string(),
|
||||||
|
content: Joi.string(),
|
||||||
|
backgroundColor: Joi.string().default('#fff'),
|
||||||
|
textColor: Joi.string().default('#000'),
|
||||||
|
}).optional(),
|
||||||
|
navbar: Joi.object({
|
||||||
|
hideOnScroll: Joi.bool().default(false),
|
||||||
|
links: Joi.array().items(NavbarLinkSchema),
|
||||||
|
title: Joi.string().required(),
|
||||||
|
logo: Joi.object({
|
||||||
|
alt: Joi.string(),
|
||||||
|
src: Joi.string().required(),
|
||||||
|
srcDark: Joi.string(),
|
||||||
|
href: Joi.string(),
|
||||||
|
target: Joi.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
footer: Joi.object({
|
||||||
|
style: Joi.string().equal('dark', 'light').default('light'),
|
||||||
|
logo: Joi.object({
|
||||||
|
alt: Joi.string(),
|
||||||
|
src: Joi.string(),
|
||||||
|
href: Joi.string(),
|
||||||
|
}),
|
||||||
|
copyright: Joi.string(),
|
||||||
|
links: Joi.array().items(
|
||||||
|
Joi.object({
|
||||||
|
title: Joi.string().required(),
|
||||||
|
items: Joi.array().items(
|
||||||
|
Joi.object({
|
||||||
|
to: Joi.string(),
|
||||||
|
href: Joi.string().uri(),
|
||||||
|
html: Joi.string(),
|
||||||
|
label: Joi.string(),
|
||||||
|
})
|
||||||
|
.xor('to', 'href', 'html')
|
||||||
|
.with('to', 'label')
|
||||||
|
.with('href', 'label')
|
||||||
|
.nand('html', 'label'),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.validateThemeConfig = ({validate, themeConfig}) => {
|
||||||
|
return validate(ThemeConfigSchema, themeConfig);
|
||||||
|
};
|
||||||
|
|
|
@ -8,11 +8,13 @@
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hapi/joi": "^17.1.1",
|
||||||
"@philpl/buble": "^0.19.7",
|
"@philpl/buble": "^0.19.7",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
"parse-numeric-range": "^0.0.2",
|
"parse-numeric-range": "^0.0.2",
|
||||||
"prism-react-renderer": "^1.1.0",
|
"prism-react-renderer": "^1.1.0",
|
||||||
"react-live": "^2.2.1"
|
"react-live": "^2.2.1",
|
||||||
|
"yup": "^0.29.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@docusaurus/core": "^2.0.0",
|
"@docusaurus/core": "^2.0.0",
|
||||||
|
@ -21,5 +23,8 @@
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.15.1"
|
"node": ">=10.15.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/hapi__joi": "^17.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const Joi = require('@hapi/joi');
|
||||||
|
|
||||||
module.exports = function () {
|
module.exports = function () {
|
||||||
return {
|
return {
|
||||||
|
@ -29,3 +30,21 @@ module.exports = function () {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ThemeConfigSchema = Joi.object({
|
||||||
|
prism: Joi.object({
|
||||||
|
theme: Joi.object({
|
||||||
|
plain: Joi.alternatives().try(Joi.array(), Joi.object()).required(),
|
||||||
|
styles: Joi.alternatives().try(Joi.array(), Joi.object()).required(),
|
||||||
|
}),
|
||||||
|
darkTheme: Joi.object({
|
||||||
|
plain: Joi.alternatives().try(Joi.array(), Joi.object()).required(),
|
||||||
|
styles: Joi.alternatives().try(Joi.array(), Joi.object()).required(),
|
||||||
|
}),
|
||||||
|
defaultLanguage: Joi.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.validateThemeConfig = ({validate, themeConfig}) => {
|
||||||
|
return validate(ThemeConfigSchema, themeConfig);
|
||||||
|
};
|
||||||
|
|
31
packages/docusaurus-types/src/index.d.ts
vendored
31
packages/docusaurus-types/src/index.d.ts
vendored
|
@ -91,7 +91,7 @@ export type HtmlTags = string | HtmlTagObject | (string | HtmlTagObject)[];
|
||||||
|
|
||||||
export interface Props extends LoadContext, InjectedHtmlTags {
|
export interface Props extends LoadContext, InjectedHtmlTags {
|
||||||
routesPaths: string[];
|
routesPaths: string[];
|
||||||
plugins: Plugin<unknown>[];
|
plugins: Plugin<any, unknown>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginContentLoadedActions {
|
export interface PluginContentLoadedActions {
|
||||||
|
@ -99,9 +99,11 @@ export interface PluginContentLoadedActions {
|
||||||
createData(name: string, data: any): Promise<string>;
|
createData(name: string, data: any): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plugin<T> {
|
export interface Plugin<T, U = unknown> {
|
||||||
name: string;
|
name: string;
|
||||||
loadContent?(): Promise<T>;
|
loadContent?(): Promise<T>;
|
||||||
|
validateOptions?(): ValidationResult<U>;
|
||||||
|
validateThemeConfig?(): ValidationResult<any>;
|
||||||
contentLoaded?({
|
contentLoaded?({
|
||||||
content,
|
content,
|
||||||
actions,
|
actions,
|
||||||
|
@ -201,3 +203,28 @@ interface HtmlTagObject {
|
||||||
*/
|
*/
|
||||||
innerHTML?: string;
|
innerHTML?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult<T, E extends Error = Error> {
|
||||||
|
error?: E;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Validate<T, E extends Error = Error> = (
|
||||||
|
validationSchrema: ValidationSchema<T>,
|
||||||
|
options: Partial<T>,
|
||||||
|
) => ValidationResult<T, E>;
|
||||||
|
|
||||||
|
export interface OptionValidationContext<T, E extends Error = Error> {
|
||||||
|
validate: Validate<T, E>;
|
||||||
|
options: Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeConfigValidationContext<T, E extends Error = Error> {
|
||||||
|
validate: Validate<T, E>;
|
||||||
|
themeConfig: Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationSchema<T> {
|
||||||
|
validate(options: Partial<T>, opt: object): ValidationResult<T>;
|
||||||
|
unknown(): ValidationSchema<T>;
|
||||||
|
}
|
||||||
|
|
|
@ -8,9 +8,35 @@
|
||||||
import Module from 'module';
|
import Module from 'module';
|
||||||
import {join} from 'path';
|
import {join} from 'path';
|
||||||
import importFresh from 'import-fresh';
|
import importFresh from 'import-fresh';
|
||||||
import {LoadContext, Plugin, PluginConfig} from '@docusaurus/types';
|
import {
|
||||||
|
LoadContext,
|
||||||
|
Plugin,
|
||||||
|
PluginConfig,
|
||||||
|
ValidationSchema,
|
||||||
|
} from '@docusaurus/types';
|
||||||
import {CONFIG_FILE_NAME} from '../../constants';
|
import {CONFIG_FILE_NAME} from '../../constants';
|
||||||
|
|
||||||
|
function validate<T>(schema: ValidationSchema<T>, options: Partial<T>) {
|
||||||
|
const {error, value} = schema.validate(options, {
|
||||||
|
convert: false,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAndStrip<T>(schema: ValidationSchema<T>, options: Partial<T>) {
|
||||||
|
const {error, value} = schema.unknown().validate(options, {
|
||||||
|
convert: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export default function initPlugins({
|
export default function initPlugins({
|
||||||
pluginConfigs,
|
pluginConfigs,
|
||||||
context,
|
context,
|
||||||
|
@ -49,9 +75,34 @@ export default function initPlugins({
|
||||||
const pluginModule: any = importFresh(
|
const pluginModule: any = importFresh(
|
||||||
pluginRequire.resolve(pluginModuleImport),
|
pluginRequire.resolve(pluginModuleImport),
|
||||||
);
|
);
|
||||||
return (pluginModule.default || pluginModule)(context, pluginOptions);
|
|
||||||
|
const plugin = pluginModule.default || pluginModule;
|
||||||
|
|
||||||
|
// support both commonjs and ES modules
|
||||||
|
const validateOptions =
|
||||||
|
pluginModule.default?.validateOptions ?? pluginModule.validateOptions;
|
||||||
|
|
||||||
|
if (validateOptions) {
|
||||||
|
const options = validateOptions({
|
||||||
|
validate,
|
||||||
|
options: pluginOptions,
|
||||||
|
});
|
||||||
|
pluginOptions = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
// support both commonjs and ES modules
|
||||||
|
const validateThemeConfig =
|
||||||
|
pluginModule.default?.validateThemeConfig ??
|
||||||
|
pluginModule.validateThemeConfig;
|
||||||
|
|
||||||
|
if (validateThemeConfig) {
|
||||||
|
validateThemeConfig({
|
||||||
|
validate: validateAndStrip,
|
||||||
|
themeConfig: context.siteConfig.themeConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return plugin(context, pluginOptions);
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return plugins;
|
return plugins;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,104 @@ This section is a work in progress.
|
||||||
|
|
||||||
Lifecycle APIs are shared by Themes and Plugins.
|
Lifecycle APIs are shared by Themes and Plugins.
|
||||||
|
|
||||||
|
## `validateOptions({options,validate})`
|
||||||
|
|
||||||
|
Return validated and normalized options for the plugin. This method is called before the plugin is initialized.You must return options since the returned options will be passed to plugin during intialization.
|
||||||
|
|
||||||
|
### `options`
|
||||||
|
|
||||||
|
`validateOptions` is called with `options` passed to plugin for validation and normalization.
|
||||||
|
|
||||||
|
### `validate`
|
||||||
|
|
||||||
|
`validateOptions` is called with `validate` function which takes a **[Joi](https://www.npmjs.com/package/@hapi/joi)** schema and options as argument, returns validated and normalized options. `validate` will automatically handle error and validation config.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
[Joi](https://www.npmjs.com/package/@hapi/joi) is recommended for validation and normalization of options.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
If you don't use **[Joi](https://www.npmjs.com/package/@hapi/joi)** for validation you can throw an Error in case of invalid options and return options in case of success.
|
||||||
|
|
||||||
|
```js {8-11} title="my-plugin/src/index.js"
|
||||||
|
module.exports = function (context, options) {
|
||||||
|
return {
|
||||||
|
name: 'docusaurus-plugin',
|
||||||
|
// rest of methods
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.validateOptions = ({options, validate}) => {
|
||||||
|
const validatedOptions = validate(myValidationSchema, options);
|
||||||
|
return validationOptions;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use ES modules style exports.
|
||||||
|
|
||||||
|
```ts {8-11} title="my-plugin/src/index.ts"
|
||||||
|
export default function (context, options) {
|
||||||
|
return {
|
||||||
|
name: 'docusaurus-plugin',
|
||||||
|
// rest of methods
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateOptions({options, validate}) {
|
||||||
|
const validatedOptions = validate(myValidationSchema, options);
|
||||||
|
return validationOptions;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `validateThemeConfig({themeConfig,validate})`
|
||||||
|
|
||||||
|
Validate `themeConfig` for the plugins and theme. This method is called before the plugin is initialized.
|
||||||
|
|
||||||
|
### `themeConfig`
|
||||||
|
|
||||||
|
`validateThemeConfig` is called with `themeConfig` provided in `docusaurus.config.js` for validation.
|
||||||
|
|
||||||
|
### `validate`
|
||||||
|
|
||||||
|
`validateThemeConfig` is called with `validate` function which takes a **[Joi](https://www.npmjs.com/package/@hapi/joi)** schema and `themeConfig` as argument, returns validated and normalized options. `validate` will automatically handle error and validation config.
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
[Joi](https://www.npmjs.com/package/@hapi/joi) is recommended for validation and normalization of options.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
|
If you don't use **[Joi](https://www.npmjs.com/package/@hapi/joi)** for validation you can throw an Error in case of invalid options.
|
||||||
|
|
||||||
|
```js {8-11} title="my-theme/src/index.js"
|
||||||
|
module.exports = function (context, options) {
|
||||||
|
return {
|
||||||
|
name: 'docusaurus-plugin',
|
||||||
|
// rest of methods
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.validateThemeConfig = ({themeConfig, validate}) => {
|
||||||
|
validate(myValidationSchema, options);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use ES modules style exports.
|
||||||
|
|
||||||
|
```ts {8-11} title="my-theme/src/index.ts"
|
||||||
|
export default function (context, options) {
|
||||||
|
return {
|
||||||
|
name: 'docusaurus-plugin',
|
||||||
|
// rest of methods
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateThemeConfig({themeConfig, validate}) {
|
||||||
|
validate(myValidationSchema, options);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## `getPathsToWatch()`
|
## `getPathsToWatch()`
|
||||||
|
|
||||||
Specifies the paths to watch for plugins and themes. The paths are watched by the dev server so that the plugin lifecycles are reloaded when contents in the watched paths change. Note that the plugins and themes modules are initially called with `context` and `options` from Node, which you may use to find the necessary directory information about the site.
|
Specifies the paths to watch for plugins and themes. The paths are watched by the dev server so that the plugin lifecycles are reloaded when contents in the watched paths change. Note that the plugins and themes modules are initially called with `context` and `options` from Node, which you may use to find the necessary directory information about the site.
|
||||||
|
|
|
@ -172,6 +172,11 @@ There are two lifecycle methods that are essential to theme implementation:
|
||||||
- [`getThemePath()`](lifecycle-apis.md#getthemepath)
|
- [`getThemePath()`](lifecycle-apis.md#getthemepath)
|
||||||
- [`getClientModules()`](lifecycle-apis.md#getclientmodules)
|
- [`getClientModules()`](lifecycle-apis.md#getclientmodules)
|
||||||
|
|
||||||
|
These lifecycle method are not essential but recommended:
|
||||||
|
|
||||||
|
- [`validateThemeConfig({themeConfig,validate})`](lifecycle-apis.md#validatethemeconfigthemeconfigvalidate)
|
||||||
|
- [`validateOptions({options,validate})`](lifecycle-apis.md#validateoptionsoptionsvalidate)
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
||||||
Outline
|
Outline
|
||||||
|
|
15
yarn.lock
15
yarn.lock
|
@ -2839,7 +2839,7 @@
|
||||||
version "17.1.2"
|
version "17.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/hapi__joi/-/hapi__joi-17.1.2.tgz#f547d45b5d33677d1807ec217aeee832dc7e6334"
|
resolved "https://registry.yarnpkg.com/@types/hapi__joi/-/hapi__joi-17.1.2.tgz#f547d45b5d33677d1807ec217aeee832dc7e6334"
|
||||||
integrity sha512-2S6+hBISRQ5Ca6/9zfQi7zPueWMGyZxox6xicqJuW1/aC/6ambLyh+gDqY5fi8JBuHmGKMHldSfEpIXJtTmGKQ==
|
integrity sha512-2S6+hBISRQ5Ca6/9zfQi7zPueWMGyZxox6xicqJuW1/aC/6ambLyh+gDqY5fi8JBuHmGKMHldSfEpIXJtTmGKQ==
|
||||||
|
|
||||||
"@types/history@*":
|
"@types/history@*":
|
||||||
version "4.7.6"
|
version "4.7.6"
|
||||||
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.6.tgz#ed8fc802c45b8e8f54419c2d054e55c9ea344356"
|
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.6.tgz#ed8fc802c45b8e8f54419c2d054e55c9ea344356"
|
||||||
|
@ -19119,6 +19119,19 @@ yup@^0.29.0:
|
||||||
synchronous-promise "^2.0.10"
|
synchronous-promise "^2.0.10"
|
||||||
toposort "^2.0.2"
|
toposort "^2.0.2"
|
||||||
|
|
||||||
|
yup@^0.29.1:
|
||||||
|
version "0.29.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/yup/-/yup-0.29.1.tgz#35d25aab470a0c3950f66040ba0ff4b1b6efe0d9"
|
||||||
|
integrity sha512-U7mPIbgfQWI6M3hZCJdGFrr+U0laG28FxMAKIgNvgl7OtyYuUoc4uy9qCWYHZjh49b8T7Ug8NNDdiMIEytcXrQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.9.6"
|
||||||
|
fn-name "~3.0.0"
|
||||||
|
lodash "^4.17.15"
|
||||||
|
lodash-es "^4.17.11"
|
||||||
|
property-expr "^2.0.2"
|
||||||
|
synchronous-promise "^2.0.10"
|
||||||
|
toposort "^2.0.2"
|
||||||
|
|
||||||
zepto@^1.2.0:
|
zepto@^1.2.0:
|
||||||
version "1.2.0"
|
version "1.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/zepto/-/zepto-1.2.0.tgz#e127bd9e66fd846be5eab48c1394882f7c0e4f98"
|
resolved "https://registry.yarnpkg.com/zepto/-/zepto-1.2.0.tgz#e127bd9e66fd846be5eab48c1394882f7c0e4f98"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue