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

fix(gatsby): fix subplugin validation #27616

Merged
merged 13 commits into from
Oct 28, 2020
Merged
6 changes: 5 additions & 1 deletion packages/gatsby-plugin-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
* See gatsbyjs/gatsby#27578 and ping @laurieontech or @mxstbr if you have any questions
*/

export interface ISiteConfig {
export interface IRawSiteConfig {
plugins?: Array<PluginRef>
}

export interface ISiteConfig extends IRawSiteConfig {
plugins?: Array<IPluginRefObject>
}

// There are two top-level "Plugin" concepts:
// 1. IPluginInfo, for processed plugins, and
// 2. PluginRef, for plugin configs
Expand Down
17 changes: 17 additions & 0 deletions packages/gatsby-remark-autolink-headers/gatsby-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use strict";

if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
exports.pluginOptionsSchema = function (_ref) {
var Joi = _ref.Joi;
return Joi.object({
offsetY: Joi.number().integer().description("Signed integer. Vertical offset value in pixels.").default(0),
icon: Joi.alternatives().try(Joi.string(), Joi.boolean()).description("SVG shape inside a template literal or boolean 'false'. Set your own svg or disable icon.").default(true),
className: Joi.string().description("Set your own class for the anchor.").default("anchor"),
maintainCase: Joi.boolean().description("Maintains the case for markdown header."),
removeAccents: Joi.boolean().description("Remove accents from generated headings IDs."),
enableCustomId: Joi.boolean().description("Enable custom header IDs with `{#id}`"),
isIconAfterHeader: Joi.boolean().description("Enable the anchor icon to be inline at the end of the header text."),
elements: Joi.array().items(Joi.string()).description("Specify which type of header tags to link.")
});
};
}
3 changes: 2 additions & 1 deletion packages/gatsby-remark-autolink-headers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"@babel/cli": "^7.11.6",
"@babel/core": "^7.11.6",
"babel-preset-gatsby-package": "^0.5.3",
"cross-env": "^7.0.2"
"cross-env": "^7.0.2",
"gatsby-plugin-utils": "0.2.39"
},
"homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-remark-autolink-headers#readme",
"keywords": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const { testPluginOptionsSchema } = require(`gatsby-plugin-utils`)
const { pluginOptionsSchema } = require(`../gatsby-node`)

