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
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
40 changes: 33 additions & 7 deletions packages/gatsby/src/bootstrap/load-plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ import {
ICurrentAPIs,
validatePluginOptions,
} from "./validate"
import { IPluginInfo, IFlattenedPlugin, ISiteConfig } from "./types"
import {
IPluginInfo,
IFlattenedPlugin,
ISiteConfig,
IRawSiteConfig,
} from "./types"
import { IPluginRefObject } from "gatsby-plugin-utils/dist/types"

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

const normalizePlugins = (config: IRawSiteConfig = {}): ISiteConfig => {
return {
...config,
plugins: (config.plugins || []).map(
(plugin): IPluginRefObject => {
if (typeof plugin === `string`)
return {
resolve: plugin,
options: {},
}

return plugin
}
),
}
mxstbr marked this conversation as resolved.
Show resolved Hide resolved
}

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 = normalizePlugins(rawConfig)

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

const currentAPIs = getAPI({
browser: browserAPIs,
node: nodeAPIs,
Expand All @@ -72,11 +103,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
140 changes: 72 additions & 68 deletions packages/gatsby/src/bootstrap/load-plugins/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { validateOptionsSchema, Joi } from "gatsby-plugin-utils"
import { resolveModuleExports } from "../resolve-module-exports"
import { getLatestAPIs } from "../../utils/get-latest-apis"
import { GatsbyNode } from "../../../"
import { IPluginInfo, IFlattenedPlugin } from "./types"
import {
IPluginInfo,
IFlattenedPlugin,
IPluginInfoOptions,
ISiteConfig,
} from "./types"

interface IApi {
version?: string
Expand Down Expand Up @@ -167,77 +172,76 @@ export async function handleBadExports({
}
}

export async function validatePluginOptions({
flattenedPlugins,
}: {
flattenedPlugins: Array<IPluginInfo & Partial<IFlattenedPlugin>>
}): Promise<void> {
const errors = (
await Promise.all(
flattenedPlugins.map(
async (plugin): Promise<boolean | null> => {
if (plugin.nodeAPIs?.indexOf(`pluginOptionsSchema`) === -1)
return null

const gatsbyNode = require(`${plugin.resolve}/gatsby-node`)
if (!gatsbyNode.pluginOptionsSchema) return null

let optionsSchema = (gatsbyNode.pluginOptionsSchema as Exclude<
GatsbyNode["pluginOptionsSchema"],
undefined
>)({
Joi,
export async function validatePluginOptions(
config: ISiteConfig = {}
): Promise<void> {
if (!config.plugins) return

let errors = 0

config.plugins = await Promise.all(
config.plugins.map(async plugin => {
let gatsbyNode

try {
gatsbyNode = require(`${plugin.resolve}/gatsby-node`)
} catch (err) {
gatsbyNode = {}
}

if (!gatsbyNode.pluginOptionsSchema) return plugin

let optionsSchema = (gatsbyNode.pluginOptionsSchema as Exclude<
GatsbyNode["pluginOptionsSchema"],
undefined
>)({
Joi,
})
Comment on lines +195 to +200
Copy link
Contributor

Choose a reason for hiding this comment

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

The fun parts of typescript 😂


// Validate correct usage of pluginOptionsSchema
if (!Joi.isSchema(optionsSchema) || optionsSchema.type !== `object`) {
reporter.warn(
`Plugin "${plugin.resolve}" has an invalid options schema so we cannot verify your configuration for it.`
)
return plugin
}

try {
if (!optionsSchema.describe().keys.plugins) {
// All plugins have "plugins: []"" added to their options in load.ts, even if they
// do not have subplugins. We add plugins to the schema if it does not exist already
// to make sure they pass validation.
optionsSchema = optionsSchema.append({
plugins: Joi.array().length(0),
})
}

plugin.options = await validateOptionsSchema(
optionsSchema,
(plugin.options as IPluginInfoOptions) || {}
)
mxstbr marked this conversation as resolved.
Show resolved Hide resolved
} catch (error) {
if (error instanceof Joi.ValidationError) {
reporter.error({
id: `11331`,
context: {
validationErrors: error.details,
pluginName: plugin.resolve,
},
})
errors++

// Validate correct usage of pluginOptionsSchema
if (!Joi.isSchema(optionsSchema) || optionsSchema.type !== `object`) {
reporter.warn(
`Plugin "${plugin.name}" has an invalid options schema so we cannot verify your configuration for it.`
)
return null
}

try {
if (typeof plugin.pluginOptions === `undefined`) {
return null
}

if (!optionsSchema.describe().keys.plugins) {
// All plugins have "plugins: []"" added to their options in load.ts, even if they
// do not have subplugins. We add plugins to the schema if it does not exist already
// to make sure they pass validation.
optionsSchema = optionsSchema.append({
plugins: Joi.array().length(0),
})
}

plugin.pluginOptions = await validateOptionsSchema(
optionsSchema,
plugin.pluginOptions
)
} catch (error) {
if (error instanceof Joi.ValidationError) {
reporter.error({
id: `11331`,
context: {
validationErrors: error.details,
pluginName: plugin.name,
},
})

return true
}

throw error
}

return null
return plugin
}
)
)
).filter(Boolean)

if (errors.length > 0) {
throw error
}

return plugin
})
)

if (errors > 0) {
process.exit(1)
}
}
Expand Down