├── .babelrc ├── .github ├── dependabot.yml └── workflows │ └── node.js.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierignore ├── README.md ├── __mocks__ └── gatsby-source-filesystem.js ├── gatsby-node.js ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json └── src ├── __tests__ └── gatsby-node.js └── gatsby-node.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["babel-preset-gatsby-package"] 4 | ] 5 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x, 14.x, 16.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v2 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | !index.js 3 | !gatsby-node.js 4 | !jest.config.js 5 | node_modules/ 6 | .vscode 7 | .history 8 | .idea 9 | .DS_Store -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | *.un~ 29 | yarn.lock 30 | src 31 | flow-typed 32 | coverage 33 | decls 34 | examples 35 | .prettierignore 36 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💾 gatsby-plugin-remote-images 2 | 3 | Download images from any string field on another node so that those images can 4 | be queried with `gatsby-plugin-image`. 5 | 6 | - [Usage](#usage) 7 | - [Install](#install) 8 | - [Options](#options) - 9 | [Example Config with Optional Options](#example-config-with-optional-options) 10 | - [Why?](#why) 11 | - [Common Issues](#common-issues) 12 | 13 | - [gatsby-source-graphql](#gatsby-source-graphql) 14 | - [Traversing objects with arrays](#traversing-objects-with-arrays) 15 | - [Handling an Array of Image URLs](#handling-an-array-of-image-urls) 16 | 17 | **Note:** This plugin support `gatsby-plugin-image` and drops support for 18 | `gatsby-image` in `3.0.0`. 19 | 20 | ## Usage 21 | 22 | ### Install 23 | 24 | First, install the plugin. 25 | 26 | `npm install --save gatsby-plugin-remote-images` 27 | 28 | Second, set up the `gatsby-config.js` with the plugin. The most common config 29 | would be this: 30 | 31 | ```javascript 32 | module.exports = { 33 | plugins: [ 34 | { 35 | resolve: `gatsby-plugin-remote-images`, 36 | options: { 37 | nodeType: 'MyNodes', 38 | imagePath: 'path.to.image', 39 | }, 40 | }, 41 | ], 42 | }; 43 | ``` 44 | 45 | ### Options 46 | 47 | | Option Name | Description | Required | Default | 48 | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------ | 49 | | nodeType | The node type that has the images you want to grab. This is generally the title-cased version of the word after the 'all' in GraphQL ie. allMyImages type is MyImages | ✅ | `null` | 50 | | 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` | 51 | | name | Name you want to give new image field on the node. Defaults to `localImage`. | ❌ | `localImage` | 52 | | auth | Adds htaccess authentication to the download request if passed in. | ❌ | `{}` | 53 | | 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` | 54 | | 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` | 55 | | 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` | 56 | | silent | Set to `true` to silence image load errors in the console. | ❌ | `boolean` | 57 | | skipUndefinedUrls | This skips undefined `urls` and adds an easy way for the user to implement their own "undefined" values by returning undefined from the `prepareUrl()` function. See [here](https://github.com/graysonhicks/gatsby-plugin-remote-images/pull/134#issue-1568549719). | ❌ | `boolean` | 58 | 59 | #### Example Config with Optional Options 60 | 61 | However, you may need more optional config, which is documented here. 62 | 63 | ```javascript 64 | module.exports = { 65 | plugins: [ 66 | { 67 | resolve: `gatsby-plugin-remote-images`, 68 | options: { 69 | nodeType: 'MyNodes', 70 | imagePath: 'path.to.image', 71 | // ** ALL OPTIONAL BELOW HERE: ** 72 | name: 'theNewImageField', 73 | auth: { htaccess_user: `USER`, htaccess_pass: `PASSWORD` }, 74 | ext: '.jpg', 75 | prepareUrl: url => (url.startsWith('//') ? `https:${url}` : url), 76 | }, 77 | }, 78 | ], 79 | }; 80 | ``` 81 | 82 | ## Why? 83 | 84 | Why do you need this plugin? The fantastic `gatsby-plugin-image` tool only works 85 | on _relative_ paths to locally stored images. This lets you use it on images 86 | from an API with an _absolute_ path. For example, look at these two response 87 | from one GraphQL query: 88 | 89 | _Query_ 90 | 91 | ```graphql 92 | allMyNodes { 93 | edges { 94 | node { 95 | id 96 | imageUrl 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | _Absolute imageUrl NOT available to `gatsby-plugin-image`_ 103 | 104 | ```javascript 105 | allMyNodes: [ 106 | { 107 | node: { 108 | id: 123, 109 | imageUrl: 'http://remoteimage.com/url.jpg', 110 | }, 111 | }, 112 | ]; 113 | ``` 114 | 115 | _Relative imageUrl IS available to `gatsby-plugin-image`_ 116 | 117 | ```javascript 118 | allMyNodes: [ 119 | { 120 | node: { 121 | id: 123, 122 | imageUrl: 'localImages/url.jpg', 123 | }, 124 | }, 125 | ]; 126 | ``` 127 | 128 | If you don't control the API that you are hitting (many third party APIs return 129 | a field with a string to an absolute path for an image), this means those image 130 | aren't run through `gatsby-plugin-image` and you lose all of the benefits. 131 | 132 | To get the images and make them available for the above example, follow the 133 | install instructions and your config should look like this: 134 | 135 | ```javascript 136 | module.exports = { 137 | plugins: [ 138 | { 139 | resolve: `gatsby-plugin-remote-images`, 140 | options: { 141 | nodeType: 'MyNodes', 142 | imagePath: 'imageUrl', 143 | // OPTIONAL: Name you want to give new image field on the node. 144 | // Defaults to 'localImage'. 145 | name: 'allItemImages', 146 | }, 147 | }, 148 | ], 149 | }; 150 | ``` 151 | 152 | Now, if we query `allMyNodes` we can query as we would any `gatsby-plugin-image` 153 | node: 154 | 155 | ```graphql 156 | allMyNodes { 157 | edges { 158 | node { 159 | localImage { 160 | childImageSharp { 161 | gatsbyImageData(width: 400) 162 | } 163 | } 164 | } 165 | } 166 | } 167 | ``` 168 | 169 | **Note:** Many Gatsby source plugins already do this work for you under the 170 | hood. So if you are working with a common CMS's Gatsby plugin, odds are that 171 | _you don't need this!_ 172 | 173 | ## Common Issues 174 | 175 | ### `gatsby-source-graphql` 176 | 177 | Due to the way `gatsby-source-graphql` creates nodes, it is currently impossible 178 | for any transformer type plugin to traverse the data from that plugin. 179 | [Please read this issue for explanation](https://github.com/gatsbyjs/gatsby/issues/8404). 180 | As soon as that as fixed in `gatsby-source-graphql`, this plugin will be tested 181 | to make sure it works with it as well. 182 | 183 | ### Traversing objects with arrays 184 | 185 | Since some GraphQL APIs will send back objects with nested arrays where your 186 | target data lives, `gatsby-plugin-remote-images` also supports traversing 187 | objects that have arrays at arbitrary depths. To opt in to this feature, add an 188 | array literal, `[]`, to the end of the node you want to indicate is an array. 189 | 190 | Given an object structure like this: 191 | 192 | ```javascript 193 | allMyNodes { 194 | nodes: [ 195 | { 196 | imageUrl: 'https://...' 197 | }, 198 | ... 199 | ] 200 | } 201 | ``` 202 | 203 | To get the images and make them available for the above example, your config 204 | should look like this: 205 | 206 | ```javascript 207 | module.exports = { 208 | plugins: [ 209 | { 210 | resolve: `gatsby-plugin-remote-images`, 211 | options: { 212 | nodeType: 'MyNodes', 213 | imagePath: 'nodes[].imageUrl', 214 | }, 215 | }, 216 | ], 217 | }; 218 | ``` 219 | 220 | Now, if we query `allMyNodes` we can query as we would any `gatsby-plugin-image` 221 | node: 222 | 223 | ```graphql 224 | allMyNodes { 225 | nodes { 226 | localImage { 227 | childImageSharp { 228 | gatsbyImageData(width: 400) 229 | } 230 | } 231 | } 232 | } 233 | ``` 234 | 235 | **Note:** While `lodash .get` doesn't natively support this syntax, it is still 236 | used to traverse the object structure, so 237 | [the documentation for `.get`](https://lodash.com/docs/4.17.11#get) still 238 | applies in full. 239 | 240 | ### Handling an Array of Image URLs 241 | 242 | In case your API offers an image path to an _array_ of images, instead of just 243 | one, there is a way to handle that with the plugin. For instances where there is 244 | an array somewhere along the _path to_ the images, 245 | [see above](#traversing-objects-with-arrays). 246 | 247 | For example, you API returns: 248 | 249 | ```javascript 250 | // MyNode 251 | { 252 | date: '1-1-2010', 253 | category: 'cats' 254 | // Note that here there are multiple images at the *leaf* node where the images are found. 255 | images: [ 256 | 'https://.../image1.png', 257 | 'https://.../image2.png' 258 | ] 259 | } 260 | ``` 261 | 262 | To make your local image field an array of these images, adjust your config 263 | accordingly: 264 | 265 | ```javascript 266 | { 267 | resolve: `gatsby-plugin-remote-images`, 268 | options: { 269 | nodeType: 'MyNodes', 270 | // Making this plural (optional). 271 | name: 'localImages', 272 | // Path to the leaf node. 273 | imagePath: 'images', 274 | // Set type to array. 275 | type: 'array' 276 | } 277 | } 278 | ``` 279 | 280 | Now, if we query `allMyNodes` we can query as we would any `gatsby-plugin-image` 281 | node, but now `localImage` (or `localImages` as in the example above) we would 282 | get an array of Gatsby images, instead of just one. 283 | 284 | ```graphql 285 | allMyNodes { 286 | nodes { 287 | localImages { 288 | childImageSharp { 289 | gatsbyImageData(width: 400) 290 | } 291 | } 292 | } 293 | } 294 | ``` 295 | -------------------------------------------------------------------------------- /__mocks__/gatsby-source-filesystem.js: -------------------------------------------------------------------------------- 1 | const gatsbyFs = jest.genMockFromModule('gatsby-source-filesystem'); 2 | 3 | /** 4 | * Thinly mocks `createRemoteFileNode` to test internal touchpoints, 5 | * which expect the returned value to have a generated `id` prop via `createNodeId` 6 | */ 7 | gatsbyFs.createRemoteFileNode = jest.fn(({ createNodeId }) => ({ 8 | id: createNodeId(), 9 | })); 10 | 11 | module.exports = gatsbyFs; 12 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { 4 | createRemoteFileNode 5 | } = require(`gatsby-source-filesystem`); 6 | const { 7 | addRemoteFilePolyfillInterface 8 | } = require('gatsby-plugin-utils/polyfill-remote-file'); 9 | const get = require('lodash/get'); 10 | const probe = require('probe-image-size'); 11 | let i = 0; 12 | exports.pluginOptionsSchema = ({ 13 | Joi 14 | }) => { 15 | return Joi.object({ 16 | nodeType: Joi.string().required(), 17 | imagePath: Joi.string().required(), 18 | name: Joi.string(), 19 | auth: Joi.object(), 20 | ext: Joi.string(), 21 | prepareUrl: Joi.function(), 22 | type: Joi.string(), 23 | silent: Joi.boolean(), 24 | skipUndefinedUrls: Joi.boolean() 25 | }); 26 | }; 27 | const isImageCdnEnabled = () => { 28 | return process.env.GATSBY_CLOUD_IMAGE_CDN === '1' || process.env.GATSBY_CLOUD_IMAGE_CDN === 'true'; 29 | }; 30 | exports.createSchemaCustomization = ({ 31 | actions, 32 | schema 33 | }) => { 34 | if (isImageCdnEnabled()) { 35 | const RemoteImageFileType = addRemoteFilePolyfillInterface(schema.buildObjectType({ 36 | name: 'RemoteImageFile', 37 | fields: { 38 | id: 'ID!' 39 | }, 40 | interfaces: ['Node', 'RemoteFile'], 41 | extensions: { 42 | infer: true 43 | } 44 | }), { 45 | schema, 46 | actions 47 | }); 48 | actions.createTypes([RemoteImageFileType]); 49 | } 50 | }; 51 | exports.onCreateNode = async ({ 52 | node, 53 | actions, 54 | store, 55 | cache, 56 | createNodeId, 57 | createContentDigest, 58 | reporter 59 | }, options) => { 60 | const { 61 | createNode 62 | } = actions; 63 | const { 64 | nodeType, 65 | imagePath, 66 | name = 'localImage', 67 | auth = {}, 68 | ext = null, 69 | prepareUrl = null, 70 | type = 'object', 71 | silent = false 72 | } = options; 73 | const createImageNodeOptions = { 74 | store, 75 | cache, 76 | createNode, 77 | createNodeId, 78 | createContentDigest, 79 | auth, 80 | ext, 81 | name, 82 | prepareUrl 83 | }; 84 | if (node.internal.type === nodeType) { 85 | // Check if any part of the path indicates the node is an array and splits at those indicators 86 | let imagePathSegments = []; 87 | if (imagePath.includes('[].')) { 88 | imagePathSegments = imagePath.split('[].'); 89 | } 90 | if (imagePathSegments.length) { 91 | const urls = await getAllFilesUrls(imagePathSegments[0], node, { 92 | imagePathSegments, 93 | ...createImageNodeOptions 94 | }); 95 | await createImageNodes(urls, node, createImageNodeOptions, reporter, silent); 96 | } else if (type === 'array') { 97 | const urls = getPaths(node, imagePath, ext); 98 | await createImageNodes(urls, node, createImageNodeOptions, reporter, silent); 99 | } else { 100 | const url = getPath(node, imagePath, ext); 101 | await createImageNode(url, node, createImageNodeOptions, reporter); 102 | } 103 | } 104 | }; 105 | function getPaths(node, path, ext = null) { 106 | const value = get(node, path); 107 | if (value) { 108 | return value.map(url => ext ? url + ext : url); 109 | } 110 | } 111 | 112 | // Returns value from path, adding extension when supplied 113 | function getPath(node, path, ext = null) { 114 | const value = get(node, path); 115 | return ext ? value + ext : value; 116 | } 117 | 118 | // Returns a unique cache key for a given node ID 119 | function getCacheKeyForNodeId(nodeId) { 120 | return `gatsby-plugin-remote-images-${nodeId}`; 121 | } 122 | async function createImageNodes(urls, node, options, reporter, silent) { 123 | const { 124 | name, 125 | imagePathSegments, 126 | prepareUrl, 127 | ...restOfOptions 128 | } = options; 129 | let fileNode; 130 | if (!urls) { 131 | return; 132 | } 133 | const fileNodes = (await Promise.all(urls.map(async (url, index) => { 134 | if (typeof prepareUrl === 'function') { 135 | url = prepareUrl(url); 136 | } 137 | if (options.skipUndefinedUrls && !url) return; 138 | try { 139 | fileNode = await createRemoteFileNode({ 140 | ...restOfOptions, 141 | url, 142 | parentNodeId: node.id 143 | }); 144 | reporter.verbose(`Created image from ${url}`); 145 | } catch (e) { 146 | if (!silent) { 147 | reporter.error(`gatsby-plugin-remote-images ERROR:`, new Error(e)); 148 | } 149 | } 150 | return fileNode; 151 | }))).filter(fileNode => !!fileNode); 152 | 153 | // Store the mapping between the current node and the newly created File node 154 | if (fileNodes.length) { 155 | // This associates the existing node (of user-specified type) with the new 156 | // File nodes created via createRemoteFileNode. The new File nodes will be 157 | // resolved dynamically through the Gatsby schema customization 158 | // createResolvers API and which File node gets resolved for each new field 159 | // on a given node of the user-specified type is determined by the contents 160 | // of this mapping. The keys are based on the ID of the parent node (of 161 | // user-specified type) and the values are each a nested mapping of the new 162 | // image File field name to the ID of the new File node. 163 | const cacheKey = getCacheKeyForNodeId(node.id); 164 | const existingFileNodeMap = await options.cache.get(cacheKey); 165 | await options.cache.set(cacheKey, { 166 | ...existingFileNodeMap, 167 | [name]: fileNodes.map(({ 168 | id 169 | }) => id) 170 | }); 171 | } 172 | } 173 | 174 | // Creates a file node and associates the parent node to its new child 175 | async function createImageNode(url, node, options, reporter, silent) { 176 | const { 177 | name, 178 | imagePathSegments, 179 | prepareUrl, 180 | ...restOfOptions 181 | } = options; 182 | let fileNodeId; 183 | let fileNode; 184 | if (typeof prepareUrl === 'function') { 185 | url = prepareUrl(url); 186 | } 187 | if (options.skipUndefinedUrls && !url) return; 188 | try { 189 | if (isImageCdnEnabled()) { 190 | fileNodeId = options.createNodeId(`RemoteImageFile >>> ${node.id}`); 191 | const metadata = await probe(url); 192 | await options.createNode({ 193 | id: fileNodeId, 194 | parent: node.id, 195 | url: url, 196 | filename: `${node.id}.${metadata.type}`, 197 | height: metadata.height, 198 | width: metadata.width, 199 | mimeType: metadata.mime, 200 | internal: { 201 | type: 'RemoteImageFile', 202 | contentDigest: node.internal.contentDigest 203 | } 204 | }); 205 | if (!silent) { 206 | reporter.verbose(`Created RemoteImageFile node from ${url}`); 207 | } 208 | } else { 209 | fileNode = await createRemoteFileNode({ 210 | ...restOfOptions, 211 | url, 212 | parentNodeId: node.id 213 | }); 214 | fileNodeId = fileNode.id; 215 | if (!silent) { 216 | reporter.verbose(`Created image from ${url}`); 217 | } 218 | } 219 | } catch (e) { 220 | if (!silent) { 221 | reporter.error(`gatsby-plugin-remote-images ERROR:`, new Error(e)); 222 | } 223 | ++i; 224 | fileNode = await options.createNode({ 225 | id: options.createNodeId(`${i}`), 226 | parent: node.id, 227 | internal: { 228 | type: 'File', 229 | mediaType: 'application/octet-stream', 230 | contentDigest: options.createContentDigest(`${i}`) 231 | } 232 | }, { 233 | name: 'gatsby-source-filesystem' 234 | }); 235 | } 236 | 237 | // Store the mapping between the current node and the newly created File node 238 | if (fileNode || isImageCdnEnabled()) { 239 | // This associates the existing node (of user-specified type) with the new 240 | // File nodes created via createRemoteFileNode. The new File nodes will be 241 | // resolved dynamically through the Gatsby schema customization 242 | // createResolvers API and which File node gets resolved for each new field 243 | // on a given node of the user-specified type is determined by the contents 244 | // of this mapping. The keys are based on the ID of the parent node (of 245 | // user-specified type) and the values are each a nested mapping of the new 246 | // image File field name to the ID of the new File node. 247 | const cacheKey = getCacheKeyForNodeId(node.id); 248 | const existingFileNodeMap = await options.cache.get(cacheKey); 249 | await options.cache.set(cacheKey, { 250 | ...existingFileNodeMap, 251 | [name]: fileNode ? fileNode.id : fileNodeId 252 | }); 253 | } 254 | } 255 | 256 | // Recursively traverses objects/arrays at each path part, and return an array of urls 257 | async function getAllFilesUrls(path, node, options) { 258 | if (!path || !node) { 259 | return; 260 | } 261 | const { 262 | imagePathSegments, 263 | ext 264 | } = options; 265 | const pathIndex = imagePathSegments.indexOf(path), 266 | isPathToLeafProperty = pathIndex === imagePathSegments.length - 1, 267 | nextValue = getPath(node, path, isPathToLeafProperty ? ext : null); 268 | 269 | // @TODO: Need logic to handle if the leaf node is an array to then shift 270 | // to the function of createImageNodes. 271 | return Array.isArray(nextValue) && !isPathToLeafProperty ? 272 | // Recursively call function with next path segment for each array element 273 | (await Promise.all(nextValue.map(item => getAllFilesUrls(imagePathSegments[pathIndex + 1], item, options)))).reduce((arr, row) => arr.concat(row), []) : 274 | // otherwise, handle leaf node 275 | nextValue; 276 | } 277 | exports.createResolvers = ({ 278 | cache, 279 | createResolvers 280 | }, options) => { 281 | const { 282 | nodeType, 283 | imagePath, 284 | name = 'localImage', 285 | type = 'object' 286 | } = options; 287 | if (type === 'array' || imagePath.includes('[].')) { 288 | const resolvers = { 289 | [nodeType]: { 290 | [name]: { 291 | type: isImageCdnEnabled() ? '[RemoteImageFile]' : '[File]', 292 | resolve: async (source, _, context) => { 293 | const fileNodeMap = await cache.get(getCacheKeyForNodeId(source.id)); 294 | if (!fileNodeMap || !fileNodeMap[name]) { 295 | return []; 296 | } 297 | return fileNodeMap[name].map(id => context.nodeModel.getNodeById({ 298 | id 299 | })); 300 | } 301 | } 302 | } 303 | }; 304 | createResolvers(resolvers); 305 | } else { 306 | const resolvers = { 307 | [nodeType]: { 308 | [name]: { 309 | type: isImageCdnEnabled() ? 'RemoteImageFile' : 'File', 310 | resolve: async (source, _, context) => { 311 | const fileNodeMap = await cache.get(getCacheKeyForNodeId(source.id)); 312 | if (!fileNodeMap) return null; 313 | return context.nodeModel.getNodeById({ 314 | id: fileNodeMap[name] 315 | }); 316 | } 317 | } 318 | } 319 | }; 320 | createResolvers(resolvers); 321 | } 322 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // noop 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | moduleNameMapper: { 3 | '^gatsby-page-utils/(.*)$': `gatsby-page-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 4 | '^gatsby-core-utils/(.*)$': `gatsby-core-utils/dist/$1`, // Workaround for https://github.com/facebook/jest/issues/9771 5 | '^gatsby-plugin-utils/(.*)$': [ 6 | `gatsby-plugin-utils/dist/$1`, 7 | `gatsby-plugin-utils/$1`, 8 | ], // Workaround for https://github.com/facebook/jest/issues/9771 9 | }, 10 | }; 11 | 12 | module.exports = config; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-plugin-remote-images", 3 | "version": "3.6.6", 4 | "description": "Gatsby plugin to use gatsby-image on remote images from absolute path string fields on other nodes.", 5 | "author": "Grayson Hicks ", 6 | "main": "gatsby-node.js", 7 | "files": [ 8 | "gatsby-node.js", 9 | "README.md" 10 | ], 11 | "repository": "graysonhicks/gatsby-plugin-remote-images", 12 | "keywords": [ 13 | "gatsby", 14 | "gatsby-plugin", 15 | "gatsby-image", 16 | "gatsby-source-filesystem", 17 | "plugin", 18 | "images", 19 | "remote", 20 | "download", 21 | "api" 22 | ], 23 | "license": "MIT", 24 | "peerDependencies": { 25 | "gatsby": "^2.2.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" 26 | }, 27 | "devDependencies": { 28 | "@babel/cli": "^7.0.0", 29 | "@babel/core": "^7.0.0", 30 | "babel-jest": "^29.0.3", 31 | "babel-preset-gatsby-package": "^3.7.0", 32 | "cross-env": "^7.0.3", 33 | "husky": "^8.0.3", 34 | "jest": "^29.0.3", 35 | "lint-staged": "^13.1.2", 36 | "prettier": "^2.8.4", 37 | "rimraf": "^3.0.2" 38 | }, 39 | "dependencies": { 40 | "gatsby-source-filesystem": "^4.0.0", 41 | "lodash": "^4.17.15", 42 | "probe-image-size": "7.2.3" 43 | }, 44 | "scripts": { 45 | "build": "rimraf ./gatsby-node.js && babel src --out-dir . --ignore **/__tests__", 46 | "format": "prettier --write **/*.{js,jsx,ts,tsx,json,css,scss,md}", 47 | "prepare": "cross-env NODE_ENV=production npm run build", 48 | "watch": "babel -w src --out-dir . --ignore **/__tests__", 49 | "test": "jest", 50 | "prepublishOnly": "npm run build" 51 | }, 52 | "prettier": { 53 | "proseWrap": "always", 54 | "singleQuote": true, 55 | "trailingComma": "es5" 56 | }, 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "lint-staged" 60 | } 61 | }, 62 | "lint-staged": { 63 | "**/*.{js,jsx,ts,tsx,json,css,scss,md}": [ 64 | "prettier --write", 65 | "git add" 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/__tests__/gatsby-node.js: -------------------------------------------------------------------------------- 1 | jest.mock('gatsby-source-filesystem'); 2 | 3 | const { createRemoteFileNode } = require('gatsby-source-filesystem'); 4 | const { onCreateNode, createResolvers } = require(`../gatsby-node`); 5 | 6 | const getGatsbyNodeHelperMocks = () => ({ 7 | actions: { createNode: jest.fn() }, 8 | createNodeId: jest.fn().mockReturnValue('remoteFileIdHere'), 9 | createResolvers: jest.fn(), 10 | createContentDigest: jest.fn(), 11 | reporter: { 12 | activityTimer: jest.fn().mockReturnValue({ 13 | start: jest.fn(), 14 | end: jest.fn(), 15 | }), 16 | error: jest.fn(), 17 | verbose: jest.fn(), 18 | }, 19 | store: {}, 20 | cache: { 21 | get: jest.fn().mockReturnValue({ 22 | resolve: { id: 'newFileNode' }, 23 | }), 24 | set: jest.fn().mockReturnValue({ 25 | resolve: { id: 'newFileNode' }, 26 | }), 27 | }, 28 | }); 29 | 30 | const mockContext = { 31 | nodeModel: { 32 | getNodeById: jest.fn(), 33 | }, 34 | }; 35 | 36 | describe('gatsby-plugin-remote-images', () => { 37 | const baseNode = { 38 | id: 'testing', 39 | parent: null, 40 | imageUrl: 'https://dummyimage.com/600x400/000/fff.png', 41 | internal: { 42 | contentDigest: 'testdigest', 43 | type: 'test', 44 | mediaType: 'image/png', 45 | }, 46 | }; 47 | const baseOptions = { 48 | nodeType: 'test', 49 | imagePath: 'imageUrl', 50 | }; 51 | 52 | it('creates remote file node with defaults', async () => { 53 | const node = { 54 | ...baseNode, 55 | }; 56 | const options = { 57 | ...baseOptions, 58 | }; 59 | const { 60 | actions, 61 | createNodeId, 62 | createResolvers: mockCreateResolvers, 63 | store, 64 | cache, 65 | reporter, 66 | } = getGatsbyNodeHelperMocks(); 67 | 68 | await onCreateNode( 69 | { node, actions, createNodeId, store, cache, reporter }, 70 | options 71 | ); 72 | expect(createNodeId).toHaveBeenCalledTimes(1); 73 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 74 | parentNodeId: baseNode.id, 75 | url: node.imageUrl, 76 | ext: null, 77 | store, 78 | cache, 79 | createNode: actions.createNode, 80 | createNodeId, 81 | auth: {}, 82 | }); 83 | 84 | createResolvers({ cache, createResolvers: mockCreateResolvers }, options); 85 | expect(mockCreateResolvers).toHaveBeenCalledTimes(1); 86 | expect(mockCreateResolvers).toHaveBeenLastCalledWith({ 87 | [options.nodeType]: { 88 | localImage: { 89 | type: 'File', 90 | resolve: expect.any(Function), 91 | }, 92 | }, 93 | }); 94 | 95 | mockContext.nodeModel.getNodeById.mockResolvedValueOnce({ 96 | id: 'newFileNode', 97 | }); 98 | const fileNodeResolver = 99 | mockCreateResolvers.mock.calls[0][0][options.nodeType].localImage.resolve; 100 | 101 | expect(fileNodeResolver(baseNode, null, mockContext)).resolves.toEqual({ 102 | id: 'newFileNode', 103 | }); 104 | }); 105 | 106 | it('can use the `name` option', () => { 107 | const options = { 108 | ...baseOptions, 109 | name: 'myNewField', 110 | }; 111 | const { createResolvers: mockCreateResolvers } = getGatsbyNodeHelperMocks(); 112 | 113 | createResolvers({ createResolvers: mockCreateResolvers }, options); 114 | expect(mockCreateResolvers).toHaveBeenLastCalledWith({ 115 | [options.nodeType]: { 116 | [options.name]: { 117 | type: 'File', 118 | resolve: expect.any(Function), 119 | }, 120 | }, 121 | }); 122 | }); 123 | 124 | it('can use the `ext` option', async () => { 125 | const node = { 126 | ...baseNode, 127 | imageUrl: 'https://dummyimage.com/600x400/000/fff', 128 | internal: { 129 | contentDigest: 'testdigest', 130 | type: 'test', 131 | mediaType: 'image/jpg', 132 | }, 133 | }; 134 | const options = { 135 | ...baseOptions, 136 | ext: '.jpg', 137 | }; 138 | const { 139 | actions, 140 | createNodeId, 141 | store, 142 | cache, 143 | reporter, 144 | } = getGatsbyNodeHelperMocks(); 145 | 146 | await onCreateNode( 147 | { node, actions, createNodeId, store, cache, reporter }, 148 | options 149 | ); 150 | expect(createNodeId).toHaveBeenCalledTimes(1); 151 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 152 | parentNodeId: baseNode.id, 153 | url: node.imageUrl + options.ext, 154 | ext: options.ext, 155 | store, 156 | cache, 157 | createNode: actions.createNode, 158 | createNodeId, 159 | auth: {}, 160 | }); 161 | }); 162 | 163 | it('can have nested arrays in `imagePath`', async () => { 164 | const node = { 165 | ...baseNode, 166 | nodes: [ 167 | { 168 | id: 'nested parent', 169 | imageUrl: 'https://dummyimage.com/600x400/000/fff.png', 170 | }, 171 | ], 172 | internal: { 173 | contentDigest: 'testdigest', 174 | type: 'test', 175 | mediaType: 'application/json', 176 | }, 177 | }; 178 | const options = { 179 | ...baseOptions, 180 | imagePath: 'nodes[].imageUrl', 181 | }; 182 | const { 183 | actions, 184 | createNodeId, 185 | store, 186 | cache, 187 | reporter, 188 | } = getGatsbyNodeHelperMocks(); 189 | 190 | await onCreateNode( 191 | { node, actions, createNodeId, store, cache, reporter }, 192 | options 193 | ); 194 | expect(createNodeId).toHaveBeenCalledTimes(1); 195 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 196 | parentNodeId: baseNode.id, 197 | url: node.nodes[0].imageUrl, 198 | ext: null, 199 | store, 200 | cache, 201 | createNode: actions.createNode, 202 | createNodeId, 203 | auth: {}, 204 | }); 205 | }); 206 | 207 | it('can have arrays at the leaf nodes', async () => { 208 | const node = { 209 | ...baseNode, 210 | imageUrls: [ 211 | 'https://dummyimage.com/600x400/000/fff.png', 212 | 'https://dummyimage.com/600x400/000/fff.png', 213 | ], 214 | internal: { 215 | contentDigest: 'testdigest', 216 | type: 'test', 217 | mediaType: 'application/json', 218 | }, 219 | }; 220 | const options = { 221 | ...baseOptions, 222 | imagePath: 'imageUrls', 223 | type: 'array', 224 | }; 225 | const { 226 | actions, 227 | createNodeId, 228 | store, 229 | cache, 230 | reporter, 231 | } = getGatsbyNodeHelperMocks(); 232 | 233 | await onCreateNode( 234 | { node, actions, createNodeId, store, cache, reporter }, 235 | options 236 | ); 237 | expect(createNodeId).toHaveBeenCalledTimes(2); 238 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 239 | parentNodeId: baseNode.id, 240 | url: node.imageUrls[1], 241 | ext: null, 242 | store, 243 | cache, 244 | createNode: actions.createNode, 245 | createNodeId, 246 | auth: {}, 247 | }); 248 | }); 249 | 250 | it('can have nested arrays in `imagePath` AND an array at the leaf node', async () => { 251 | const node = { 252 | ...baseNode, 253 | nodes: [ 254 | { 255 | id: 'nested parent', 256 | imageUrls: [ 257 | 'https://dummyimage.com/600x400/000/fff.png', 258 | 'https://dummyimage.com/600x400/000/fff.png', 259 | ], 260 | }, 261 | ], 262 | internal: { 263 | contentDigest: 'testdigest', 264 | type: 'test', 265 | mediaType: 'application/json', 266 | }, 267 | }; 268 | const options = { 269 | ...baseOptions, 270 | imagePath: 'nodes[].imageUrls', 271 | type: 'array', 272 | }; 273 | const { 274 | actions, 275 | createNodeId, 276 | store, 277 | cache, 278 | reporter, 279 | } = getGatsbyNodeHelperMocks(); 280 | 281 | await onCreateNode( 282 | { node, actions, createNodeId, store, cache, reporter }, 283 | options 284 | ); 285 | expect(createNodeId).toHaveBeenCalledTimes(2); 286 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 287 | parentNodeId: baseNode.id, 288 | url: node.nodes[0].imageUrls[1], 289 | ext: null, 290 | store, 291 | cache, 292 | createNode: actions.createNode, 293 | createNodeId, 294 | auth: {}, 295 | }); 296 | }); 297 | 298 | it('can have nested arrays in `imagePath` with multiple elements in array', async () => { 299 | const node = { 300 | ...baseNode, 301 | nodes: [ 302 | { 303 | id: 'nested parent', 304 | imageUrl: 'https://dummyimage.com/600x400/000/fff.png', 305 | }, 306 | { 307 | id: 'another', 308 | imageUrl: 'https://dummyimage.com/600x400/000/ddd.png', 309 | }, 310 | ], 311 | internal: { 312 | contentDigest: 'testdigest', 313 | type: 'test', 314 | mediaType: 'application/json', 315 | }, 316 | }; 317 | const options = { 318 | ...baseOptions, 319 | imagePath: 'nodes[].imageUrl', 320 | }; 321 | const { 322 | actions, 323 | createNodeId, 324 | store, 325 | cache, 326 | reporter, 327 | } = getGatsbyNodeHelperMocks(); 328 | 329 | await onCreateNode( 330 | { node, actions, createNodeId, store, cache, reporter }, 331 | options 332 | ); 333 | expect(createNodeId).toHaveBeenCalledTimes(2); 334 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 335 | parentNodeId: baseNode.id, 336 | url: node.nodes[1].imageUrl, 337 | ext: null, 338 | store, 339 | cache, 340 | createNode: actions.createNode, 341 | createNodeId, 342 | auth: {}, 343 | }); 344 | expect(cache.set).toHaveBeenCalledTimes(1); 345 | }); 346 | 347 | it('can have multiple path levels', async () => { 348 | const node = { 349 | ...baseNode, 350 | ancestor: { 351 | nodes: [ 352 | { 353 | id: 'nested parent', 354 | imageUrl: 'https://dummyimage.com/600x400/000/fff.png', 355 | }, 356 | { 357 | id: 'another', 358 | imageUrl: 'https://dummyimage.com/600x400/000/ddd.png', 359 | }, 360 | ], 361 | }, 362 | internal: { 363 | contentDigest: 'testdigest', 364 | type: 'test', 365 | mediaType: 'application/json', 366 | }, 367 | }; 368 | const options = { 369 | ...baseOptions, 370 | imagePath: 'ancestor.nodes[].imageUrl', 371 | }; 372 | const { 373 | actions, 374 | createNodeId, 375 | store, 376 | cache, 377 | reporter, 378 | } = getGatsbyNodeHelperMocks(); 379 | 380 | await onCreateNode( 381 | { node, actions, createNodeId, store, cache, reporter }, 382 | options 383 | ); 384 | expect(createNodeId).toHaveBeenCalledTimes(2); 385 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 386 | parentNodeId: baseNode.id, 387 | url: node.ancestor.nodes[1].imageUrl, 388 | ext: null, 389 | store, 390 | cache, 391 | createNode: actions.createNode, 392 | createNodeId, 393 | auth: {}, 394 | }); 395 | expect(cache.set).toHaveBeenCalledTimes(1); 396 | }); 397 | 398 | it('creates remote files node with defaults when an array is in path', async () => { 399 | const node = { 400 | ...baseNode, 401 | ancestor: { 402 | nodes: [ 403 | { 404 | id: 'nested parent', 405 | imageUrl: 'https://dummyimage.com/600x400/000/fff.png', 406 | }, 407 | { 408 | id: 'another', 409 | imageUrl: 'https://dummyimage.com/600x400/000/ddd.png', 410 | }, 411 | ], 412 | }, 413 | }; 414 | const options = { 415 | ...baseOptions, 416 | imagePath: 'ancestor.nodes[].imageUrl', 417 | }; 418 | const { 419 | actions, 420 | createNodeId, 421 | createResolvers: mockCreateResolvers, 422 | store, 423 | cache, 424 | reporter, 425 | } = getGatsbyNodeHelperMocks(); 426 | 427 | await onCreateNode( 428 | { node, actions, createNodeId, store, cache, reporter }, 429 | options 430 | ); 431 | expect(createNodeId).toHaveBeenCalledTimes(2); 432 | 433 | createResolvers({ cache, createResolvers: mockCreateResolvers }, options); 434 | expect(mockCreateResolvers).toHaveBeenCalledTimes(1); 435 | expect(mockCreateResolvers).toHaveBeenLastCalledWith({ 436 | [options.nodeType]: { 437 | localImage: { 438 | type: '[File]', 439 | resolve: expect.any(Function), 440 | }, 441 | }, 442 | }); 443 | }); 444 | 445 | it("should create file node when url is falsy(null, '', undefined)", async () => { 446 | createRemoteFileNode.mockRejectedValue('Invalid url'); 447 | 448 | const nodes = [ 449 | { 450 | id: 'empty string', 451 | imageUrl: '', 452 | internal: { 453 | contentDigest: 'testdigest', 454 | type: 'test', 455 | mediaType: 'application/json', 456 | }, 457 | }, 458 | { 459 | id: 'null', 460 | imageUrl: null, 461 | internal: { 462 | contentDigest: 'testdigest', 463 | type: 'test', 464 | mediaType: 'application/json', 465 | }, 466 | }, 467 | { 468 | id: 'undefined', 469 | imageUrl: undefined, 470 | internal: { 471 | contentDigest: 'testdigest', 472 | type: 'test', 473 | mediaType: 'application/json', 474 | }, 475 | }, 476 | ]; 477 | const options = { 478 | ...baseOptions, 479 | imagePath: 'imageUrl', 480 | }; 481 | const { 482 | actions, 483 | createNodeId, 484 | store, 485 | cache, 486 | reporter, 487 | createContentDigest, 488 | } = getGatsbyNodeHelperMocks(); 489 | 490 | for (const node of nodes) { 491 | createNodeId.mockClear(); 492 | await onCreateNode( 493 | { 494 | node, 495 | actions, 496 | createNodeId, 497 | createContentDigest, 498 | store, 499 | cache, 500 | reporter, 501 | }, 502 | options 503 | ); 504 | expect(createNodeId).toHaveBeenCalledTimes(1); 505 | expect(createRemoteFileNode).toHaveBeenLastCalledWith({ 506 | parentNodeId: node.id, 507 | url: node.imageUrl, 508 | ext: null, 509 | store, 510 | cache, 511 | createNode: actions.createNode, 512 | createContentDigest, 513 | createNodeId, 514 | auth: {}, 515 | }); 516 | expect(actions.createNode).toHaveBeenLastCalledWith( 517 | expect.objectContaining({ 518 | internal: expect.objectContaining({ 519 | type: 'File', 520 | mediaType: 'application/octet-stream', 521 | }), 522 | }), 523 | { name: 'gatsby-source-filesystem' } 524 | ); 525 | } 526 | }); 527 | }); 528 | -------------------------------------------------------------------------------- /src/gatsby-node.js: -------------------------------------------------------------------------------- 1 | const { createRemoteFileNode } = require(`gatsby-source-filesystem`); 2 | const { 3 | addRemoteFilePolyfillInterface, 4 | } = require('gatsby-plugin-utils/polyfill-remote-file'); 5 | 6 | const get = require('lodash/get'); 7 | const probe = require('probe-image-size'); 8 | 9 | let i = 0; 10 | 11 | exports.pluginOptionsSchema = ({ Joi }) => { 12 | return Joi.object({ 13 | nodeType: Joi.string().required(), 14 | imagePath: Joi.string().required(), 15 | name: Joi.string(), 16 | auth: Joi.object(), 17 | ext: Joi.string(), 18 | prepareUrl: Joi.function(), 19 | type: Joi.string(), 20 | silent: Joi.boolean(), 21 | skipUndefinedUrls: Joi.boolean(), 22 | }); 23 | }; 24 | 25 | const isImageCdnEnabled = () => { 26 | return ( 27 | process.env.GATSBY_CLOUD_IMAGE_CDN === '1' || 28 | process.env.GATSBY_CLOUD_IMAGE_CDN === 'true' 29 | ); 30 | }; 31 | 32 | exports.createSchemaCustomization = ({ actions, schema }) => { 33 | if (isImageCdnEnabled()) { 34 | const RemoteImageFileType = addRemoteFilePolyfillInterface( 35 | schema.buildObjectType({ 36 | name: 'RemoteImageFile', 37 | fields: { 38 | id: 'ID!', 39 | }, 40 | interfaces: ['Node', 'RemoteFile'], 41 | extensions: { 42 | infer: true, 43 | }, 44 | }), 45 | { 46 | schema, 47 | actions, 48 | } 49 | ); 50 | actions.createTypes([RemoteImageFileType]); 51 | } 52 | }; 53 | 54 | exports.onCreateNode = async ( 55 | { node, actions, store, cache, createNodeId, createContentDigest, reporter }, 56 | options 57 | ) => { 58 | const { createNode } = actions; 59 | const { 60 | nodeType, 61 | imagePath, 62 | name = 'localImage', 63 | auth = {}, 64 | ext = null, 65 | prepareUrl = null, 66 | type = 'object', 67 | silent = false, 68 | } = options; 69 | const createImageNodeOptions = { 70 | store, 71 | cache, 72 | createNode, 73 | createNodeId, 74 | createContentDigest, 75 | auth, 76 | ext, 77 | name, 78 | prepareUrl, 79 | }; 80 | 81 | if (node.internal.type === nodeType) { 82 | // Check if any part of the path indicates the node is an array and splits at those indicators 83 | let imagePathSegments = []; 84 | if (imagePath.includes('[].')) { 85 | imagePathSegments = imagePath.split('[].'); 86 | } 87 | 88 | if (imagePathSegments.length) { 89 | const urls = await getAllFilesUrls(imagePathSegments[0], node, { 90 | imagePathSegments, 91 | ...createImageNodeOptions, 92 | }); 93 | await createImageNodes( 94 | urls, 95 | node, 96 | createImageNodeOptions, 97 | reporter, 98 | silent 99 | ); 100 | } else if (type === 'array') { 101 | const urls = getPaths(node, imagePath, ext); 102 | await createImageNodes( 103 | urls, 104 | node, 105 | createImageNodeOptions, 106 | reporter, 107 | silent 108 | ); 109 | } else { 110 | const url = getPath(node, imagePath, ext); 111 | await createImageNode(url, node, createImageNodeOptions, reporter); 112 | } 113 | } 114 | }; 115 | 116 | function getPaths(node, path, ext = null) { 117 | const value = get(node, path); 118 | if (value) { 119 | return value.map((url) => (ext ? url + ext : url)); 120 | } 121 | } 122 | 123 | // Returns value from path, adding extension when supplied 124 | function getPath(node, path, ext = null) { 125 | const value = get(node, path); 126 | 127 | return ext ? value + ext : value; 128 | } 129 | 130 | // Returns a unique cache key for a given node ID 131 | function getCacheKeyForNodeId(nodeId) { 132 | return `gatsby-plugin-remote-images-${nodeId}`; 133 | } 134 | 135 | async function createImageNodes(urls, node, options, reporter, silent) { 136 | const { name, imagePathSegments, prepareUrl, ...restOfOptions } = options; 137 | let fileNode; 138 | 139 | if (!urls) { 140 | return; 141 | } 142 | 143 | const fileNodes = ( 144 | await Promise.all( 145 | urls.map(async (url, index) => { 146 | if (typeof prepareUrl === 'function') { 147 | url = prepareUrl(url); 148 | } 149 | 150 | if (options.skipUndefinedUrls && !url) return; 151 | 152 | try { 153 | fileNode = await createRemoteFileNode({ 154 | ...restOfOptions, 155 | url, 156 | parentNodeId: node.id, 157 | }); 158 | reporter.verbose(`Created image from ${url}`); 159 | } catch (e) { 160 | if (!silent) { 161 | reporter.error(`gatsby-plugin-remote-images ERROR:`, new Error(e)); 162 | } 163 | } 164 | return fileNode; 165 | }) 166 | ) 167 | ).filter((fileNode) => !!fileNode); 168 | 169 | // Store the mapping between the current node and the newly created File node 170 | if (fileNodes.length) { 171 | // This associates the existing node (of user-specified type) with the new 172 | // File nodes created via createRemoteFileNode. The new File nodes will be 173 | // resolved dynamically through the Gatsby schema customization 174 | // createResolvers API and which File node gets resolved for each new field 175 | // on a given node of the user-specified type is determined by the contents 176 | // of this mapping. The keys are based on the ID of the parent node (of 177 | // user-specified type) and the values are each a nested mapping of the new 178 | // image File field name to the ID of the new File node. 179 | const cacheKey = getCacheKeyForNodeId(node.id); 180 | const existingFileNodeMap = await options.cache.get(cacheKey); 181 | await options.cache.set(cacheKey, { 182 | ...existingFileNodeMap, 183 | [name]: fileNodes.map(({ id }) => id), 184 | }); 185 | } 186 | } 187 | 188 | // Creates a file node and associates the parent node to its new child 189 | async function createImageNode(url, node, options, reporter, silent) { 190 | const { name, imagePathSegments, prepareUrl, ...restOfOptions } = options; 191 | 192 | let fileNodeId; 193 | let fileNode; 194 | 195 | if (typeof prepareUrl === 'function') { 196 | url = prepareUrl(url); 197 | } 198 | 199 | if (options.skipUndefinedUrls && !url) return; 200 | 201 | try { 202 | if (isImageCdnEnabled()) { 203 | fileNodeId = options.createNodeId(`RemoteImageFile >>> ${node.id}`); 204 | const metadata = await probe(url); 205 | await options.createNode({ 206 | id: fileNodeId, 207 | parent: node.id, 208 | url: url, 209 | filename: `${node.id}.${metadata.type}`, 210 | height: metadata.height, 211 | width: metadata.width, 212 | mimeType: metadata.mime, 213 | internal: { 214 | type: 'RemoteImageFile', 215 | contentDigest: node.internal.contentDigest, 216 | }, 217 | }); 218 | if (!silent) { 219 | reporter.verbose(`Created RemoteImageFile node from ${url}`); 220 | } 221 | } else { 222 | fileNode = await createRemoteFileNode({ 223 | ...restOfOptions, 224 | url, 225 | parentNodeId: node.id, 226 | }); 227 | fileNodeId = fileNode.id; 228 | if (!silent) { 229 | reporter.verbose(`Created image from ${url}`); 230 | } 231 | } 232 | } catch (e) { 233 | if (!silent) { 234 | reporter.error(`gatsby-plugin-remote-images ERROR:`, new Error(e)); 235 | } 236 | ++i; 237 | 238 | fileNode = await options.createNode( 239 | { 240 | id: options.createNodeId(`${i}`), 241 | parent: node.id, 242 | internal: { 243 | type: 'File', 244 | mediaType: 'application/octet-stream', 245 | contentDigest: options.createContentDigest(`${i}`), 246 | }, 247 | }, 248 | { name: 'gatsby-source-filesystem' } 249 | ); 250 | } 251 | 252 | // Store the mapping between the current node and the newly created File node 253 | if (fileNode || isImageCdnEnabled()) { 254 | // This associates the existing node (of user-specified type) with the new 255 | // File nodes created via createRemoteFileNode. The new File nodes will be 256 | // resolved dynamically through the Gatsby schema customization 257 | // createResolvers API and which File node gets resolved for each new field 258 | // on a given node of the user-specified type is determined by the contents 259 | // of this mapping. The keys are based on the ID of the parent node (of 260 | // user-specified type) and the values are each a nested mapping of the new 261 | // image File field name to the ID of the new File node. 262 | const cacheKey = getCacheKeyForNodeId(node.id); 263 | const existingFileNodeMap = await options.cache.get(cacheKey); 264 | await options.cache.set(cacheKey, { 265 | ...existingFileNodeMap, 266 | [name]: fileNode ? fileNode.id : fileNodeId, 267 | }); 268 | } 269 | } 270 | 271 | // Recursively traverses objects/arrays at each path part, and return an array of urls 272 | async function getAllFilesUrls(path, node, options) { 273 | if (!path || !node) { 274 | return; 275 | } 276 | const { imagePathSegments, ext } = options; 277 | const pathIndex = imagePathSegments.indexOf(path), 278 | isPathToLeafProperty = pathIndex === imagePathSegments.length - 1, 279 | nextValue = getPath(node, path, isPathToLeafProperty ? ext : null); 280 | 281 | // @TODO: Need logic to handle if the leaf node is an array to then shift 282 | // to the function of createImageNodes. 283 | return Array.isArray(nextValue) && !isPathToLeafProperty 284 | ? // Recursively call function with next path segment for each array element 285 | ( 286 | await Promise.all( 287 | nextValue.map((item) => 288 | getAllFilesUrls(imagePathSegments[pathIndex + 1], item, options) 289 | ) 290 | ) 291 | ).reduce((arr, row) => arr.concat(row), []) 292 | : // otherwise, handle leaf node 293 | nextValue; 294 | } 295 | 296 | exports.createResolvers = ({ cache, createResolvers }, options) => { 297 | const { nodeType, imagePath, name = 'localImage', type = 'object' } = options; 298 | 299 | if (type === 'array' || imagePath.includes('[].')) { 300 | const resolvers = { 301 | [nodeType]: { 302 | [name]: { 303 | type: isImageCdnEnabled() ? '[RemoteImageFile]' : '[File]', 304 | resolve: async (source, _, context) => { 305 | const fileNodeMap = await cache.get( 306 | getCacheKeyForNodeId(source.id) 307 | ); 308 | if (!fileNodeMap || !fileNodeMap[name]) { 309 | return []; 310 | } 311 | return fileNodeMap[name].map((id) => 312 | context.nodeModel.getNodeById({ id }) 313 | ); 314 | }, 315 | }, 316 | }, 317 | }; 318 | createResolvers(resolvers); 319 | } else { 320 | const resolvers = { 321 | [nodeType]: { 322 | [name]: { 323 | type: isImageCdnEnabled() ? 'RemoteImageFile' : 'File', 324 | resolve: async (source, _, context) => { 325 | const fileNodeMap = await cache.get( 326 | getCacheKeyForNodeId(source.id) 327 | ); 328 | if (!fileNodeMap) return null; 329 | return context.nodeModel.getNodeById({ id: fileNodeMap[name] }); 330 | }, 331 | }, 332 | }, 333 | }; 334 | createResolvers(resolvers); 335 | } 336 | }; 337 | --------------------------------------------------------------------------------