describe(`pluginOptionsSchema`, () => {
it(`should validate a valid config`, async () => {
// Only the "toVerify" key of the schema will be verified in this test
const { isValid, errors } = await testPluginOptionsSchema(
pluginOptionsSchema,
{
offsetY: 100,
icon: `<svg aria-hidden="true" height="20" version="1.1" viewBox="0 0 16 16" width="20"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>`,
className: `custom-class`,
maintainCase: true,
removeAccents: true,
isIconAfterHeader: true,
elements: [`h1`, `h4`],
}
)

expect(isValid).toBe(true)
expect(errors).toEqual([])
})

it(`should validate a boolean icon`, async () => {
// Only the "toVerify" key of the schema will be verified in this test
const { isValid, errors } = await testPluginOptionsSchema(
pluginOptionsSchema,
{
icon: false,
}
)

expect(isValid).toBe(true)
expect(errors).toEqual([])
})

it(`should invalidate an invalid config`, async () => {
// Only the "toVerify" key of the schema will be verified in this test
const { isValid, errors } = await testPluginOptionsSchema(
pluginOptionsSchema,
{
offsetY: `string`,
icon: 1000,
className: true,
maintainCase: `bla`,
removeAccents: `yes`,
isIconAfterHeader: `yes`,
elements: [1, 2],
}
)

expect(isValid).toBe(false)
expect(errors).toMatchInlineSnapshot(`
Array [
"\\"offsetY\\" must be a number",
"\\"icon\\" must be one of [string, boolean]",
"\\"className\\" must be a string",
"\\"maintainCase\\" must be a boolean",
"\\"removeAccents\\" must be a boolean",
"\\"isIconAfterHeader\\" must be a boolean",
"\\"elements[0]\\" must be a string",
"\\"elements[1]\\" must be a string",
]
`)
})
})
33 changes: 33 additions & 0 deletions packages/gatsby-remark-autolink-headers/src/gatsby-node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
exports.pluginOptionsSchema = ({ Joi }) =>
Joi.object({
offsetY: Joi.number()
.integer()
.description(`Signed integer. Vertical offset value in pixels.`)
.default(0),
icon: Joi.alternatives()
.try(Joi.string(), Joi.boolean())
.description(
`SVG shape inside a template literal or boolean 'false'. Set your own svg or disable icon.`
)
.default(true),
className: Joi.string()
.description(`Set your own class for the anchor.`)
.default(`anchor`),
maintainCase: Joi.boolean().description(
`Maintains the case for markdown header.`
),
removeAccents: Joi.boolean().description(
`Remove accents from generated headings IDs.`
),
enableCustomId: Joi.boolean().description(
`Enable custom header IDs with \`{#id}\``
),
isIconAfterHeader: Joi.boolean().description(
`Enable the anchor icon to be inline at the end of the header text.`
),
elements: Joi.array()
.items(Joi.string())
.description(`Specify which type of header tags to link.`),
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { slash } from "gatsby-core-utils"
import reporter from "gatsby-cli/lib/reporter"
import { IFlattenedPlugin } from "../types"

afterEach(() => {
Object.keys(reporter).forEach(method => {
reporter[method].mockClear()
})
})

describe(`Load plugins`, () => {
/**
* Replace the resolve path and version string.
Expand Down Expand Up @@ -307,5 +313,53 @@ describe(`Load plugins`, () => {
trackingId: `fake`,
})
})

it(`validates subplugin schemas`, async () => {
await loadPlugins({
plugins: [
{
resolve: `gatsby-transformer-remark`,
options: {
plugins: [
{
resolve: `gatsby-remark-autolink-headers`,
options: {
maintainCase: `should be boolean`,
},
},
],
},
},
],
})

expect(reporter.error as jest.Mock).toHaveBeenCalledTimes(1)
expect((reporter.error as jest.Mock).mock.calls[0])
.toMatchInlineSnapshot(`
Array [
Object {
"context": Object {
"pluginName": "gatsby-remark-autolink-headers",
"validationErrors": Array [
Object {
"context": Object {
"key": "maintainCase",
"label": "maintainCase",
"value": "should be boolean",
},
"message": "\\"maintainCase\\" must be a boolean",
"path": Array [
"maintainCase",
],
"type": "boolean.base",
},
],
},
"id": "11331",
},
]
`)
expect(mockProcessExit).toHaveBeenCalledWith(1)
})
})
})
54 changes: 46 additions & 8 deletions packages/gatsby/src/bootstrap/load-plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ import {
handleMultipleReplaceRenderers,
ExportType,
ICurrentAPIs,
validatePluginOptions,
validateConfigPluginsOptions,
} from "./validate"
import { IPluginInfo, IFlattenedPlugin, ISiteConfig } from "./types"
import {
IPluginInfo,
IFlattenedPlugin,
ISiteConfig,
IRawSiteConfig,
} from "./types"
import { IPluginRefObject, PluginRef } from "gatsby-plugin-utils/dist/types"

const getAPI = (
api: { [exportType in ExportType]: { [api: string]: boolean } }
Expand Down Expand Up @@ -45,10 +51,47 @@ const flattenPlugins = (plugins: Array<IPluginInfo>): Array<IPluginInfo> => {
return flattened
}

function normalizePlugin(plugin): IPluginRefObject {
if (typeof plugin === `string`) {
return {
resolve: plugin,
options: {},
}
}

if (plugin.options?.plugins) {
plugin.options = {
...plugin.options,
plugins: normalizePlugins(plugin.options.plugins),
}
}

return plugin
}

function normalizePlugins(plugins?: Array<PluginRef>): Array<IPluginRefObject> {
return (plugins || []).map(normalizePlugin)
}

const normalizeConfig = (config: IRawSiteConfig = {}): ISiteConfig => {
return {
...config,
plugins: (config.plugins || []).map(normalizePlugin),
}
}

export async function loadPlugins(
config: ISiteConfig = {},
rawConfig: IRawSiteConfig = {},
rootDir: string | null = null
): Promise<Array<IFlattenedPlugin>> {
// Turn all strings in plugins: [`...`] into the { resolve: ``, options: {} } form
const config = normalizeConfig(rawConfig)

// Show errors for invalid plugin configuration
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
await validateConfigPluginsOptions(config)
}

const currentAPIs = getAPI({
browser: browserAPIs,
node: nodeAPIs,
Expand All @@ -72,11 +115,6 @@ export async function loadPlugins(
// Show errors for any non-Gatsby APIs exported from plugins
await handleBadExports({ currentAPIs, badExports })

// Show errors for invalid plugin configuration
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
await validatePluginOptions({ flattenedPlugins })
}

// Show errors when ReplaceRenderer has been implemented multiple times
flattenedPlugins = handleMultipleReplaceRenderers({
flattenedPlugins,
Expand Down
11 changes: 8 additions & 3 deletions packages/gatsby/src/bootstrap/load-plugins/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,20 @@ export function loadPlugins(

// TypeScript support by default! use the user-provided one if it exists
const typescriptPlugin = (config.plugins || []).find(
plugin =>
(plugin as IPluginRefObject).resolve === `gatsby-plugin-typescript` ||
plugin === `gatsby-plugin-typescript`
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: removed the string case because plugin cannot be a string at this point anymore now that we normalize them ahead of time. TypeScript helpfully pointed that out to me!

plugin => plugin.resolve === `gatsby-plugin-typescript`
)

if (typescriptPlugin === undefined) {
plugins.push(
processPlugin({
resolve: require.resolve(`gatsby-plugin-typescript`),
options: {
// TODO(@mxstbr): Do not hard-code these defaults but infer them from the
// pluginOptionsSchema of gatsby-plugin-typescript
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't have to happen for now, but would be a small cleanup in the future should we ever change the typescript plugins' defaults!

allExtensions: false,
isTSX: false,
jsxPragma: `React`,
},
})
)
}
Expand Down
6 changes: 5 additions & 1 deletion packages/gatsby/src/bootstrap/load-plugins/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
export interface ISiteConfig {
export interface IRawSiteConfig {
plugins?: Array<PluginRef>
}

export interface ISiteConfig extends IRawSiteConfig {
plugins?: Array<IPluginRefObject>
}

// There are two top-level "Plugin" concepts:
// 1. IPluginInfo, for processed plugins, and
// 2. PluginRef, for plugin configs
Expand Down
Loading