Skip to content

Commit 65deab8

Browse files
mxstbrwardpeet
andauthored
fix(gatsby): fix subplugin validation (#27616)
Co-authored-by: Ward Peeters <[email protected]>
1 parent 12c1fd6 commit 65deab8

File tree

10 files changed

+331
-82
lines changed

10 files changed

+331
-82
lines changed

packages/gatsby-plugin-utils/src/types.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@
66
* See gatsbyjs/gatsby#27578 and ping @laurieontech or @mxstbr if you have any questions
77
*/
88

9-
export interface ISiteConfig {
9+
export interface IRawSiteConfig {
1010
plugins?: Array<PluginRef>
1111
}
1212

13+
export interface ISiteConfig extends IRawSiteConfig {
14+
plugins?: Array<IPluginRefObject>
15+
}
16+
1317
// There are two top-level "Plugin" concepts:
1418
// 1. IPluginInfo, for processed plugins, and
1519
// 2. PluginRef, for plugin configs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"use strict";
2+
3+
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
4+
exports.pluginOptionsSchema = function (_ref) {
5+
var Joi = _ref.Joi;
6+
return Joi.object({
7+
offsetY: Joi.number().integer().description("Signed integer. Vertical offset value in pixels.").default(0),
8+
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),
9+
className: Joi.string().description("Set your own class for the anchor.").default("anchor"),
10+
maintainCase: Joi.boolean().description("Maintains the case for markdown header."),
11+
removeAccents: Joi.boolean().description("Remove accents from generated headings IDs."),
12+
enableCustomId: Joi.boolean().description("Enable custom header IDs with `{#id}`"),
13+
isIconAfterHeader: Joi.boolean().description("Enable the anchor icon to be inline at the end of the header text."),
14+
elements: Joi.array().items(Joi.string()).description("Specify which type of header tags to link.")
15+
});
16+
};
17+
}

