Skip to content

Commit

Permalink
Add ability to locally define plugins (gatsbyjs#1126)
Browse files Browse the repository at this point in the history
* Add ability to locally define plugins

* Restrict local plugin search to ./plugins folder

* Add content hash for local plugins

* Catch some errors earlier

* Add missing timer start

* Make hash function sync because it simplifies things

* Plugin version should be string always

* Remove debug log

* Format code

* Update load-plugins snapshot

* Update snapshot and fix test

* Add some documentation about local plugins

* Fix some spelling
  • Loading branch information
0x80 authored and KyleAMathews committed Jun 13, 2017
1 parent 6c09730 commit abc93d6
Show file tree
Hide file tree
Showing 5 changed files with 139 additions and 31 deletions.
32 changes: 32 additions & 0 deletions docs/docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@ module.exports = {
Plugins can take options. See each plugin page below for more detailed documentation
on using each plugin.

## Locally defined plugins

When you want to work on a new plugin, or maybe write one that is only relevant
to your specific use-case, a locally defined plugin is more convenient than
having to create an NPM package for it.

You can place the code in the `plugins` folder in the root of your project like
this:

```
plugins
└── my-own-plugin
├── gatsby-node.js
└── package.json
```

Each plugin requires a package.json file, but the minimum content is just an
empty object `{}`. The `name` and `version` fields are read from the package file.
The name is used to identify the plugin when it mutates the GraphQL data structure.
The version is used to clear the cache when it changes.

For local plugins it is best to leave the version field empty. Gatsby will
generate an md5-hash from all gatsby-* file contents and use that as the version.
This way the cache is automatically flushed when you change the code of your
plugin.

If the name is empty it is inferred from the plugin folder name.

Like all gatsby-* files, the code is not being processed by Babel. If you
want to use javascript syntax which isn't supported by your version of Node.js,
you can place the files in a `src` subfolder and build them to the plugin folder root.

## Official plugins

* [gatsby-plugin-catch-links](/docs/packages/gatsby-plugin-catch-links/)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,14 @@ Array [
"plugins": Array [],
},
"resolve": "",
"version": undefined,
},
Object {
"name": "default-site-plugin",
"pluginOptions": Object {
"plugins": Array [],
},
"resolve": "",
"version": "n/a",
"version": "d41d8cd98f00b204e9800998ecf8427e",
},
]
`;
Expand Down Expand Up @@ -93,7 +92,7 @@ Array [
"plugins": Array [],
},
"resolve": "",
"version": "n/a",
"version": "d41d8cd98f00b204e9800998ecf8427e",
},
]
`;
108 changes: 89 additions & 19 deletions packages/gatsby/src/bootstrap/load-plugins.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,82 @@ const _ = require(`lodash`)
const slash = require(`slash`)
const fs = require(`fs`)
const path = require(`path`)

const crypto = require(`crypto`)
const { store } = require(`../redux`)
const nodeAPIs = require(`../utils/api-node-docs`)
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,
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,
name: packageJSON.name,
version: packageJSON.version,
}
} catch (err) {
throw new Error(`Unable to find plugin "${pluginName}"`)
}
}

module.exports = async (config = {}) => {
// Instantiate plugins.
Expand All @@ -15,14 +88,10 @@ module.exports = async (config = {}) => {
// Also test adding to redux store.
const processPlugin = plugin => {
if (_.isString(plugin)) {
const resolvedPath = slash(path.dirname(require.resolve(plugin)))
const packageJSON = JSON.parse(
fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`)
)
const info = resolvePlugin(plugin)

return {
resolve: resolvedPath,
name: packageJSON.name,
version: packageJSON.version,
...info,
pluginOptions: {
plugins: [],
},
Expand All @@ -40,18 +109,19 @@ module.exports = async (config = {}) => {

// Add some default values for tests as we don't actually
// want to try to load anything during tests.
let resolvedPath
let packageJSON = { name: `TEST` }
if (plugin.resolve !== `___TEST___`) {
resolvedPath = slash(path.dirname(require.resolve(plugin.resolve)))
packageJSON = JSON.parse(
fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`)
)
if (plugin.resolve === `___TEST___`) {
return {
name: `TEST`,
pluginOptions: {
plugins: [],
},
}
}

const info = resolvePlugin(plugin.resolve)

return {
resolve: resolvedPath,
name: packageJSON.name,
version: packageJSON.version,
...info,
pluginOptions: _.merge({ plugins: [] }, plugin.options),
}
}
Expand Down Expand Up @@ -86,7 +156,7 @@ module.exports = async (config = {}) => {
plugins.push({
resolve: slash(process.cwd()),
name: `default-site-plugin`,
version: `n/a`,
version: createFileContentHash(process.cwd(), `gatsby-*`),
pluginOptions: {
plugins: [],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { store } = require(`../../redux/`)
const { boundActionCreators } = require(`../../redux/actions`)
const queryCompiler = require(`./query-compiler`).default
const queryRunner = require(`./query-runner`)
const invariant = require(`invariant`)

exports.extractQueries = () => {
const pages = store.getState().pages
Expand Down Expand Up @@ -57,6 +58,11 @@ exports.watch = rootDir => {
queryCompiler().then(queries => {
const pages = store.getState().pageComponents
queries.forEach(({ text }, path) => {
invariant(
pages[path],
`Path ${path} not found in the store pages: ${JSON.stringify(pages)}`
)

if (text !== pages[path].query) {
boundActionCreators.replacePageComponentQuery({
query: text,
Expand Down
19 changes: 10 additions & 9 deletions packages/gatsby/src/schema/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,24 @@ const { GraphQLSchema, GraphQLObjectType } = require(`graphql`)
const buildNodeTypes = require(`./build-node-types`)
const buildNodeConnections = require(`./build-node-connections`)
const { store } = require(`../redux`)
const invariant = require(`invariant`)

module.exports = async () => {
console.time(`building schema`)

const typesGQL = await buildNodeTypes()
const connections = buildNodeConnections(_.values(typesGQL))

// Pull off just the graphql node from each type object.
const nodes = _.mapValues(typesGQL, `node`)

invariant(!_.isEmpty(nodes), `There are no available GQL nodes`)
invariant(!_.isEmpty(connections), `There are no available GQL connections`)

const schema = new GraphQLSchema({
query: new GraphQLObjectType({
name: `RootQueryType`,
fields: () => {
return {
// Pull off just the graphql node from each type object.
..._.mapValues(typesGQL, `node`),
...connections,
}
},
fields: { ...nodes, ...connections },
}),
})

Expand All @@ -28,6 +31,4 @@ module.exports = async () => {
type: `SET_SCHEMA`,
payload: schema,
})

return
}

0 comments on commit abc93d6

Please sign in to comment.