From be5447eeb2192f56a5a9825208e061d5d14e4b21 Mon Sep 17 00:00:00 2001 From: Mike Allanson Date: Thu, 15 Feb 2018 19:31:49 +0000 Subject: [PATCH] Use babylon to parse out exports from plugin files --- .../__tests__/resolve-module-exports.js | 143 ++++++++++++++++++ packages/gatsby/src/bootstrap/load-plugins.js | 48 ++---- .../src/bootstrap/resolve-module-exports.js | 59 ++++++++ 3 files changed, 216 insertions(+), 34 deletions(-) create mode 100644 packages/gatsby/src/bootstrap/__tests__/resolve-module-exports.js create mode 100644 packages/gatsby/src/bootstrap/resolve-module-exports.js diff --git a/packages/gatsby/src/bootstrap/__tests__/resolve-module-exports.js b/packages/gatsby/src/bootstrap/__tests__/resolve-module-exports.js new file mode 100644 index 0000000000000..3090e5f7316eb --- /dev/null +++ b/packages/gatsby/src/bootstrap/__tests__/resolve-module-exports.js @@ -0,0 +1,143 @@ +jest.mock(`fs`) + +const resolveModuleExports = require(`../resolve-module-exports`) +let resolver + +describe(`Resolve module exports`, () => { + const MOCK_FILE_INFO = { + "/bad/file": `const exports.blah = () = }}}`, + "/simple/export": `exports.foo = '';`, + "/multiple/export": `exports.bar = () => ''; exports.baz = {}; exports.foo = '';`, + "/import/with/export": `import React from 'react'; exports.baz = '';`, + "/realistic/export": ` + /* eslint-disable react/prop-types */ + /* globals window CustomEvent */ + import React, { createElement } from "react" + import { Transition } from "react-transition-group" + import createHistory from "history/createBrowserHistory" + + import getTransitionStyle from "./src/utils/getTransitionStyle" + + const timeout = 250 + const historyExitingEventType = 'history::exiting' + + const getUserConfirmation = (pathname, callback) => { + const event = new CustomEvent(historyExitingEventType, { detail: { pathname } }) + window.dispatchEvent(event) + setTimeout(() => { + callback(true) + }, timeout) + } + const history = createHistory({ getUserConfirmation }) + // block must return a string to conform + history.block((location, action) => location.pathname) + exports.replaceHistory = () => history + + class ReplaceComponentRenderer extends React.Component { + constructor(props) { + super(props) + this.state = { exiting: false, nextPageResources: {} } + this.listenerHandler = this.listenerHandler.bind(this) + } + + listenerHandler(event) { + const nextPageResources = this.props.loader.getResourcesForPathname( + event.detail.pathname, + nextPageResources => this.setState({ nextPageResources }) + ) || {} + this.setState({ exiting: true, nextPageResources }) + } + + componentDidMount() { + window.addEventListener(historyExitingEventType, this.listenerHandler) + } + + componentWillUnmount() { + window.removeEventListener(historyExitingEventType, this.listenerHandler) + } + + componentWillReceiveProps(nextProps) { + if (this.props.location.key !== nextProps.location.key) { + this.setState({ exiting: false, nextPageResources: {} }) + } + } + + render() { + const transitionProps = { + timeout: { + enter: 0, + exit: timeout, + }, + appear: true, + in: !this.state.exiting, + key: this.props.location.key, + } + return ( + + { + (status) => createElement(this.props.pageResources.component, { + ...this.props, + ...this.props.pageResources.json, + transition: { + status, + timeout, + style: getTransitionStyle({ status, timeout }), + nextPageResources: this.state.nextPageResources, + }, + }) + } + + ) + } + } + + // eslint-disable-next-line react/display-name + exports.replaceComponentRenderer = ({ props, loader }) => { + if (props.layout) { + return undefined + } + return createElement(ReplaceComponentRenderer, { ...props, loader }) + } + `, + } + + beforeEach(() => { + resolver = jest.fn(arg => arg) + require(`fs`).__setMockFiles(MOCK_FILE_INFO) + }) + + it(`Returns empty array for file paths that don't exist`, () => { + const result = resolveModuleExports(`/file/path/does/not/exist`) + expect(result).toEqual([]) + }) + + it(`Returns empty array for directory paths that don't exist`, () => { + const result = resolveModuleExports(`/directory/path/does/not/exist/`) + expect(result).toEqual([]) + }) + + it(`Returns empty array for invalid JavaScript`, () => { + const result = resolveModuleExports(`/bad/file`) + expect(result).toEqual([]) + }) + + it(`Resolves an export`, () => { + const result = resolveModuleExports(`/simple/export`, resolver) + expect(result).toEqual([`foo`]) + }) + + it(`Resolves multiple exports`, () => { + const result = resolveModuleExports(`/multiple/export`, resolver) + expect(result).toEqual([`bar`, `baz`, `foo`]) + }) + + it(`Resolves an export from an ES6 file`, () => { + const result = resolveModuleExports(`/import/with/export`, resolver) + expect(result).toEqual([`baz`]) + }) + + it(`Resolves exports from a larger file`, () => { + const result = resolveModuleExports(`/realistic/export`, resolver) + expect(result).toEqual([`replaceHistory`, `replaceComponentRenderer`]) + }) +}) diff --git a/packages/gatsby/src/bootstrap/load-plugins.js b/packages/gatsby/src/bootstrap/load-plugins.js index 47a777fc34192..78ecae71528c1 100644 --- a/packages/gatsby/src/bootstrap/load-plugins.js +++ b/packages/gatsby/src/bootstrap/load-plugins.js @@ -9,24 +9,7 @@ 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 testRequireError = require(`../utils/test-require-error`) -const report = require(`gatsby-cli/lib/reporter`) - -// Given a plugin object and a moduleName like `gatsby-node`, check that the -// path to moduleName can be resolved. -const resolvePluginModule = (plugin, moduleName) => { - let resolved = false - try { - resolved = require(`${plugin.resolve}/${moduleName}`) - } catch (err) { - if (!testRequireError(moduleName, err)) { - // ignore - } else { - report.panic(`Error requiring ${plugin.resolve}/${moduleName}.js`, err) - } - } - return resolved -} +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. @@ -290,32 +273,29 @@ module.exports = async (config = {}) => { plugin.browserAPIs = [] plugin.ssrAPIs = [] - const gatsbyNode = resolvePluginModule(plugin, `gatsby-node`) - const gatsbyBrowser = resolvePluginModule(plugin, `gatsby-browser`) - const gatsbySSR = resolvePluginModule(plugin, `gatsby-ssr`) - // 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. - if (gatsbyNode) { - const gatsbyNodeKeys = _.keys(gatsbyNode) - plugin.nodeAPIs = _.intersection(gatsbyNodeKeys, apis.node) + 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, gatsbyNodeKeys, apis.node) // Collate any bad exports + badExports.node = getBadExports(plugin, pluginNodeExports, apis.node) // Collate any bad exports } - if (gatsbyBrowser) { - const gatsbyBrowserKeys = _.keys(gatsbyBrowser) - plugin.browserAPIs = _.intersection(gatsbyBrowserKeys, apis.browser) + if (pluginBrowserExports.length > 0) { + plugin.browserAPIs = _.intersection(pluginBrowserExports, apis.browser) plugin.browserAPIs.map(browserAPI => apiToPlugins[browserAPI].push(plugin.name)) - badExports.browser = getBadExports(plugin, gatsbyBrowserKeys, apis.browser) // Collate any bad exports + badExports.browser = getBadExports(plugin, pluginBrowserExports, apis.browser) // Collate any bad exports } - if (gatsbySSR) { - const gatsbySSRKeys = _.keys(gatsbySSR) - plugin.ssrAPIs = _.intersection(gatsbySSRKeys, apis.ssr) + if (pluginSSRExports.length > 0) { + plugin.ssrAPIs = _.intersection(pluginSSRExports, apis.ssr) plugin.ssrAPIs.map(ssrAPI => apiToPlugins[ssrAPI].push(plugin.name)) - badExports.ssr = getBadExports(plugin, gatsbySSRKeys, apis.ssr) // Collate any bad exports + badExports.ssr = getBadExports(plugin, pluginSSRExports, apis.ssr) // Collate any bad exports } }) diff --git a/packages/gatsby/src/bootstrap/resolve-module-exports.js b/packages/gatsby/src/bootstrap/resolve-module-exports.js new file mode 100644 index 0000000000000..744263bd891fc --- /dev/null +++ b/packages/gatsby/src/bootstrap/resolve-module-exports.js @@ -0,0 +1,59 @@ +// @flow +const fs = require(`fs`) +const babylon = require(`babylon`) +const traverse = require(`babel-traverse`).default + +/** + * Given a `require.resolve()` compatible path pointing to a JS module, + * return an array listing the names of the module's exports. + * + * Returns [] for invalid paths and modules without exports. + * + * @param {string} modulePath + * @param {function} resolver + */ +module.exports = (modulePath, resolver = require.resolve) => { + let absPath + const exportNames = [] + + try { + absPath = resolver(modulePath) + } catch (err) { + return exportNames // doesn't exist + } + const code = fs.readFileSync(absPath, `utf8`) // get file contents + + const babylonOpts = { + sourceType: `module`, + allowImportExportEverywhere: true, + plugins: [ + `jsx`, + `doExpressions`, + `objectRestSpread`, + `decorators`, + `classProperties`, + `exportExtensions`, + `asyncGenerators`, + `functionBind`, + `functionSent`, + `dynamicImport`, + `flow`, + ], + } + + const ast = babylon.parse(code, babylonOpts) + + // extract names of exports from file + traverse(ast, { + AssignmentExpression: function AssignmentExpression(astPath) { + if ( + astPath.node.left.type === `MemberExpression` && + astPath.node.left.object.name === `exports` + ) { + exportNames.push(astPath.node.left.property.name) + } + }, + }) + + return exportNames +}