diff --git a/.gitignore b/.gitignore index 0909c61..cb13fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /*.js !index.js node_modules/ -package-lock.json \ No newline at end of file +package-lock.json +.vscode +.history \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b43b020 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v14.4.0 \ No newline at end of file diff --git a/README.md b/README.md index fc554c3..2314639 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ -# :floppy_disk: gatsby-plugin-remote-images +# 💾 gatsby-plugin-remote-images Download images from any string field on another node so that those images can be queried with `gatsby-image`. -### Usage +- [Usage](#usage) + - [Install](#install) + - [Options](#options) - + [Example Config with Optional Options](#example-config-with-optional-options) +- [Why?](#why) +- [Common Issues](#common-issues) + - [gatsby-source-graphql](#gatsby-source-graphql) + - [Traversing objects with arrays](#traversing-objects-with-arrays) + - [Handling an Array of Image URLs](#handling-an-array-of-image-urls) -#### Install +## Usage + +### Install First, install the plugin. `npm install --save gatsby-plugin-remote-images` -#### Config - Second, set up the `gatsby-config.js` with the plugin. The most common config would be this: @@ -22,7 +30,7 @@ module.exports = { { resolve: `gatsby-plugin-remote-images`, options: { - nodeType: 'myNodes', + nodeType: 'MyNodes', imagePath: 'path.to.image', }, }, @@ -30,6 +38,20 @@ module.exports = { }; ``` +### Options + +| Option Name | Description | Required | Default | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------ | +| nodeType | The node type that has the images you want to grab. This is generally the camelcased version of the word after the 'all' in GraphQL ie. allMyImages type is myImages | ✅ | `null` | +| imagePath | For simple object traversal, this is the string path to the image you want to use, relative to the node. This uses lodash .get, see [docs for accepted formats here](https://lodash.com/docs/4.17.11#get). For traversing objects with arrays at given depths, see [how to handle arrays along the path below](#traversing-objects-with-arrays). | ✅ | `null` | +| name | Name you want to give new image field on the node. Defaults to `localImage`. | ❌ | `localImage` | +| auth | Adds htaccess authentication to the download request if passed in. | ❌ | `{}` | +| ext | Sets the file extension. Useful for APIs that separate the image file path from its extension. Or for changing the extension. Defaults to existing file extension. | ❌ | `null` | +| prepareUrl | Allows modification of the URL per image if needed. Expects a function taking the original URL as a parameter and returning the desired URL. | ❌ | `null` | +| type | Tell the plugin that the leaf node is an _array_ of images instead of one single string. Only option here is `array`. For example usage, [see here](#handling-an-array-of-image-urls). | ❌ | `object` | + +#### Example Config with Optional Options + However, you may need more optional config, which is documented here. ```javascript @@ -38,27 +60,12 @@ module.exports = { { resolve: `gatsby-plugin-remote-images`, options: { - // The node type that has the images you want to grab. - // This is generally the camelcased version of the word - // after the 'all' in GraphQL ie. allMyImages type is myImages - nodeType: 'myNodes', - // For simple object traversal, this is the string path to the image you - // want to use, relative to the node. - // This uses lodash .get, see [docs for accepted formats here](https://lodash.com/docs/4.17.11#get). - // For traversing objects with arrays at given depths, see [how to handle arrays below](#traversing-objects-with-arrays) + nodeType: 'MyNodes', imagePath: 'path.to.image', // ** ALL OPTIONAL BELOW HERE: ** - // Name you want to give new image field on the node. - // Defaults to 'localImage'. name: 'theNewImageField', - // Adds htaccess authentication to the download request if passed in. auth: { htaccess_user: `USER`, htaccess_pass: `PASSWORD` }, - // Sets the file extension. Useful for APIs that separate the image file path - // from its extension. Or for changing the extention. Defaults to existing - // file extension. ext: '.jpg', - // Allows modification of the URL per image if needed. Expects a function - // taking the original URL as a parameter and returning the desired URL. prepareUrl: url => (url.startsWith('//') ? `https:${url}` : url), }, }, @@ -66,11 +73,12 @@ module.exports = { }; ``` -### Why? +## Why? Why do you need this plugin? The fantastic gatsby-image tool only works on -_relative_ paths. This lets you use it on images from an API with an _absolute_ -path. For example, look at these two response from one GraphQL query: +_relative_ paths to locally stored images. This lets you use it on images from +an API with an _absolute_ path. For example, look at these two response from one +GraphQL query: _Query_ @@ -124,7 +132,7 @@ module.exports = { { resolve: `gatsby-plugin-remote-images`, options: { - nodeType: 'myNodes', + nodeType: 'MyNodes', imagePath: 'imageUrl', // OPTIONAL: Name you want to give new image field on the node. // Defaults to 'localImage'. @@ -153,7 +161,13 @@ allMyNodes { } ``` -#### Note on `gatsby-source-graphql` +**Note:** Many Gatsby source plugins already do this work for you under the +hood. So if you are working with a common CMS's Gatsby plugin, odds are that +_you don't need this!_ + +## Common Issues + +### `gatsby-source-graphql` Due to the way `gatsby-source-graphql` creates nodes, it is currently impossible for any transformer type plugin to traverse the data from that plugin. @@ -168,8 +182,6 @@ target data lives, `gatsby-plugin-remote-images` also supports traversing objects that have arrays at arbitrary depths. To opt in to this feature, add an array literal, `[]`, to the end of the node you want to indicate is an array. -##### Note: arrays of image urls at leaf nodes are currently not supported - Given an object structure like this: ```javascript @@ -192,7 +204,7 @@ module.exports = { { resolve: `gatsby-plugin-remote-images`, options: { - nodeType: 'myNodes', + nodeType: 'MyNodes', imagePath: 'nodes[].imageUrl', }, }, @@ -216,4 +228,65 @@ allMyNodes { } ``` -##### Note: While `lodash .get` doesn't natively support this syntax, it is still used to traverse the object structure, so [the documentation for `.get`](https://lodash.com/docs/4.17.11#get) still applies in full. +**Note:** While `lodash .get` doesn't natively support this syntax, it is still +used to traverse the object structure, so +[the documentation for `.get`](https://lodash.com/docs/4.17.11#get) still +applies in full. + +### Handling an Array of Image URLs + +In case your API offers an image path to an _array_ of images, instead of just +one, there is a way to handle that with the plugin. For instances where there is +an array somewhere along the _path to_ the images, +[see above](#traversing-objects-with-arrays). + +For example, you API returns: + +```javascript +// MyNode +{ + date: '1-1-2010', + category: 'cats' + // Note that here there are multiple images at the *leaf* node where the images are found. + images: [ + 'https://.../image1.png', + 'https://.../image2.png' + ] +} +``` + +To make your local image field an array of these images, adjust your config +accordingly: + +```javascript + { + resolve: `gatsby-plugin-remote-images`, + options: { + nodeType: 'MyNodes', + // Making this plural (optional). + name: 'localImages', + // Path to the leaf node. + imagePath: 'images', + // Set type to array. + type: 'array' + } +} +``` + +Now, if we query `allMyNodes` we can query as we would any gatsby-image node, +but now `localImage` (or `localImages` as in the example above) we would get an +array of Gatsby images, instead of just one. + +```graphql +allMyNodes { + nodes { + localImages { + childImageSharp { + fluid(maxWidth: 400, maxHeight: 250) { + ...GatsbyImageSharpFluid + } + } + } + } +} +``` diff --git a/package.json b/package.json index 7382a57..830fa24 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gatsby-plugin-remote-images", - "version": "2.1.0", + "version": "2.2.0", "description": "Gatsby plugin to use gatsby-image on remote images from absolute path string fields on other nodes.", "author": "Grayson Hicks ", "main": "gatsby-node.js", @@ -36,8 +36,8 @@ "prettier": "^1.19.1" }, "dependencies": { - "gatsby-source-filesystem": "^2.0.11", - "lodash": "^4.17.11" + "gatsby-source-filesystem": "^2.3.14", + "lodash": "^4.17.15" }, "scripts": { "build": "babel src --out-dir . --ignore **/__tests__", diff --git a/src/__tests__/gatsby-node.js b/src/__tests__/gatsby-node.js index 03e287d..a9a5e54 100644 --- a/src/__tests__/gatsby-node.js +++ b/src/__tests__/gatsby-node.js @@ -7,8 +7,21 @@ const getGatsbyNodeHelperMocks = () => ({ actions: { createNode: jest.fn() }, createNodeId: jest.fn().mockReturnValue('remoteFileIdHere'), createResolvers: jest.fn(), + reporter: { + activityTimer: jest.fn().mockReturnValue({ + start: jest.fn(), + end: jest.fn(), + }), + }, store: {}, - cache: {}, + cache: { + get: jest.fn().mockReturnValue({ + resolve: { id: 'newFileNode' }, + }), + set: jest.fn().mockReturnValue({ + resolve: { id: 'newFileNode' }, + }), + }, }); const mockContext = { @@ -46,9 +59,13 @@ describe('gatsby-plugin-remote-images', () => { createResolvers: mockCreateResolvers, store, cache, + reporter, } = getGatsbyNodeHelperMocks(); - await onCreateNode({ node, actions, createNodeId, store, cache }, options); + await onCreateNode( + { node, actions, createNodeId, store, cache, reporter }, + options + ); expect(createNodeId).toHaveBeenCalledTimes(1); expect(createRemoteFileNode).toHaveBeenLastCalledWith({ parentNodeId: 'testing', @@ -61,7 +78,7 @@ describe('gatsby-plugin-remote-images', () => { auth: {}, }); - createResolvers({ createResolvers: mockCreateResolvers }, options); + createResolvers({ cache, createResolvers: mockCreateResolvers }, options); expect(mockCreateResolvers).toHaveBeenCalledTimes(1); expect(mockCreateResolvers).toHaveBeenLastCalledWith({ [options.nodeType]: { @@ -77,6 +94,7 @@ describe('gatsby-plugin-remote-images', () => { }); const fileNodeResolver = mockCreateResolvers.mock.calls[0][0][options.nodeType].localImage.resolve; + expect(fileNodeResolver(baseNode, null, mockContext)).resolves.toEqual({ id: 'newFileNode', }); @@ -114,9 +132,18 @@ describe('gatsby-plugin-remote-images', () => { ...baseOptions, ext: '.jpg', }; - const { actions, createNodeId, store, cache } = getGatsbyNodeHelperMocks(); + const { + actions, + createNodeId, + store, + cache, + reporter, + } = getGatsbyNodeHelperMocks(); - await onCreateNode({ node, actions, createNodeId, store, cache }, options); + await onCreateNode( + { node, actions, createNodeId, store, cache, reporter }, + options + ); expect(createNodeId).toHaveBeenCalledTimes(1); expect(createRemoteFileNode).toHaveBeenLastCalledWith({ parentNodeId: 'testing', @@ -149,9 +176,18 @@ describe('gatsby-plugin-remote-images', () => { ...baseOptions, imagePath: 'nodes[].imageUrl', }; - const { actions, createNodeId, store, cache } = getGatsbyNodeHelperMocks(); + const { + actions, + createNodeId, + store, + cache, + reporter, + } = getGatsbyNodeHelperMocks(); - await onCreateNode({ node, actions, createNodeId, store, cache }, options); + await onCreateNode( + { node, actions, createNodeId, store, cache, reporter }, + options + ); expect(createNodeId).toHaveBeenCalledTimes(1); expect(createRemoteFileNode).toHaveBeenLastCalledWith({ parentNodeId: 'nested parent', @@ -164,4 +200,95 @@ describe('gatsby-plugin-remote-images', () => { auth: {}, }); }); + + it('can have arrays at the leaf nodes', async () => { + const node = { + ...baseNode, + imageUrls: [ + 'https://dummyimage.com/600x400/000/fff.png', + 'https://dummyimage.com/600x400/000/fff.png', + ], + internal: { + contentDigest: 'testdigest', + type: 'test', + mediaType: 'application/json', + }, + }; + const options = { + ...baseOptions, + imagePath: 'imageUrls', + type: 'array', + }; + const { + actions, + createNodeId, + store, + cache, + reporter, + } = getGatsbyNodeHelperMocks(); + + await onCreateNode( + { node, actions, createNodeId, store, cache, reporter }, + options + ); + expect(createNodeId).toHaveBeenCalledTimes(2); + expect(createRemoteFileNode).toHaveBeenLastCalledWith({ + parentNodeId: 'testing', + url: node.imageUrls[1], + ext: null, + store, + cache, + createNode: actions.createNode, + createNodeId, + auth: {}, + }); + }); + + it('can have nested arrays in `imagePath` AND an array at the leaf node', async () => { + const node = { + ...baseNode, + nodes: [ + { + id: 'nested parent', + imageUrls: [ + 'https://dummyimage.com/600x400/000/fff.png', + 'https://dummyimage.com/600x400/000/fff.png', + ], + }, + ], + internal: { + contentDigest: 'testdigest', + type: 'test', + mediaType: 'application/json', + }, + }; + const options = { + ...baseOptions, + imagePath: 'nodes[].imageUrls', + type: 'array', + }; + const { + actions, + createNodeId, + store, + cache, + reporter, + } = getGatsbyNodeHelperMocks(); + + await onCreateNode( + { node, actions, createNodeId, store, cache, reporter }, + options + ); + expect(createNodeId).toHaveBeenCalledTimes(2); + expect(createRemoteFileNode).toHaveBeenLastCalledWith({ + parentNodeId: 'nested parent', + url: node.nodes[0].imageUrls[1], + ext: null, + store, + cache, + createNode: actions.createNode, + createNodeId, + auth: {}, + }); + }); }); diff --git a/src/gatsby-node.js b/src/gatsby-node.js index 5206036..ce08b89 100644 --- a/src/gatsby-node.js +++ b/src/gatsby-node.js @@ -2,7 +2,7 @@ const { createRemoteFileNode } = require(`gatsby-source-filesystem`); const get = require('lodash/get'); exports.onCreateNode = async ( - { node, actions, store, cache, createNodeId }, + { node, actions, store, cache, createNodeId, reporter }, options ) => { const { createNode } = actions; @@ -13,6 +13,7 @@ exports.onCreateNode = async ( auth = {}, ext = null, prepareUrl = null, + type = 'object', } = options; const createImageNodeOptions = { store, @@ -31,18 +32,31 @@ exports.onCreateNode = async ( if (imagePath.includes('[].')) { imagePathSegments = imagePath.split('[].'); } + const downloadingFilesActivity = reporter.activityTimer( + `Creating local images for ${nodeType}` + ); + downloadingFilesActivity.start(); if (imagePathSegments.length) { await createImageNodesInArrays(imagePathSegments[0], node, { imagePathSegments, ...createImageNodeOptions, }); + } else if (type === 'array') { + const urls = getPaths(node, imagePath, ext); + await createImageNodes(urls, node, createImageNodeOptions); } else { const url = getPath(node, imagePath, ext); await createImageNode(url, node, createImageNodeOptions); } + downloadingFilesActivity.end(); } }; +function getPaths(node, path, ext = null) { + const value = get(node, path); + return value.map(url => (ext ? url + ext : url)); +} + // Returns value from path, adding extension when supplied function getPath(node, path, ext = null) { const value = get(node, path); @@ -55,6 +69,54 @@ function getCacheKeyForNodeId(nodeId) { return `gatsby-plugin-remote-images-${nodeId}`; } +async function createImageNodes(urls, node, options) { + const { name, imagePathSegments, prepareUrl, ...restOfOptions } = options; + let fileNode; + + if (!urls) { + return; + } + + const fileNodes = ( + await Promise.all( + urls.map(async (url, index) => { + if (typeof prepareUrl === 'function') { + url = prepareUrl(url); + } + + try { + fileNode = await createRemoteFileNode({ + ...restOfOptions, + url, + parentNodeId: node.id, + }); + } catch (e) { + console.error('gatsby-plugin-remote-images ERROR:', e); + } + return fileNode; + }) + ) + ).filter(fileNode => !!fileNode); + + // Store the mapping between the current node and the newly created File node + if (fileNodes.length) { + // This associates the existing node (of user-specified type) with the new + // File nodes created via createRemoteFileNode. The new File nodes will be + // resolved dynamically through the Gatsby schema customization + // createResolvers API and which File node gets resolved for each new field + // on a given node of the user-specified type is determined by the contents + // of this mapping. The keys are based on the ID of the parent node (of + // user-specified type) and the values are each a nested mapping of the new + // image File field name to the ID of the new File node. + const cacheKey = getCacheKeyForNodeId(node.id); + const existingFileNodeMap = await options.cache.get(cacheKey); + await options.cache.set(cacheKey, { + ...existingFileNodeMap, + [name]: fileNodes.map(({ id }) => id), + }); + } +} + // Creates a file node and associates the parent node to its new child async function createImageNode(url, node, options) { const { name, imagePathSegments, prepareUrl, ...restOfOptions } = options; @@ -117,6 +179,8 @@ async function createImageNodesInArrays(path, node, options) { .join('.'); nextNode = get(node, pathToLastParent); } + // @TODO: Need logic to handle if the leaf node is an array to then shift + // to the function of createImageNodes. return Array.isArray(nextValue) ? // Recursively call function with next path segment for each array element Promise.all( @@ -133,19 +197,42 @@ async function createImageNodesInArrays(path, node, options) { } exports.createResolvers = ({ cache, createResolvers }, options) => { - const { nodeType, name = 'localImage' } = options; - - const resolvers = { - [nodeType]: { - [name]: { - type: 'File', - resolve: async (source, _, context) => { - const fileNodeMap = await cache.get(getCacheKeyForNodeId(source.id)); - return context.nodeModel.getNodeById({ id: fileNodeMap[name] }); + const { nodeType, name = 'localImage', type = 'object' } = options; + + if (type === 'array') { + const resolvers = { + [nodeType]: { + [name]: { + type: '[File]', + resolve: async (source, _, context) => { + const fileNodeMap = await cache.get( + getCacheKeyForNodeId(source.id) + ); + if (!fileNodeMap || !fileNodeMap[name]) { + return []; + } + return fileNodeMap[name].map(id => + context.nodeModel.getNodeById({ id }) + ); + }, }, }, - }, - }; - - createResolvers(resolvers); + }; + createResolvers(resolvers); + } else { + const resolvers = { + [nodeType]: { + [name]: { + type: 'File', + resolve: async (source, _, context) => { + const fileNodeMap = await cache.get( + getCacheKeyForNodeId(source.id) + ); + return context.nodeModel.getNodeById({ id: fileNodeMap[name] }); + }, + }, + }, + }; + createResolvers(resolvers); + } };