diff --git a/docs/docs/debugging-replace-renderer-api.md b/docs/docs/debugging-replace-renderer-api.md new file mode 100644 index 0000000000000..999fc07e9dcb2 --- /dev/null +++ b/docs/docs/debugging-replace-renderer-api.md @@ -0,0 +1,113 @@ +--- +title: Debugging replaceRenderer API +--- + +## What is the `replaceRenderer` API? + +The `replaceRenderer` API is one of [Gatsby's Server Side Rendering (SSR) extension APIs](/docs/ssr-apis/#replaceRenderer). This API is called when you run `gatsby build` and is used to customise how Gatsby renders your static content. It can be implemented by any Gatsby plugin or your `gatsby-ssr.js` file - adding support for Redux, CSS-in-JS libraries or any code that needs to change Gatsby's default HTML output. + +## Why does it cause build errors? + +If multiple plugins implement `replaceRenderer` in your project, only the last plugin implementing the API can be called - which will break your site builds. + +Note that `replaceRenderer` is only used during `gatsby build`. It won't cause problems as you work on your site with `gatsby develop`. + +If multiple plugins implement `replaceRenderer`, `gatsby build` will warn you: + +``` +The "replaceRenderer" API is implemented by several enabled plugins. +This could be an error, see https://gatsbyjs.org/docs/debugging-replace-renderer-api for workarounds. +Check the following plugins for "replaceRenderer" implementations: +/path/to/my/site/node_modules/gatsby-plugin-styled-components/gatsby-ssr.js +/path/to/my/site/gatsby-ssr.js +``` + +## Fixing `replaceRenderer` build errors + +If you see errors during your build, you can fix them with the following steps. + +### 1. Identify the plugins using `replaceRenderer` + +Your error message should list the files that use `replaceRenderer` + +```shell +Check the following files for "replaceRenderer" implementations: +/path/to/my/site/node_modules/gatsby-plugin-styled-components/gatsby-ssr.js +/path/to/my/site/gatsby-ssr.js +``` + +In this example, your `gatsby-ssr.js` file and `gatsby-plugin-styled-components` are both using `replaceRenderer`. + +### 2. Copy the plugins' `replaceRenderer` functionality to your site's `gatsby-ssr.js` file + +You'll need to override your plugins' `replaceRenderer` code in your `gatsby-ssr.js` file. This step will be different for each project, keep reading to see an example. + +## Example + +### Initial setup + +In this example project we're using [`redux`](https://github.com/gatsbyjs/gatsby/tree/master/examples/using-redux) and [Gatsby's Styled Components plugin](https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-styled-components). + +`gatsby-config.js` + +```js +module.exports = { + plugins: [`gatsby-plugin-styled-components`], +} +``` + +`gatsby-ssr.js` (based on the [using Redux example](https://github.com/gatsbyjs/gatsby/blob/master/examples/using-redux/gatsby-ssr.js)) + +```js +import React from "react" +import { Provider } from "react-redux" +import { renderToString } from "react-dom/server" + +import createStore from "./src/state/createStore" + +exports.replaceRenderer = ({ bodyComponent, replaceBodyHTMLString }) => { + const store = createStore() + + const ConnectedBody = () => {bodyComponent} + replaceBodyHTMLString(renderToString()) +} +``` + +Note that the Styled Components plugin uses `replaceRenderer`, and the code in `gatsby-ssr.js` also uses `replaceRenderer`. + +### Fixing the `replaceRenderer` error + +Our `gatsby-config.js` file will remain unchanged. However, oour `gatsby-ssr.js` file will update to include the [`replaceRenderer` functionality from the Styled Components plugin](https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-plugin-styled-components/src/gatsby-ssr.js) + +`gatsby-ssr.js` + +```js +import React from "react" +import { Provider } from "react-redux" +import { renderToString } from "react-dom/server" +import { ServerStyleSheet, StyleSheetManager } from "styled-components" +import createStore from "./src/state/createStore" + +exports.replaceRenderer = ({ + bodyComponent, + replaceBodyHTMLString, + setHeadComponents, +}) => { + const sheet = new ServerStyleSheet() + const store = createStore() + + const app = () => ( + + + {bodyComponent} + + + ) + replaceBodyHTMLString(renderToString()) + setHeadComponents([sheet.getStyleElement()]) +} +``` + +Now `gatsby-ssr.js` implements the Styled Components and Redux functionality using one `replaceRenderer` instance. Run `gatsby build` and the site will build without errors. + +All the code from this example is [available on GitHub](https://github.com/m-allanson/gatsby-replace-renderer-example/commits/master). diff --git a/packages/gatsby/cache-dir/api-runner-browser.js b/packages/gatsby/cache-dir/api-runner-browser.js index b68e5ba1970c7..bd0b255d90c8f 100644 --- a/packages/gatsby/cache-dir/api-runner-browser.js +++ b/packages/gatsby/cache-dir/api-runner-browser.js @@ -1,8 +1,14 @@ // During bootstrap, we write requires at top of this file which looks // basically like: // var plugins = [ -// require('/path/to/plugin1/gatsby-browser.js'), -// require('/path/to/plugin2/gatsby-browser.js'), +// { +// plugin: require("/path/to/plugin1/gatsby-browser.js"), +// options: { ... }, +// }, +// { +// plugin: require("/path/to/plugin2/gatsby-browser.js"), +// options: { ... }, +// }, // ] export function apiRunner(api, args, defaultReturn) { diff --git a/packages/gatsby/cache-dir/api-runner-ssr.js b/packages/gatsby/cache-dir/api-runner-ssr.js index 3fc53b5a72a1e..f5891ff43eaa2 100644 --- a/packages/gatsby/cache-dir/api-runner-ssr.js +++ b/packages/gatsby/cache-dir/api-runner-ssr.js @@ -1,15 +1,23 @@ // During bootstrap, we write requires at top of this file which looks like: // var plugins = [ -// require('/path/to/plugin1/gatsby-ssr.js'), -// require('/path/to/plugin2/gatsby-ssr.js'), +// { +// plugin: require("/path/to/plugin1/gatsby-ssr.js"), +// options: { ... }, +// }, +// { +// plugin: require("/path/to/plugin2/gatsby-ssr.js"), +// options: { ... }, +// }, // ] const apis = require(`./api-ssr-docs`) +// Run the specified API in any plugins that have implemented it module.exports = (api, args, defaultReturn) => { if (!apis[api]) { console.log(`This API doesn't exist`, api) } + // Run each plugin in series. let results = plugins.map(plugin => { if (plugin.plugin[api]) { diff --git a/packages/gatsby/cache-dir/develop-static-entry.js b/packages/gatsby/cache-dir/develop-static-entry.js index 2c4512665720a..c763978352eac 100644 --- a/packages/gatsby/cache-dir/develop-static-entry.js +++ b/packages/gatsby/cache-dir/develop-static-entry.js @@ -1,8 +1,8 @@ import React from "react" import { renderToStaticMarkup } from "react-dom/server" import { merge } from "lodash" -import apiRunner from "./api-runner-ssr" import testRequireError from "./test-require-error" +import apiRunner from "./api-runner-ssr" let HTML try { @@ -17,7 +17,6 @@ try { } module.exports = (locals, callback) => { - // const apiRunner = require(`${directory}/.cache/api-runner-ssr`) let headComponents = [] let htmlAttributes = {} let bodyAttributes = {} diff --git a/packages/gatsby/src/bootstrap/__mocks__/resolve-module-exports.js b/packages/gatsby/src/bootstrap/__mocks__/resolve-module-exports.js new file mode 100644 index 0000000000000..8bd33b3a6a031 --- /dev/null +++ b/packages/gatsby/src/bootstrap/__mocks__/resolve-module-exports.js @@ -0,0 +1,19 @@ +'use strict' + +let mockResults = {} + +module.exports = input => { + // return a mocked result + if (typeof input === `string`) { + return mockResults[input] + } + + // return default result + if (typeof input !== `object`) { + return [] + } + + // set mock results + mockResults = Object.assign({}, input) + return undefined +} diff --git a/packages/gatsby/src/bootstrap/index.js b/packages/gatsby/src/bootstrap/index.js index 89aa3ef84cabf..2c2b5b9157242 100644 --- a/packages/gatsby/src/bootstrap/index.js +++ b/packages/gatsby/src/bootstrap/index.js @@ -1,13 +1,13 @@ /* @flow */ const Promise = require(`bluebird`) -const glob = require(`glob`) const _ = require(`lodash`) const slash = require(`slash`) const fs = require(`fs-extra`) const md5File = require(`md5-file/promise`) const crypto = require(`crypto`) const del = require(`del`) +const path = require(`path`) const apiRunnerNode = require(`../utils/api-runner-node`) const { graphql } = require(`graphql`) @@ -175,9 +175,17 @@ module.exports = async (args: BootstrapArgs) => { // Find plugins which implement gatsby-browser and gatsby-ssr and write // out api-runners for them. - const hasAPIFile = (env, plugin) => - // TODO make this async... - glob.sync(`${plugin.resolve}/gatsby-${env}*`)[0] + const hasAPIFile = (env, plugin) => { + // The plugin loader has disabled SSR APIs for this plugin. Usually due to + // multiple implementations of an API that can only be implemented once + if (env === `ssr` && plugin.skipSSR === true) return undefined + + const envAPIs = plugin[`${env}APIs`] + if (envAPIs && Array.isArray(envAPIs) && envAPIs.length > 0 ) { + return path.join(plugin.resolve, `gatsby-${env}.js`) + } + return undefined + } const ssrPlugins = _.filter( flattenedPlugins.map(plugin => { diff --git a/packages/gatsby/src/bootstrap/load-plugins.js b/packages/gatsby/src/bootstrap/load-plugins.js deleted file mode 100644 index 3203d144c4d47..0000000000000 --- a/packages/gatsby/src/bootstrap/load-plugins.js +++ /dev/null @@ -1,342 +0,0 @@ -const _ = require(`lodash`) -const slash = require(`slash`) -const fs = require(`fs`) -const path = require(`path`) -const crypto = require(`crypto`) -const glob = require(`glob`) - -const { store } = require(`../redux`) -const nodeAPIs = require(`../utils/api-node-docs`) -const browserAPIs = require(`../utils/api-browser-docs`) -const ssrAPIs = require(`../../cache-dir/api-ssr-docs`) -const resolveModuleExports = require(`./resolve-module-exports`) - -// Given a plugin object, an array of the API names it exports and an -// array of valid API names, return an array of invalid API exports. -const getBadExports = (plugin, pluginAPIKeys, apis) => { - let badExports = [] - // Discover any exports from plugins which are not "known" - badExports = badExports.concat( - _.difference(pluginAPIKeys, apis).map(e => { - return { - exportName: e, - pluginName: plugin.name, - pluginVersion: plugin.version, - } - }) - ) - return badExports -} - -const getBadExportsMessage = (badExports, exportType, apis) => { - const { stripIndent } = require(`common-tags`) - const stringSimiliarity = require(`string-similarity`) - let capitalized = `${exportType[0].toUpperCase()}${exportType.slice(1)}` - if (capitalized === `Ssr`) capitalized = `SSR` - - let message = `\n` - message += stripIndent` - Your plugins must export known APIs from their gatsby-${exportType}.js. - The following exports aren't APIs. Perhaps you made a typo or - your plugin is outdated? - - See https://www.gatsbyjs.org/docs/${exportType}-apis/ for the list of Gatsby ${capitalized} APIs` - - badExports.forEach(bady => { - const similarities = stringSimiliarity.findBestMatch(bady.exportName, apis) - message += `\n — ` - if (bady.pluginName == `default-site-plugin`) { - message += `Your site's gatsby-${exportType}.js is exporting a variable named "${ - bady.exportName - }" which isn't an API.` - } else { - message += `The plugin "${bady.pluginName}@${ - bady.pluginVersion - }" is exporting a variable named "${bady.exportName}" which isn't an API.` - } - if (similarities.bestMatch.rating > 0.5) { - message += ` Perhaps you meant to export "${ - similarities.bestMatch.target - }"?` - } - }) - - return message -} - -function createFileContentHash(root, globPattern) { - const hash = crypto.createHash(`md5`) - const files = glob.sync(`${root}/${globPattern}`, { nodir: true }) - - files.forEach(filepath => { - hash.update(fs.readFileSync(filepath)) - }) - - return hash.digest(`hex`) -} - -/** - * @typedef {Object} PluginInfo - * @property {string} resolve The absolute path to the plugin - * @property {string} name The plugin name - * @property {string} version The plugin version (can be content hash) - */ - -/** - * resolvePlugin - * @param {string} pluginName - * This can be a name of a local plugin, the name of a plugin located in - * node_modules, or a Gatsby internal plugin. In the last case the pluginName - * will be an absolute path. - * @return {PluginInfo} - */ -function resolvePlugin(pluginName) { - // Only find plugins when we're not given an absolute path - if (!fs.existsSync(pluginName)) { - // Find the plugin in the local plugins folder - const resolvedPath = slash(path.resolve(`./plugins/${pluginName}`)) - - if (fs.existsSync(resolvedPath)) { - if (fs.existsSync(`${resolvedPath}/package.json`)) { - const packageJSON = JSON.parse( - fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`) - ) - - return { - resolve: resolvedPath, - name: packageJSON.name || pluginName, - id: `Plugin ${packageJSON.name || pluginName}`, - version: - packageJSON.version || createFileContentHash(resolvedPath, `**`), - } - } else { - // Make package.json a requirement for local plugins too - throw new Error(`Plugin ${pluginName} requires a package.json file`) - } - } - } - - /** - * Here we have an absolute path to an internal plugin, or a name of a module - * which should be located in node_modules. - */ - try { - const resolvedPath = slash(path.dirname(require.resolve(pluginName))) - - const packageJSON = JSON.parse( - fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`) - ) - - return { - resolve: resolvedPath, - id: `Plugin ${packageJSON.name}`, - name: packageJSON.name, - version: packageJSON.version, - } - } catch (err) { - throw new Error(`Unable to find plugin "${pluginName}"`) - } -} - -module.exports = async (config = {}) => { - // Instantiate plugins. - const plugins = [] - - // Create fake little site with a plugin for testing this - // w/ snapshots. Move plugin processing to its own module. - // Also test adding to redux store. - const processPlugin = plugin => { - if (_.isString(plugin)) { - const info = resolvePlugin(plugin) - - return { - ...info, - pluginOptions: { - plugins: [], - }, - } - } else { - // Plugins can have plugins. - const subplugins = [] - if (plugin.options && plugin.options.plugins) { - plugin.options.plugins.forEach(p => { - subplugins.push(processPlugin(p)) - }) - - plugin.options.plugins = subplugins - } - - // Add some default values for tests as we don't actually - // want to try to load anything during tests. - if (plugin.resolve === `___TEST___`) { - return { - name: `TEST`, - pluginOptions: { - plugins: [], - }, - } - } - - const info = resolvePlugin(plugin.resolve) - - return { - ...info, - pluginOptions: _.merge({ plugins: [] }, plugin.options), - } - } - } - - // Add internal plugins - plugins.push( - processPlugin(path.join(__dirname, `../internal-plugins/dev-404-page`)) - ) - plugins.push( - processPlugin( - path.join(__dirname, `../internal-plugins/component-page-creator`) - ) - ) - plugins.push( - processPlugin( - path.join(__dirname, `../internal-plugins/component-layout-creator`) - ) - ) - plugins.push( - processPlugin( - path.join(__dirname, `../internal-plugins/internal-data-bridge`) - ) - ) - plugins.push( - processPlugin(path.join(__dirname, `../internal-plugins/prod-404`)) - ) - plugins.push( - processPlugin(path.join(__dirname, `../internal-plugins/query-runner`)) - ) - - // Add plugins from the site config. - if (config.plugins) { - config.plugins.forEach(plugin => { - plugins.push(processPlugin(plugin)) - }) - } - - // Add the site's default "plugin" i.e. gatsby-x files in root of site. - plugins.push({ - resolve: slash(process.cwd()), - id: `Plugin default-site-plugin`, - name: `default-site-plugin`, - version: createFileContentHash(process.cwd(), `gatsby-*`), - pluginOptions: { - plugins: [], - }, - }) - - // Create a "flattened" array of plugins with all subplugins - // brought to the top-level. This simplifies running gatsby-* files - // for subplugins. - const flattenedPlugins = [] - const extractPlugins = plugin => { - plugin.pluginOptions.plugins.forEach(subPlugin => { - flattenedPlugins.push(subPlugin) - extractPlugins(subPlugin) - }) - } - - plugins.forEach(plugin => { - flattenedPlugins.push(plugin) - extractPlugins(plugin) - }) - - // Validate plugins before saving. Plugins can only export known APIs. The known - // APIs that a plugin supports are saved along with the plugin in the store for - // easier filtering later. If there are bad exports (either typos, outdated, or - // plain incorrect), then we output a readable error & quit. - const apis = {} - apis.node = _.keys(nodeAPIs) - apis.browser = _.keys(browserAPIs) - apis.ssr = _.keys(ssrAPIs) - - const allAPIs = [...apis.node, ...apis.browser, ...apis.ssr] - - const apiToPlugins = allAPIs.reduce((acc, value) => { - acc[value] = [] - return acc - }, {}) - - const badExports = { - node: [], - browser: [], - ssr: [], - } - - flattenedPlugins.forEach(plugin => { - plugin.nodeAPIs = [] - plugin.browserAPIs = [] - plugin.ssrAPIs = [] - - // Discover which APIs this plugin implements and store an array against - // the plugin node itself *and* in an API to plugins map for faster lookups - // later. - const pluginNodeExports = resolveModuleExports( - `${plugin.resolve}/gatsby-node` - ) - const pluginBrowserExports = resolveModuleExports( - `${plugin.resolve}/gatsby-browser` - ) - const pluginSSRExports = resolveModuleExports( - `${plugin.resolve}/gatsby-ssr` - ) - - if (pluginNodeExports.length > 0) { - plugin.nodeAPIs = _.intersection(pluginNodeExports, apis.node) - plugin.nodeAPIs.map(nodeAPI => apiToPlugins[nodeAPI].push(plugin.name)) - badExports.node = getBadExports(plugin, pluginNodeExports, apis.node) // Collate any bad exports - } - - if (pluginBrowserExports.length > 0) { - plugin.browserAPIs = _.intersection(pluginBrowserExports, apis.browser) - plugin.browserAPIs.map(browserAPI => - apiToPlugins[browserAPI].push(plugin.name) - ) - badExports.browser = getBadExports( - plugin, - pluginBrowserExports, - apis.browser - ) // Collate any bad exports - } - - if (pluginSSRExports.length > 0) { - plugin.ssrAPIs = _.intersection(pluginSSRExports, apis.ssr) - plugin.ssrAPIs.map(ssrAPI => apiToPlugins[ssrAPI].push(plugin.name)) - badExports.ssr = getBadExports(plugin, pluginSSRExports, apis.ssr) // Collate any bad exports - } - }) - - // Output error messages for all bad exports - let bad = false - _.toPairs(badExports).forEach(bad => { - const [exportType, entries] = bad - if (entries.length > 0) { - bad = true - console.log(getBadExportsMessage(entries, exportType, apis[exportType])) - } - }) - - if (bad) process.exit() - - store.dispatch({ - type: `SET_SITE_PLUGINS`, - payload: plugins, - }) - - store.dispatch({ - type: `SET_SITE_FLATTENED_PLUGINS`, - payload: flattenedPlugins, - }) - - store.dispatch({ - type: `SET_SITE_API_TO_PLUGINS`, - payload: apiToPlugins, - }) - - return flattenedPlugins -} diff --git a/packages/gatsby/src/bootstrap/__tests__/__snapshots__/load-plugins.js.snap b/packages/gatsby/src/bootstrap/load-plugins/__tests__/__snapshots__/load-plugins.js.snap similarity index 98% rename from packages/gatsby/src/bootstrap/__tests__/__snapshots__/load-plugins.js.snap rename to packages/gatsby/src/bootstrap/load-plugins/__tests__/__snapshots__/load-plugins.js.snap index 4609e91b310ea..7148fa293ff8e 100644 --- a/packages/gatsby/src/bootstrap/__tests__/__snapshots__/load-plugins.js.snap +++ b/packages/gatsby/src/bootstrap/load-plugins/__tests__/__snapshots__/load-plugins.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Load plugins Loads plugins defined with an object but without an option key 1`] = ` +exports[`Load plugins Load plugins for a site 1`] = ` Array [ Object { "browserAPIs": Array [], @@ -88,16 +88,6 @@ Array [ "ssrAPIs": Array [], "version": "1.0.0", }, - Object { - "browserAPIs": Array [], - "name": "TEST", - "nodeAPIs": Array [], - "pluginOptions": Object { - "plugins": Array [], - }, - "resolve": "", - "ssrAPIs": Array [], - }, Object { "browserAPIs": Array [], "id": "Plugin default-site-plugin", @@ -113,7 +103,7 @@ Array [ ] `; -exports[`Load plugins load plugins for a site 1`] = ` +exports[`Load plugins Loads plugins defined with an object but without an option key 1`] = ` Array [ Object { "browserAPIs": Array [], @@ -201,6 +191,16 @@ Array [ "ssrAPIs": Array [], "version": "1.0.0", }, + Object { + "browserAPIs": Array [], + "name": "TEST", + "nodeAPIs": Array [], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "", + "ssrAPIs": Array [], + }, Object { "browserAPIs": Array [], "id": "Plugin default-site-plugin", diff --git a/packages/gatsby/src/bootstrap/load-plugins/__tests__/__snapshots__/validate.js.snap b/packages/gatsby/src/bootstrap/load-plugins/__tests__/__snapshots__/validate.js.snap new file mode 100644 index 0000000000000..28d5ce3f1054d --- /dev/null +++ b/packages/gatsby/src/bootstrap/load-plugins/__tests__/__snapshots__/validate.js.snap @@ -0,0 +1,259 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`collatePluginAPIs Identifies APIs used by a site's plugins 1`] = ` +Object { + "apiToPlugins": Object { + "browser-1": Array [ + "foo-plugin", + ], + "browser-2": Array [ + "foo-plugin", + "default-site-plugin", + ], + "browser-3": Array [ + "default-site-plugin", + ], + "browser-4": Array [], + "node-1": Array [ + "foo-plugin", + ], + "node-2": Array [ + "foo-plugin", + "default-site-plugin", + ], + "node-3": Array [ + "default-site-plugin", + ], + "node-4": Array [], + "ssr-1": Array [ + "foo-plugin", + ], + "ssr-2": Array [ + "foo-plugin", + "default-site-plugin", + ], + "ssr-3": Array [ + "default-site-plugin", + ], + "ssr-4": Array [], + }, + "badExports": Object { + "browser": Array [], + "node": Array [], + "ssr": Array [], + }, + "flattenedPlugins": Array [ + Object { + "browserAPIs": Array [ + "browser-1", + "browser-2", + ], + "id": "Plugin foo", + "name": "foo-plugin", + "nodeAPIs": Array [ + "node-1", + "node-2", + ], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "/foo", + "ssrAPIs": Array [ + "ssr-1", + "ssr-2", + ], + "version": "1.0.0", + }, + Object { + "browserAPIs": Array [ + "browser-2", + "browser-3", + ], + "id": "Plugin default-site-plugin", + "name": "default-site-plugin", + "nodeAPIs": Array [ + "node-2", + "node-3", + ], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "/bar", + "ssrAPIs": Array [ + "ssr-2", + "ssr-3", + ], + "version": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + ], +} +`; + +exports[`collatePluginAPIs Identifies incorrect APIs used by a site's plugins 1`] = ` +Object { + "apiToPlugins": Object { + "browser-1": Array [ + "foo-plugin", + ], + "browser-2": Array [ + "foo-plugin", + ], + "browser-3": Array [], + "browser-4": Array [], + "node-1": Array [ + "foo-plugin", + ], + "node-2": Array [ + "foo-plugin", + ], + "node-3": Array [], + "node-4": Array [], + "ssr-1": Array [ + "foo-plugin", + ], + "ssr-2": Array [ + "foo-plugin", + ], + "ssr-3": Array [], + "ssr-4": Array [], + }, + "badExports": Object { + "browser": Array [ + Object { + "exportName": "bad-browser-2", + "pluginName": "default-site-plugin", + "pluginVersion": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + Object { + "exportName": "bad-browser-3", + "pluginName": "default-site-plugin", + "pluginVersion": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + ], + "node": Array [ + Object { + "exportName": "bad-node-2", + "pluginName": "default-site-plugin", + "pluginVersion": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + Object { + "exportName": "bad-node-3", + "pluginName": "default-site-plugin", + "pluginVersion": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + ], + "ssr": Array [ + Object { + "exportName": "bad-ssr-2", + "pluginName": "default-site-plugin", + "pluginVersion": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + Object { + "exportName": "bad-ssr-3", + "pluginName": "default-site-plugin", + "pluginVersion": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + ], + }, + "flattenedPlugins": Array [ + Object { + "browserAPIs": Array [ + "browser-1", + "browser-2", + ], + "id": "Plugin foo", + "name": "foo-plugin", + "nodeAPIs": Array [ + "node-1", + "node-2", + ], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "/foo", + "ssrAPIs": Array [ + "ssr-1", + "ssr-2", + ], + "version": "1.0.0", + }, + Object { + "browserAPIs": Array [], + "id": "Plugin default-site-plugin", + "name": "default-site-plugin", + "nodeAPIs": Array [], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "/bad-apis", + "ssrAPIs": Array [], + "version": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, + ], +} +`; + +exports[`handleMultipleReplaceRenderers Does nothing when replaceRenderers is implemented once 1`] = ` +Array [ + Object { + "browserAPIs": Array [], + "id": "Plugin foo", + "name": "foo-plugin", + "nodeAPIs": Array [], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "___TEST___", + "ssrAPIs": Array [ + "replaceRenderer", + ], + "version": "1.0.0", + }, + Object { + "browserAPIs": Array [], + "id": "Plugin default-site-plugin", + "name": "default-site-plugin", + "nodeAPIs": Array [], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "___TEST___", + "ssrAPIs": Array [], + "version": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, +] +`; + +exports[`handleMultipleReplaceRenderers Sets skipSSR when replaceRenderers is implemented more than once 1`] = ` +Array [ + Object { + "browserAPIs": Array [], + "id": "Plugin foo", + "name": "foo-plugin", + "nodeAPIs": Array [], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "___TEST___", + "skipSSR": true, + "ssrAPIs": Array [ + "replaceRenderer", + ], + "version": "1.0.0", + }, + Object { + "browserAPIs": Array [], + "id": "Plugin default-site-plugin", + "name": "default-site-plugin", + "nodeAPIs": Array [], + "pluginOptions": Object { + "plugins": Array [], + }, + "resolve": "___TEST___", + "ssrAPIs": Array [ + "replaceRenderer", + ], + "version": "ec21d02c31ab044d027a1d2fcaeb4a79", + }, +] +`; diff --git a/packages/gatsby/src/bootstrap/__tests__/load-plugins.js b/packages/gatsby/src/bootstrap/load-plugins/__tests__/load-plugins.js similarity index 90% rename from packages/gatsby/src/bootstrap/__tests__/load-plugins.js rename to packages/gatsby/src/bootstrap/load-plugins/__tests__/load-plugins.js index 75922f986e18f..7e15fc064615d 100644 --- a/packages/gatsby/src/bootstrap/__tests__/load-plugins.js +++ b/packages/gatsby/src/bootstrap/load-plugins/__tests__/load-plugins.js @@ -1,7 +1,7 @@ -const loadPlugins = require(`../load-plugins`) +const loadPlugins = require(`../index`) describe(`Load plugins`, () => { - it(`load plugins for a site`, async () => { + it(`Load plugins for a site`, async () => { let plugins = await loadPlugins({ plugins: [] }) // Delete the resolve path as that varies depending diff --git a/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.js b/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.js new file mode 100644 index 0000000000000..2f09e0d09b88c --- /dev/null +++ b/packages/gatsby/src/bootstrap/load-plugins/__tests__/validate.js @@ -0,0 +1,189 @@ +jest.mock(`../../resolve-module-exports`) + +const { + collatePluginAPIs, + handleBadExports, + handleMultipleReplaceRenderers, +} = require(`../validate`) + +describe(`collatePluginAPIs`, () => { + const MOCK_RESULTS = { + "/foo/gatsby-node": [`node-1`, `node-2`], + "/foo/gatsby-browser": [`browser-1`, `browser-2`], + "/foo/gatsby-ssr": [`ssr-1`, `ssr-2`], + "/bar/gatsby-node": [`node-2`, `node-3`], + "/bar/gatsby-browser": [`browser-2`, `browser-3`], + "/bar/gatsby-ssr": [`ssr-2`, `ssr-3`], + "/bad-apis/gatsby-node": [`bad-node-2`, `bad-node-3`], + "/bad-apis/gatsby-browser": [`bad-browser-2`, `bad-browser-3`], + "/bad-apis/gatsby-ssr": [`bad-ssr-2`, `bad-ssr-3`], + } + + beforeEach(() => { + const resolveModuleExports = require(`../../resolve-module-exports`) + resolveModuleExports(MOCK_RESULTS) + }) + + it(`Identifies APIs used by a site's plugins`, async () => { + const apis = { + node: [`node-1`, `node-2`, `node-3`, `node-4`], + browser: [`browser-1`, `browser-2`, `browser-3`, `browser-4`], + ssr: [`ssr-1`, `ssr-2`, `ssr-3`, `ssr-4`], + } + const flattenedPlugins = [ + { + resolve: `/foo`, + id: `Plugin foo`, + name: `foo-plugin`, + version: `1.0.0`, + pluginOptions: { plugins: [] }, + }, + { + resolve: `/bar`, + id: `Plugin default-site-plugin`, + name: `default-site-plugin`, + version: `ec21d02c31ab044d027a1d2fcaeb4a79`, + pluginOptions: { plugins: [] }, + }, + ] + + let result = collatePluginAPIs({ apis, flattenedPlugins }) + expect(result).toMatchSnapshot() + }) + + it(`Identifies incorrect APIs used by a site's plugins`, async () => { + const apis = { + node: [`node-1`, `node-2`, `node-3`, `node-4`], + browser: [`browser-1`, `browser-2`, `browser-3`, `browser-4`], + ssr: [`ssr-1`, `ssr-2`, `ssr-3`, `ssr-4`], + } + const flattenedPlugins = [ + { + resolve: `/foo`, + id: `Plugin foo`, + name: `foo-plugin`, + version: `1.0.0`, + pluginOptions: { plugins: [] }, + }, + { + resolve: `/bad-apis`, + id: `Plugin default-site-plugin`, + name: `default-site-plugin`, + version: `ec21d02c31ab044d027a1d2fcaeb4a79`, + pluginOptions: { plugins: [] }, + }, + ] + + let result = collatePluginAPIs({ apis, flattenedPlugins }) + expect(result).toMatchSnapshot() + }) +}) + +describe(`handleBadExports`, () => { + it(`Does nothing when there are no bad exports`, async () => { + const result = handleBadExports({ + apis: { + node: [`these`, `can`, `be`], + browser: [`anything`, `as there`], + ssr: [`are no`, `bad errors`], + }, + badExports: { + node: [], + browser: [], + ssr: [], + }, + }) + + expect(result).toEqual(false) + }) + + it(`Returns true and logs a message when bad exports are detected`, async () => { + const result = handleBadExports({ + apis: { + node: [``], + browser: [``], + ssr: [`notFoo`, `bar`], + }, + badExports: { + node: [], + browser: [], + ssr: [ + { + exportName: `foo`, + pluginName: `default-site-plugin`, + }, + ], + }, + }) + // TODO: snapshot console.log()'s from handleBadExports? + expect(result).toEqual(true) + }) +}) + +describe(`handleMultipleReplaceRenderers`, () => { + it(`Does nothing when replaceRenderers is implemented once`, async () => { + const apiToPlugins = { + replaceRenderer: [`foo-plugin`], + } + + const flattenedPlugins = [ + { + resolve: `___TEST___`, + id: `Plugin foo`, + name: `foo-plugin`, + version: `1.0.0`, + pluginOptions: { plugins: [] }, + nodeAPIs: [], + browserAPIs: [], + ssrAPIs: [`replaceRenderer`], + }, + { + resolve: `___TEST___`, + id: `Plugin default-site-plugin`, + name: `default-site-plugin`, + version: `ec21d02c31ab044d027a1d2fcaeb4a79`, + pluginOptions: { plugins: [] }, + nodeAPIs: [], + browserAPIs: [], + ssrAPIs: [], + }, + ] + + const result = handleMultipleReplaceRenderers({ apiToPlugins, flattenedPlugins }) + + expect(result).toMatchSnapshot() + }) + + it(`Sets skipSSR when replaceRenderers is implemented more than once`, async () => { + const apiToPlugins = { + replaceRenderer: [`foo-plugin`, `default-site-plugin`], + } + + const flattenedPlugins = [ + { + resolve: `___TEST___`, + id: `Plugin foo`, + name: `foo-plugin`, + version: `1.0.0`, + pluginOptions: { plugins: [] }, + nodeAPIs: [], + browserAPIs: [], + ssrAPIs: [`replaceRenderer`], + }, + { + resolve: `___TEST___`, + id: `Plugin default-site-plugin`, + name: `default-site-plugin`, + version: `ec21d02c31ab044d027a1d2fcaeb4a79`, + pluginOptions: { plugins: [] }, + nodeAPIs: [], + browserAPIs: [], + ssrAPIs: [`replaceRenderer`], + }, + ] + + const result = handleMultipleReplaceRenderers({ apiToPlugins, flattenedPlugins }) + + expect(result).toMatchSnapshot() + }) +}) diff --git a/packages/gatsby/src/bootstrap/load-plugins/index.js b/packages/gatsby/src/bootstrap/load-plugins/index.js new file mode 100644 index 0000000000000..c197f7ddba7b8 --- /dev/null +++ b/packages/gatsby/src/bootstrap/load-plugins/index.js @@ -0,0 +1,82 @@ +const _ = require(`lodash`) + +const { store } = require(`../../redux`) +const nodeAPIs = require(`../../utils/api-node-docs`) +const browserAPIs = require(`../../utils/api-browser-docs`) +const ssrAPIs = require(`../../../cache-dir/api-ssr-docs`) +const loadPlugins = require(`./load`) +const { + collatePluginAPIs, + handleBadExports, + handleMultipleReplaceRenderers, +} = require(`./validate`) + +const apis = { + node: _.keys(nodeAPIs), + browser: _.keys(browserAPIs), + ssr: _.keys(ssrAPIs), +} + +// Create a "flattened" array of plugins with all subplugins +// brought to the top-level. This simplifies running gatsby-* files +// for subplugins. +const flattenPlugins = plugins => { + const flattened = [] + const extractPlugins = plugin => { + plugin.pluginOptions.plugins.forEach(subPlugin => { + flattened.push(subPlugin) + extractPlugins(subPlugin) + }) + } + + plugins.forEach(plugin => { + flattened.push(plugin) + extractPlugins(plugin) + }) + + return flattened +} + +module.exports = async (config = {}) => { + // Collate internal plugins, site config plugins, site default plugins + const plugins = await loadPlugins(config) + + // Create a flattened array of the plugins + let flattenedPlugins = flattenPlugins(plugins) + + // Work out which plugins use which APIs, including those which are not + // valid Gatsby APIs, aka 'badExports' + const x = collatePluginAPIs({ apis, flattenedPlugins }) + flattenedPlugins = x.flattenedPlugins + const apiToPlugins = x.apiToPlugins + const badExports = x.badExports + + // Show errors for any non-Gatsby APIs exported from plugins + const isBad = handleBadExports({ apis, badExports }) + if (isBad && process.env.NODE_ENV === `production`) process.exit(1) // TODO: change to panicOnBuild + + // Show errors when ReplaceRenderer has been implemented multiple times + flattenedPlugins = handleMultipleReplaceRenderers({ + apiToPlugins, + flattenedPlugins, + }) + + // If we get this far, everything looks good. Update the store + store.dispatch({ + type: `SET_SITE_FLATTENED_PLUGINS`, + payload: flattenedPlugins, + }) + + store.dispatch({ + type: `SET_SITE_API_TO_PLUGINS`, + payload: apiToPlugins, + }) + + // TODO: Is this used? plugins and flattenedPlugins may be out of sync + store.dispatch({ + type: `SET_SITE_PLUGINS`, + payload: plugins, + }) + + return flattenedPlugins +} diff --git a/packages/gatsby/src/bootstrap/load-plugins/load.js b/packages/gatsby/src/bootstrap/load-plugins/load.js new file mode 100644 index 0000000000000..139bc8e30a59f --- /dev/null +++ b/packages/gatsby/src/bootstrap/load-plugins/load.js @@ -0,0 +1,163 @@ +const _ = require(`lodash`) +const slash = require(`slash`) +const fs = require(`fs`) +const path = require(`path`) +const crypto = require(`crypto`) +const glob = require(`glob`) + +function createFileContentHash(root, globPattern) { + const hash = crypto.createHash(`md5`) + const files = glob.sync(`${root}/${globPattern}`, { nodir: true }) + + files.forEach(filepath => { + hash.update(fs.readFileSync(filepath)) + }) + + return hash.digest(`hex`) +} + +/** + * @typedef {Object} PluginInfo + * @property {string} resolve The absolute path to the plugin + * @property {string} name The plugin name + * @property {string} version The plugin version (can be content hash) + */ + +/** + * resolvePlugin + * @param {string} pluginName + * This can be a name of a local plugin, the name of a plugin located in + * node_modules, or a Gatsby internal plugin. In the last case the pluginName + * will be an absolute path. + * @return {PluginInfo} + */ +function resolvePlugin(pluginName) { + // Only find plugins when we're not given an absolute path + if (!fs.existsSync(pluginName)) { + // Find the plugin in the local plugins folder + const resolvedPath = slash(path.resolve(`../plugins/${pluginName}`)) + + if (fs.existsSync(resolvedPath)) { + if (fs.existsSync(`${resolvedPath}/package.json`)) { + const packageJSON = JSON.parse( + fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`) + ) + + return { + resolve: resolvedPath, + name: packageJSON.name || pluginName, + id: `Plugin ${packageJSON.name || pluginName}`, + version: + packageJSON.version || createFileContentHash(resolvedPath, `**`), + } + } else { + // Make package.json a requirement for local plugins too + throw new Error(`Plugin ${pluginName} requires a package.json file`) + } + } + } + + /** + * Here we have an absolute path to an internal plugin, or a name of a module + * which should be located in node_modules. + */ + try { + const resolvedPath = slash(path.dirname(require.resolve(pluginName))) + + const packageJSON = JSON.parse( + fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`) + ) + + return { + resolve: resolvedPath, + id: `Plugin ${packageJSON.name}`, + name: packageJSON.name, + version: packageJSON.version, + } + } catch (err) { + throw new Error(`Unable to find plugin "${pluginName}"`) + } +} + +module.exports = async (config = {}) => { + // Instantiate plugins. + const plugins = [] + + // Create fake little site with a plugin for testing this + // w/ snapshots. Move plugin processing to its own module. + // Also test adding to redux store. + const processPlugin = plugin => { + if (_.isString(plugin)) { + const info = resolvePlugin(plugin) + + return { + ...info, + pluginOptions: { + plugins: [], + }, + } + } else { + // Plugins can have plugins. + const subplugins = [] + if (plugin.options && plugin.options.plugins) { + plugin.options.plugins.forEach(p => { + subplugins.push(processPlugin(p)) + }) + + plugin.options.plugins = subplugins + } + + // Add some default values for tests as we don't actually + // want to try to load anything during tests. + if (plugin.resolve === `___TEST___`) { + return { + name: `TEST`, + pluginOptions: { + plugins: [], + }, + } + } + + const info = resolvePlugin(plugin.resolve) + + return { + ...info, + pluginOptions: _.merge({ plugins: [] }, plugin.options), + } + } + } + + // Add internal plugins + const internalPlugins = [ + `../../internal-plugins/dev-404-page`, + `../../internal-plugins/component-page-creator`, + `../../internal-plugins/component-layout-creator`, + `../../internal-plugins/internal-data-bridge`, + `../../internal-plugins/prod-404`, + `../../internal-plugins/query-runner`, + ] + internalPlugins.forEach(relPath => { + const absPath = path.join(__dirname, relPath) + plugins.push(processPlugin(absPath)) + }) + + // Add plugins from the site config. + if (config.plugins) { + config.plugins.forEach(plugin => { + plugins.push(processPlugin(plugin)) + }) + } + + // Add the site's default "plugin" i.e. gatsby-x files in root of site. + plugins.push({ + resolve: slash(process.cwd()), + id: `Plugin default-site-plugin`, + name: `default-site-plugin`, + version: createFileContentHash(process.cwd(), `gatsby-*`), + pluginOptions: { + plugins: [], + }, + }) + + return plugins +} diff --git a/packages/gatsby/src/bootstrap/load-plugins/validate.js b/packages/gatsby/src/bootstrap/load-plugins/validate.js new file mode 100644 index 0000000000000..8da4ae65cf2ac --- /dev/null +++ b/packages/gatsby/src/bootstrap/load-plugins/validate.js @@ -0,0 +1,189 @@ +const _ = require(`lodash`) + +const reporter = require(`gatsby-cli/lib/reporter`) +const resolveModuleExports = require(`../resolve-module-exports`) + +// Given a plugin object, an array of the API names it exports and an +// array of valid API names, return an array of invalid API exports. +const getBadExports = (plugin, pluginAPIKeys, apis) => { + let badExports = [] + // Discover any exports from plugins which are not "known" + badExports = badExports.concat( + _.difference(pluginAPIKeys, apis).map(e => { + return { + exportName: e, + pluginName: plugin.name, + pluginVersion: plugin.version, + } + }) + ) + return badExports +} + +const getBadExportsMessage = (badExports, exportType, apis) => { + const { stripIndent } = require(`common-tags`) + const stringSimiliarity = require(`string-similarity`) + let capitalized = `${exportType[0].toUpperCase()}${exportType.slice(1)}` + if (capitalized === `Ssr`) capitalized = `SSR` + + let message = `\n` + message += stripIndent` + Your plugins must export known APIs from their gatsby-${exportType}.js. + The following exports aren't APIs. Perhaps you made a typo or + your plugin is outdated? + + See https://www.gatsbyjs.org/docs/${exportType}-apis/ for the list of Gatsby ${capitalized} APIs` + + badExports.forEach(bady => { + const similarities = stringSimiliarity.findBestMatch(bady.exportName, apis) + message += `\n — ` + if (bady.pluginName == `default-site-plugin`) { + message += `Your site's gatsby-${exportType}.js is exporting a variable named "${ + bady.exportName + }" which isn't an API.` + } else { + message += `The plugin "${bady.pluginName}@${ + bady.pluginVersion + }" is exporting a variable named "${bady.exportName}" which isn't an API.` + } + if (similarities.bestMatch.rating > 0.5) { + message += ` Perhaps you meant to export "${ + similarities.bestMatch.target + }"?` + } + }) + + return message +} + +const handleBadExports = ({ apis, badExports }) => { + // Output error messages for all bad exports + let isBad = false + _.toPairs(badExports).forEach(badItem => { + const [exportType, entries] = badItem + if (entries.length > 0) { + isBad = true + console.log(getBadExportsMessage(entries, exportType, apis[exportType])) + } + }) + return isBad +} + +/** + * Identify which APIs each plugin exports + */ +const collatePluginAPIs = ({ apis, flattenedPlugins }) => { + const allAPIs = [...apis.node, ...apis.browser, ...apis.ssr] + const apiToPlugins = allAPIs.reduce((acc, value) => { + acc[value] = [] + return acc + }, {}) + + // Get a list of bad exports + const badExports = { + node: [], + browser: [], + ssr: [], + } + + flattenedPlugins.forEach(plugin => { + plugin.nodeAPIs = [] + plugin.browserAPIs = [] + plugin.ssrAPIs = [] + + // Discover which APIs this plugin implements and store an array against + // the plugin node itself *and* in an API to plugins map for faster lookups + // later. + const pluginNodeExports = resolveModuleExports( + `${plugin.resolve}/gatsby-node` + ) + const pluginBrowserExports = resolveModuleExports( + `${plugin.resolve}/gatsby-browser` + ) + const pluginSSRExports = resolveModuleExports( + `${plugin.resolve}/gatsby-ssr` + ) + + if (pluginNodeExports.length > 0) { + plugin.nodeAPIs = _.intersection(pluginNodeExports, apis.node) + plugin.nodeAPIs.map(nodeAPI => apiToPlugins[nodeAPI].push(plugin.name)) + badExports.node = getBadExports(plugin, pluginNodeExports, apis.node) // Collate any bad exports + } + + if (pluginBrowserExports.length > 0) { + plugin.browserAPIs = _.intersection(pluginBrowserExports, apis.browser) + plugin.browserAPIs.map(browserAPI => + apiToPlugins[browserAPI].push(plugin.name) + ) + badExports.browser = getBadExports( + plugin, + pluginBrowserExports, + apis.browser + ) // Collate any bad exports + } + + if (pluginSSRExports.length > 0) { + plugin.ssrAPIs = _.intersection(pluginSSRExports, apis.ssr) + plugin.ssrAPIs.map(ssrAPI => apiToPlugins[ssrAPI].push(plugin.name)) + badExports.ssr = getBadExports(plugin, pluginSSRExports, apis.ssr) // Collate any bad exports + } + }) + + return { apiToPlugins, flattenedPlugins, badExports } +} + +const handleMultipleReplaceRenderers = ({ apiToPlugins, flattenedPlugins }) => { + // multiple replaceRenderers may cause problems at build time + if (apiToPlugins.replaceRenderer.length > 1) { + const rendererPlugins = [...apiToPlugins.replaceRenderer] + + if (rendererPlugins.includes(`default-site-plugin`)) { + reporter.warn(`replaceRenderer API found in these plugins:`) + reporter.warn(rendererPlugins.join(`, `)) + reporter.warn( + `This might be an error, see: https://www.gatsbyjs.org/docs/debugging-replace-renderer-api/` + ) + } else { + console.log(``) + reporter.error( + `Gatsby's replaceRenderer API is implemented by multiple plugins:` + ) + reporter.error(rendererPlugins.join(`, `)) + reporter.error(`This will break your build`) + reporter.error( + `See: https://www.gatsbyjs.org/docs/debugging-replace-renderer-api/` + ) + if (process.env.NODE_ENV === `production`) process.exit(1) + } + + // Now update plugin list so only final replaceRenderer will run + const ignorable = rendererPlugins.slice(0, -1) + + // For each plugin in ignorable, set a skipSSR flag to true + // This prevents apiRunnerSSR() from attempting to run it later + const messages = [] + flattenedPlugins.forEach((fp, i) => { + if (ignorable.includes(fp.name)) { + messages.push( + `Duplicate replaceRenderer found, skipping gatsby-ssr.js for plugin: ${ + fp.name + }` + ) + flattenedPlugins[i].skipSSR = true + } + }) + if (messages.length > 0) { + console.log(``) + messages.forEach(m => reporter.warn(m)) + console.log(``) + } + } + + return flattenedPlugins +} + +module.exports = { + collatePluginAPIs, + handleBadExports, + handleMultipleReplaceRenderers, +}