packages/gatsby-remark-autolink-headers/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"@babel/cli": "^7.11.6",
1818
"@babel/core": "^7.11.6",
1919
"babel-preset-gatsby-package": "^0.5.3",
20-
"cross-env": "^7.0.2"
20+
"cross-env": "^7.0.2",
21+
"gatsby-plugin-utils": "0.2.39"
2122
},
2223
"homepage": "https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-remark-autolink-headers#readme",
2324
"keywords": [
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
const { testPluginOptionsSchema } = require(`gatsby-plugin-utils`)
2+
const { pluginOptionsSchema } = require(`../gatsby-node`)
3+
4+
describe(`pluginOptionsSchema`, () => {
5+
it(`should validate a valid config`, async () => {
6+
// Only the "toVerify" key of the schema will be verified in this test
7+
const { isValid, errors } = await testPluginOptionsSchema(
8+
pluginOptionsSchema,
9+
{
10+
offsetY: 100,
11+
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>`,
12+
className: `custom-class`,
13+
maintainCase: true,
14+
removeAccents: true,
15+
isIconAfterHeader: true,
16+
elements: [`h1`, `h4`],
17+
}
18+
)
19+
20+
expect(isValid).toBe(true)
21+
expect(errors).toEqual([])
22+
})
23+
24+
it(`should validate a boolean icon`, async () => {
25+
// Only the "toVerify" key of the schema will be verified in this test
26+
const { isValid, errors } = await testPluginOptionsSchema(
27+
pluginOptionsSchema,
28+
{
29+
icon: false,
30+
}
31+
)
32+
33+
expect(isValid).toBe(true)
34+
expect(errors).toEqual([])
35+
})
36+
37+
it(`should invalidate an invalid config`, async () => {
38+
// Only the "toVerify" key of the schema will be verified in this test
39+
const { isValid, errors } = await testPluginOptionsSchema(
40+
pluginOptionsSchema,
41+
{
42+
offsetY: `string`,
43+
icon: 1000,
44+
className: true,
45+
maintainCase: `bla`,
46+
removeAccents: `yes`,
47+
isIconAfterHeader: `yes`,
48+
elements: [1, 2],
49+
}
50+
)
51+
52+
expect(isValid).toBe(false)
53+
expect(errors).toMatchInlineSnapshot(`
54+
Array [
55+
"\\"offsetY\\" must be a number",
56+
"\\"icon\\" must be one of [string, boolean]",
57+
"\\"className\\" must be a string",
58+
"\\"maintainCase\\" must be a boolean",
59+
"\\"removeAccents\\" must be a boolean",
60+
"\\"isIconAfterHeader\\" must be a boolean",
61+
"\\"elements[0]\\" must be a string",
62+
"\\"elements[1]\\" must be a string",
63+
]
64+
`)
65+
})
66+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
2+
exports.pluginOptionsSchema = ({ Joi }) =>
3+
Joi.object({
4+
offsetY: Joi.number()
5+
.integer()
6+
.description(`Signed integer. Vertical offset value in pixels.`)
7+
.default(0),
8+
icon: Joi.alternatives()
9+
.try(Joi.string(), Joi.boolean())
10+
.description(
11+
`SVG shape inside a template literal or boolean 'false'. Set your own svg or disable icon.`
12+
)
13+
.default(true),
14+
className: Joi.string()
15+
.description(`Set your own class for the anchor.`)
16+
.default(`anchor`),
17+
maintainCase: Joi.boolean().description(
18+
`Maintains the case for markdown header.`
19+
),
20+
removeAccents: Joi.boolean().description(
21+
`Remove accents from generated headings IDs.`
22+
),
23+
enableCustomId: Joi.boolean().description(
24+
`Enable custom header IDs with \`{#id}\``
25+
),
26+
isIconAfterHeader: Joi.boolean().description(
27+
`Enable the anchor icon to be inline at the end of the header text.`
28+
),
29+
elements: Joi.array()
30+
.items(Joi.string())
31+
.description(`Specify which type of header tags to link.`),
32+
})
33+
}

packages/gatsby/src/bootstrap/load-plugins/__tests__/load-plugins.ts

+54
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import { slash } from "gatsby-core-utils"
1414
import reporter from "gatsby-cli/lib/reporter"
1515
import { IFlattenedPlugin } from "../types"
1616

17+
afterEach(() => {
18+
Object.keys(reporter).forEach(method => {
19+
reporter[method].mockClear()
20+
})
21+
})
22+
1723
describe(`Load plugins`, () => {
1824
/**
1925
* Replace the resolve path and version string.
@@ -307,5 +313,53 @@ describe(`Load plugins`, () => {
307313
trackingId: `fake`,
308314
})
309315
})
316+
317+
it(`validates subplugin schemas`, async () => {
318+
await loadPlugins({
319+
plugins: [
320+
{
321+
resolve: `gatsby-transformer-remark`,
322+
options: {
323+
plugins: [
324+
{
325+
resolve: `gatsby-remark-autolink-headers`,
326+
options: {
327+
maintainCase: `should be boolean`,
328+
},
329+
},
330+
],
331+
},
332+
},
333+
],
334+
})
335+
336+
expect(reporter.error as jest.Mock).toHaveBeenCalledTimes(1)
337+
expect((reporter.error as jest.Mock).mock.calls[0])
338+
.toMatchInlineSnapshot(`
339+
Array [
340+
Object {
341+
"context": Object {
342+
"pluginName": "gatsby-remark-autolink-headers",
343+
"validationErrors": Array [
344+
Object {
345+
"context": Object {
346+
"key": "maintainCase",
347+
"label": "maintainCase",
348+
"value": "should be boolean",
349+
},
350+
"message": "\\"maintainCase\\" must be a boolean",
351+
"path": Array [
352+
"maintainCase",
353+
],
354+
"type": "boolean.base",
355+
},
356+
],
357+
},
358+
"id": "11331",
359+
},
360+
]
361+
`)
362+
expect(mockProcessExit).toHaveBeenCalledWith(1)
363+
})
310364
})
311365
})

packages/gatsby/src/bootstrap/load-plugins/index.ts

+46-8
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,15 @@ import {
1111
handleMultipleReplaceRenderers,
1212
ExportType,
1313
ICurrentAPIs,
14-
validatePluginOptions,
14+
validateConfigPluginsOptions,
1515
} from "./validate"
16-
import { IPluginInfo, IFlattenedPlugin, ISiteConfig } from "./types"
16+
import {
17+
IPluginInfo,
18+
IFlattenedPlugin,
19+
ISiteConfig,
20+
IRawSiteConfig,
21+
} from "./types"
22+
import { IPluginRefObject, PluginRef } from "gatsby-plugin-utils/dist/types"
1723

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

54+
function normalizePlugin(plugin): IPluginRefObject {
55+
if (typeof plugin === `string`) {
56+
return {
57+
resolve: plugin,
58+
options: {},
59+
}
60+
}
61+
62+
if (plugin.options?.plugins) {
63+
plugin.options = {
64+
...plugin.options,
65+
plugins: normalizePlugins(plugin.options.plugins),
66+
}
67+
}
68+
69+
return plugin
70+
}
71+
72+
function normalizePlugins(plugins?: Array<PluginRef>): Array<IPluginRefObject> {
73+
return (plugins || []).map(normalizePlugin)
74+
}
75+
76+
const normalizeConfig = (config: IRawSiteConfig = {}): ISiteConfig => {
77+
return {
78+
...config,
79+
plugins: (config.plugins || []).map(normalizePlugin),
80+
}
81+
}
82+
4883
export async function loadPlugins(
49-
config: ISiteConfig = {},
84+
rawConfig: IRawSiteConfig = {},
5085
rootDir: string | null = null
5186
): Promise<Array<IFlattenedPlugin>> {
87+
// Turn all strings in plugins: [`...`] into the { resolve: ``, options: {} } form
88+
const config = normalizeConfig(rawConfig)
89+
90+
// Show errors for invalid plugin configuration
91+
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
92+
await validateConfigPluginsOptions(config)
93+
}
94+
5295
const currentAPIs = getAPI({
5396
browser: browserAPIs,
5497
node: nodeAPIs,
@@ -72,11 +115,6 @@ export async function loadPlugins(
72115
// Show errors for any non-Gatsby APIs exported from plugins
73116
await handleBadExports({ currentAPIs, badExports })
74117

75-
// Show errors for invalid plugin configuration
76-
if (process.env.GATSBY_EXPERIMENTAL_PLUGIN_OPTION_VALIDATION) {
77-
await validatePluginOptions({ flattenedPlugins })
78-
}
79-
80118
// Show errors when ReplaceRenderer has been implemented multiple times
81119
flattenedPlugins = handleMultipleReplaceRenderers({
82120
flattenedPlugins,

packages/gatsby/src/bootstrap/load-plugins/load.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -233,15 +233,20 @@ export function loadPlugins(
233233

234234
// TypeScript support by default! use the user-provided one if it exists
235235
const typescriptPlugin = (config.plugins || []).find(
236-
plugin =>
237-
(plugin as IPluginRefObject).resolve === `gatsby-plugin-typescript` ||
238-
plugin === `gatsby-plugin-typescript`
236+
plugin => plugin.resolve === `gatsby-plugin-typescript`
239237
)
240238

241239
if (typescriptPlugin === undefined) {
242240
plugins.push(
243241
processPlugin({
244242
resolve: require.resolve(`gatsby-plugin-typescript`),
243+
options: {
244+
// TODO(@mxstbr): Do not hard-code these defaults but infer them from the
245+
// pluginOptionsSchema of gatsby-plugin-typescript
246+
allExtensions: false,
247+
isTSX: false,
248+
jsxPragma: `React`,
249+
},
245250
})
246251
)
247252
}

packages/gatsby/src/bootstrap/load-plugins/types.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
export interface ISiteConfig {
1+
export interface IRawSiteConfig {
22
plugins?: Array<PluginRef>
33
}
44

5+
export interface ISiteConfig extends IRawSiteConfig {
6+
plugins?: Array<IPluginRefObject>
7+
}
8+
59
// There are two top-level "Plugin" concepts:
610
// 1. IPluginInfo, for processed plugins, and
711
// 2. PluginRef, for plugin configs

0 commit comments

Comments
 (0)