feat(v2): allow config plugins as function or [function,options] (#4618)

* feat : update PluginSchema validation

* feat : update plugin init functionality

* test : add and update tests

* fix : tests

* refactor : init.ts

* test : update test

* docs : add functional plugin docs

* fix little issues

* refactor : refactor code

* minor refactors

* simplify initPlugins code

* simplify initPlugin + add custom validation error message

* fix snapshots

* improve function plugin doc

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
besemuna 2021-05-15 17:33:05 +00:00 committed by GitHub
parent e092910627
commit 69be003e12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 460 additions and 124 deletions

View file

@ -294,7 +294,12 @@ export type ConfigurePostCssFn = Plugin<unknown>['configurePostCss'];
export type PluginOptions = {id?: string} & Record<string, unknown>;
export type PluginConfig = [string, PluginOptions] | [string] | string;
export type PluginConfig =
| [string, PluginOptions]
| [string]
| string
| [PluginModule, PluginOptions]
| PluginModule;
export interface ChunkRegistry {
loader: string;

View file

@ -18,22 +18,34 @@ import initPlugins from '../server/plugins/init';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
export function getPluginNames(plugins: PluginConfig[]): string[] {
return plugins.map((plugin) => {
const pluginPath = Array.isArray(plugin) ? plugin[0] : plugin;
let packagePath = path.dirname(pluginPath);
while (packagePath) {
if (fs.existsSync(path.join(packagePath, 'package.json'))) {
break;
} else {
packagePath = path.dirname(packagePath);
return plugins
.filter(
(plugin) =>
typeof plugin === 'string' ||
(Array.isArray(plugin) && typeof plugin[0] === 'string'),
)
.map((plugin) => {
const pluginPath = Array.isArray(plugin) ? plugin[0] : plugin;
if (typeof pluginPath === 'string') {
let packagePath = path.dirname(pluginPath);
while (packagePath) {
if (fs.existsSync(path.join(packagePath, 'package.json'))) {
break;
} else {
packagePath = path.dirname(packagePath);
}
}
if (packagePath === '.') {
return pluginPath;
}
return importFresh<{name: string}>(
path.join(packagePath, 'package.json'),
).name;
}
}
if (packagePath === '.') {
return pluginPath;
}
return importFresh<{name: string}>(path.join(packagePath, 'package.json'))
.name;
});
return '';
})
.filter((plugin) => plugin !== '');
}
function walk(dir: string): Array<string> {
@ -178,7 +190,7 @@ export default async function swizzle(
// find the plugin from list of plugin and get options if specified
pluginConfigs.forEach((pluginConfig) => {
// plugin can be a [string], [string,object] or string.
if (Array.isArray(pluginConfig)) {
if (Array.isArray(pluginConfig) && typeof pluginConfig[0] === 'string') {
if (require.resolve(pluginConfig[0]) === resolvedThemeName) {
if (pluginConfig.length === 2) {
const [, options] = pluginConfig;

View file

@ -31,32 +31,66 @@ exports[`normalizeConfig should throw error if css doesn't have href 1`] = `
`;
exports[`normalizeConfig should throw error if plugins is not a string and it's not an array #1 for the input of: [123] 1`] = `
"\\"plugins[0]\\" does not match any of the allowed types
"
`;
" => Bad Docusaurus plugin value as path [plugins,0].
Example valid plugin config:
{
plugins: [
[\\"@docusaurus/plugin-content-docs\\",options],
\\"./myPlugin\\",
[\\"./myPlugin\\",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
exports[`normalizeConfig should throw error if plugins is not a string and it's not an array #2 for the input of: [[Function anonymous]] 1`] = `
"\\"plugins[0]\\" does not match any of the allowed types
"
`;
exports[`normalizeConfig should throw error if plugins is not an array of [string, object][] #1 for the input of: [[Array]] 1`] = `
"\\"plugins[0]\\" does not match any of the allowed types
" => Bad Docusaurus plugin value as path [plugins,0].
Example valid plugin config:
{
plugins: [
[\\"@docusaurus/plugin-content-docs\\",options],
\\"./myPlugin\\",
[\\"./myPlugin\\",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
"
`;
exports[`normalizeConfig should throw error if plugins is not an array of [string, object][] #2 for the input of: [[Array]] 1`] = `
"\\"plugins[0]\\" does not match any of the allowed types
" => Bad Docusaurus plugin value as path [plugins,0].
Example valid plugin config:
{
plugins: [
[\\"@docusaurus/plugin-content-docs\\",options],
\\"./myPlugin\\",
[\\"./myPlugin\\",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
"
`;
exports[`normalizeConfig should throw error if plugins is not an array of [string, object][] #3 for the input of: [[Array]] 1`] = `
"\\"plugins[0]\\" does not match any of the allowed types
"
`;
" => Bad Docusaurus plugin value as path [plugins,0].
Example valid plugin config:
{
plugins: [
[\\"@docusaurus/plugin-content-docs\\",options],
\\"./myPlugin\\",
[\\"./myPlugin\\",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
exports[`normalizeConfig should throw error if plugins is not array for the input of: [Function anonymous] 1`] = `
"\\"plugins\\" must be an array
"
`;

View file

@ -90,24 +90,10 @@ describe('normalizeConfig', () => {
test.each([
['should throw error if plugins is not array', {}],
[
'should throw error if plugins is not array',
function () {
console.log('noop');
},
],
[
"should throw error if plugins is not a string and it's not an array #1",
[123],
],
[
"should throw error if plugins is not a string and it's not an array #2",
[
function () {
console.log('noop');
},
],
],
[
'should throw error if plugins is not an array of [string, object][] #1',
[['example/path', 'wrong parameter here']],
@ -153,6 +139,11 @@ describe('normalizeConfig', () => {
['this/should/work', {too: 'yes'}],
],
],
['should accept function for plugin', [function (_context, _options) {}]],
[
'should accept [function, object] for plugin',
[[function (_context, _options) {}, {it: 'should work'}]],
],
])(`%s for the input of: %p`, (_message, plugins) => {
expect(() => {
normalizeConfig({

View file

@ -51,13 +51,36 @@ export const DEFAULT_CONFIG: Pick<
baseUrlIssueBanner: true,
};
const PluginSchema = Joi.alternatives().try(
Joi.string(),
Joi.array()
.ordered(Joi.string().required(), Joi.object().required())
.length(2),
Joi.bool().equal(false), // In case of conditional adding of plugins.
);
const PluginSchema = Joi.alternatives()
.try(
Joi.function(),
Joi.array().ordered(Joi.function().required(), Joi.object().required()),
Joi.string(),
Joi.array()
.ordered(Joi.string().required(), Joi.object().required())
.length(2),
Joi.bool().equal(false), // In case of conditional adding of plugins.
)
// TODO isn't there a simpler way to customize the default Joi error message???
// Not sure why Joi makes it complicated to add a custom error message...
// See https://stackoverflow.com/a/54657686/82609
.error((errors) => {
errors.forEach((error) => {
error.message = ` => Bad Docusaurus plugin value as path [${error.path}].
Example valid plugin config:
{
plugins: [
["@docusaurus/plugin-content-docs",options],
"./myPlugin",
["./myPlugin",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
`;
});
return errors as any;
});
const ThemeSchema = Joi.alternatives().try(
Joi.string(),

View file

@ -38,7 +38,7 @@ import {
} from './translations/translations';
import {mapValues} from 'lodash';
type LoadContextOptions = {
export type LoadContextOptions = {
customOutDir?: string;
customConfigFilePath?: string;
locale?: string;

View file

@ -0,0 +1,15 @@
/**
* 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.
*/
module.exports = {
title: 'My Site',
tagline: 'The tagline of my site',
url: 'https://your-docusaurus-test-site.com',
baseUrl: '/',
favicon: 'img/favicon.ico',
plugins: [42, true],
};

View file

@ -0,0 +1,27 @@
/**
* 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.
*/
module.exports = {
title: 'My Site',
tagline: 'The tagline of my site',
url: 'https://your-docusaurus-test-site.com',
baseUrl: '/',
favicon: 'img/favicon.ico',
plugins: [
function (context, options) {
return {name: 'first-plugin'};
},
[
function (context, options) {
return {name: 'second-plugin'};
},
{it: 'should work'},
],
'./plugin3.js',
['./plugin4.js', {}],
],
};

View file

@ -0,0 +1,5 @@
module.exports = function (context, options) {
return {
name: 'third-plugin',
};
};

View file

@ -0,0 +1,5 @@
module.exports = function (context, options) {
return {
name: 'fourth-plugin',
};
};

View file

@ -0,0 +1,5 @@
{
"docs": {
"Test": ["hello"]
}
}

View file

@ -0,0 +1,29 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`initPlugins plugins with bad values throw user-friendly error message 1`] = `
" => Bad Docusaurus plugin value as path [plugins,0].
Example valid plugin config:
{
plugins: [
[\\"@docusaurus/plugin-content-docs\\",options],
\\"./myPlugin\\",
[\\"./myPlugin\\",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
=> Bad Docusaurus plugin value as path [plugins,1].
Example valid plugin config:
{
plugins: [
[\\"@docusaurus/plugin-content-docs\\",options],
\\"./myPlugin\\",
[\\"./myPlugin\\",{someOption: 42}],
function myPlugin() { },
[function myPlugin() { },options]
],
};
"
`;

View file

@ -0,0 +1,44 @@
/**
* 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 path from 'path';
import {loadContext, LoadContextOptions, loadPluginConfigs} from '../../index';
import initPlugins from '../init';
describe('initPlugins', () => {
async function loadSite(options: LoadContextOptions = {}) {
const siteDir = path.join(__dirname, '__fixtures__', 'site-with-plugin');
const context = await loadContext(siteDir, options);
const pluginConfigs = loadPluginConfigs(context);
const plugins = initPlugins({
pluginConfigs,
context,
});
return {siteDir, context, plugins};
}
test('plugins gets parsed correctly and loads in correct order', async () => {
const {context, plugins} = await loadSite();
expect(context.siteConfig.plugins?.length).toBe(4);
expect(plugins.length).toBe(4);
expect(plugins[0].name).toBe('first-plugin');
expect(plugins[1].name).toBe('second-plugin');
expect(plugins[2].name).toBe('third-plugin');
expect(plugins[3].name).toBe('fourth-plugin');
});
test('plugins with bad values throw user-friendly error message', async () => {
await expect(() =>
loadSite({
customConfigFilePath: 'badPlugins.docusaurus.config.js',
}),
).rejects.toThrowErrorMatchingSnapshot();
});
});

View file

@ -11,6 +11,7 @@ import {
DocusaurusPluginVersionInformation,
ImportedPluginModule,
LoadContext,
PluginModule,
Plugin,
PluginConfig,
PluginOptions,
@ -23,6 +24,106 @@ import {
normalizeThemeConfig,
} from '@docusaurus/utils-validation';
type NormalizedPluginConfig = {
plugin: PluginModule;
options: PluginOptions;
// Only available when a string is provided in config
pluginModule?: {
path: string;
module: ImportedPluginModule;
};
};
function normalizePluginConfig(
pluginConfig: PluginConfig,
pluginRequire: NodeRequire,
): NormalizedPluginConfig {
// plugins: ['./plugin']
if (typeof pluginConfig === 'string') {
const pluginModuleImport = pluginConfig;
const pluginPath = pluginRequire.resolve(pluginModuleImport);
const pluginModule = importFresh<ImportedPluginModule>(pluginPath);
return {
plugin: pluginModule?.default ?? pluginModule,
options: {},
pluginModule: {
path: pluginModuleImport,
module: pluginModule,
},
};
}
// plugins: [function plugin() { }]
if (typeof pluginConfig === 'function') {
return {
plugin: pluginConfig,
options: {},
};
}
if (Array.isArray(pluginConfig)) {
// plugins: [
// ['./plugin',options],
// ]
if (typeof pluginConfig[0] === 'string') {
const pluginModuleImport = pluginConfig[0];
const pluginPath = pluginRequire.resolve(pluginModuleImport);
const pluginModule = importFresh<ImportedPluginModule>(pluginPath);
return {
plugin: pluginModule?.default ?? pluginModule,
options: pluginConfig[1] ?? {},
pluginModule: {
path: pluginModuleImport,
module: pluginModule,
},
};
}
// plugins: [
// [function plugin() { },options],
// ]
if (typeof pluginConfig[0] === 'function') {
return {
plugin: pluginConfig[0],
options: pluginConfig[1] ?? {},
};
}
}
throw new Error(
`Unexpected: cant load plugin for plugin config = ${JSON.stringify(
pluginConfig,
)}`,
);
}
function getOptionValidationFunction(
normalizedPluginConfig: NormalizedPluginConfig,
): PluginModule['validateOptions'] {
if (normalizedPluginConfig.pluginModule) {
// support both commonjs and ES modules
return (
normalizedPluginConfig.pluginModule.module?.default?.validateOptions ??
normalizedPluginConfig.pluginModule.module?.validateOptions
);
} else {
return normalizedPluginConfig.plugin.validateOptions;
}
}
function getThemeValidationFunction(
normalizedPluginConfig: NormalizedPluginConfig,
): PluginModule['validateThemeConfig'] {
if (normalizedPluginConfig.pluginModule) {
// support both commonjs and ES modules
return (
normalizedPluginConfig.pluginModule.module.default?.validateThemeConfig ??
normalizedPluginConfig.pluginModule.module.validateThemeConfig
);
} else {
return normalizedPluginConfig.plugin.validateThemeConfig;
}
}
export type InitPlugin = Plugin<unknown> & {
readonly options: PluginOptions;
readonly version: DocusaurusPluginVersionInformation;
@ -42,75 +143,82 @@ export default function initPlugins({
const createRequire = Module.createRequire || Module.createRequireFromPath;
const pluginRequire = createRequire(context.siteConfigPath);
const plugins: InitPlugin[] = pluginConfigs
.map((pluginItem) => {
let pluginModuleImport: string | undefined;
let pluginOptions: PluginOptions = {};
function doGetPluginVersion(
normalizedPluginConfig: NormalizedPluginConfig,
): DocusaurusPluginVersionInformation {
// get plugin version
if (normalizedPluginConfig.pluginModule?.path) {
const pluginPath = pluginRequire.resolve(
normalizedPluginConfig.pluginModule?.path,
);
return getPluginVersion(pluginPath, context.siteDir);
} else {
return {type: 'local'};
}
}
if (!pluginItem) {
function doValidateThemeConfig(
normalizedPluginConfig: NormalizedPluginConfig,
) {
const validateThemeConfig = getThemeValidationFunction(
normalizedPluginConfig,
);
if (validateThemeConfig) {
return validateThemeConfig({
validate: normalizeThemeConfig,
themeConfig: context.siteConfig.themeConfig,
});
} else {
return context.siteConfig.themeConfig;
}
}
function doValidatePluginOptions(
normalizedPluginConfig: NormalizedPluginConfig,
) {
const validateOptions = getOptionValidationFunction(normalizedPluginConfig);
if (validateOptions) {
return validateOptions({
validate: normalizePluginOptions,
options: normalizedPluginConfig.options,
});
} else {
// Important to ensure all plugins have an id
// as we don't go through the Joi schema that adds it
return {
...normalizedPluginConfig.options,
id: normalizedPluginConfig.options.id ?? DEFAULT_PLUGIN_ID,
};
}
}
const plugins: InitPlugin[] = pluginConfigs
.map((pluginConfig) => {
if (!pluginConfig) {
return null;
}
const normalizedPluginConfig = normalizePluginConfig(
pluginConfig,
pluginRequire,
);
const pluginVersion: DocusaurusPluginVersionInformation = doGetPluginVersion(
normalizedPluginConfig,
);
const pluginOptions = doValidatePluginOptions(normalizedPluginConfig);
if (typeof pluginItem === 'string') {
pluginModuleImport = pluginItem;
} else if (Array.isArray(pluginItem)) {
[pluginModuleImport, pluginOptions = {}] = pluginItem;
} else {
throw new TypeError(`You supplied a wrong type of plugin.
A plugin should be either string or [importPath: string, options?: object].
// Side-effect: merge the normalized theme config in the original one
context.siteConfig.themeConfig = {
...context.siteConfig.themeConfig,
...doValidateThemeConfig(normalizedPluginConfig),
};
For more information, visit https://docusaurus.io/docs/using-plugins.`);
}
if (!pluginModuleImport) {
throw new Error('The path to the plugin is either undefined or null.');
}
// The pluginModuleImport value is any valid
// module identifier - npm package or locally-resolved path.
const pluginPath = pluginRequire.resolve(pluginModuleImport);
const pluginModule: ImportedPluginModule = importFresh(pluginPath);
const pluginVersion = getPluginVersion(pluginPath, context.siteDir);
const plugin = pluginModule.default || pluginModule;
// support both commonjs and ES modules
const validateOptions =
pluginModule.default?.validateOptions ?? pluginModule.validateOptions;
if (validateOptions) {
pluginOptions = validateOptions({
validate: normalizePluginOptions,
options: pluginOptions,
});
} else {
// Important to ensure all plugins have an id
// as we don't go through the Joi schema that adds it
pluginOptions = {
...pluginOptions,
id: pluginOptions.id ?? DEFAULT_PLUGIN_ID,
};
}
// support both commonjs and ES modules
const validateThemeConfig =
pluginModule.default?.validateThemeConfig ??
pluginModule.validateThemeConfig;
if (validateThemeConfig) {
const normalizedThemeConfig = validateThemeConfig({
validate: normalizeThemeConfig,
themeConfig: context.siteConfig.themeConfig,
});
context.siteConfig.themeConfig = {
...context.siteConfig.themeConfig,
...normalizedThemeConfig,
};
}
const pluginInstance = normalizedPluginConfig.plugin(
context,
pluginOptions,
);
return {
...plugin(context, pluginOptions),
...pluginInstance,
options: pluginOptions,
version: pluginVersion,
};

View file

@ -119,28 +119,62 @@ Docusaurus' implementation of the plugins system provides us with a convenient w
## Creating plugins {#creating-plugins}
A plugin is a module which exports a function that takes two parameters and returns an object when executed.
A plugin is a function that takes two parameters: `context` and `options`.
### Module definition {#module-definition}
It returns a plugin instance object, containing plugin [lifecycle APIs](./lifecycle-apis.md).
The exported modules for plugins are called with two parameters: `context` and `options` and returns a JavaScript object with defining the [lifecycle APIs](./lifecycle-apis.md).
It can be defined as a function or a module.
For example if you have a reference to a local folder such as this in your `docusaurus.config.js`:
### Functional definition {#functional-definition}
You can use a plugin as a function, directly in the Docusaurus config file:
```js title="docusaurus.config.js"
module.exports = {
// ...
plugins: [path.resolve(__dirname, 'my-plugin')],
plugins: [
// highligh-start
function myPlugin(contex, options) {
// ...
return {
name: 'my-plugin',
async loadContent() {
// ...
},
async contentLoaded({content, actions}) {
// ...
},
/* other lifecycle API */
};
},
// highlight-end
],
};
```
### Module definition {#module-definition}
You can use a plugin as a module, loading it from a separate file or NPM package:
```js title="docusaurus.config.js"
module.exports = {
// ...
plugins: [
// without options:
'./my-plugin',
// or with options:
['./my-plugin', options],
],
};
```
Then in the folder `my-plugin` you can create an index.js such as this
```js title="index.js"
module.exports = function (context, options) {
```js title="my-plugin.js"
module.exports = function myPlugin(context, options) {
// ...
return {
name: 'my-docusaurus-plugin',
name: 'my-plugin',
async loadContent() {
/* ... */
},
@ -152,11 +186,9 @@ module.exports = function (context, options) {
};
```
The `my-plugin` folder could also be a fully fledged package with it's own package.json and a `src/index.js` file for example
#### `context` {#context}
`context` is plugin-agnostic and the same object will be passed into all plugins used for a Docusaurus website. The `context` object contains the following fields:
`context` is plugin-agnostic, and the same object will be passed into all plugins used for a Docusaurus website. The `context` object contains the following fields:
```ts
interface LoadContext {