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

Use babylon to parse out exports from plugin files #4057

Merged
merged 1 commit into from
Feb 15, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}