Skip to content

Commit

Permalink
Use babylon to parse out exports from plugin files (#4057)
Browse files Browse the repository at this point in the history
  • Loading branch information
m-allanson authored and KyleAMathews committed Feb 15, 2018
1 parent 94fe5d3 commit a87290d
Show file tree
Hide file tree
Showing 3 changed files with 216 additions and 34 deletions.
143 changes: 143 additions & 0 deletions packages/gatsby/src/bootstrap/__tests__/resolve-module-exports.js
Original file line number Diff line number Diff line change
@@ -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 (
<Transition {...transitionProps}>
{
(status) => createElement(this.props.pageResources.component, {
...this.props,
...this.props.pageResources.json,
transition: {
status,
timeout,
style: getTransitionStyle({ status, timeout }),
nextPageResources: this.state.nextPageResources,
},
})
}
</Transition>
)
}
}
// 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`])
})
})
48 changes: 14 additions & 34 deletions packages/gatsby/src/bootstrap/load-plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
})

Expand Down
59 changes: 59 additions & 0 deletions packages/gatsby/src/bootstrap/resolve-module-exports.js
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit a87290d

Please sign in to comment.