├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── gatsby-node.js ├── index.js ├── package.json └── src ├── contentParser.js ├── createResolvers.js ├── plugin-values.js ├── sourceParser.js └── utils.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "google", 5 | "eslint:recommended", 6 | "plugin:react/recommended" 7 | ], 8 | "plugins": ["react"], 9 | "parserOptions": { 10 | "ecmaVersion": 2016, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "jsx": true 14 | } 15 | }, 16 | "env": { 17 | "browser": true, 18 | "es6": true, 19 | "node": true, 20 | "jest": true 21 | }, 22 | "globals": { 23 | "before": true, 24 | "spyOn": true, 25 | "__PATH_PREFIX__": true 26 | }, 27 | "rules": { 28 | "arrow-body-style": [ 29 | "error", 30 | "as-needed", 31 | { "requireReturnForObjectLiteral": true } 32 | ], 33 | "consistent-return": "off", 34 | "no-console": "off", 35 | "no-inner-declarations": "off", 36 | "quotes": "off", 37 | "react/display-name": "off", 38 | "react/jsx-key": "warn", 39 | "react/no-unescaped-entities": "warn", 40 | "react/prop-types": "off", 41 | "require-jsdoc": "off", 42 | "valid-jsdoc": "off", 43 | "arrow-parens": ["error", "as-needed"], 44 | "object-curly-spacing": ["error", "always"], 45 | "indent": "off", 46 | "prefer-const": "off", 47 | "max-len": "off" 48 | }, 49 | "overrides": [ 50 | { 51 | "files": [ 52 | "packages/**/gatsby-browser.js", 53 | "packages/gatsby/cache-dir/**/*" 54 | ], 55 | "env": { 56 | "browser": true 57 | }, 58 | "globals": { 59 | "___loader": false, 60 | "___emitter": false 61 | } 62 | }, 63 | { 64 | "files": ["**/cypress/integration/**/*", "**/cypress/support/**/*"], 65 | "globals": { 66 | "cy": false, 67 | "Cypress": false 68 | } 69 | } 70 | ], 71 | "settings": { 72 | "react": { 73 | "version": "16.4.2" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variables file 55 | .env 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | package-lock.json 71 | -------------------------------------------------------------------------------- /.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 | package-lock.json 31 | flow-typed 32 | coverage 33 | decls 34 | examples 35 | 36 | .prettierignore 37 | .prettierrc 38 | .eslintrc.json 39 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.min.js 2 | **/node_modules/** 3 | flow-typed 4 | 5 | # webfont demo styles 6 | **/specimen_files 7 | 8 | # built sites 9 | benchmarks/**/public 10 | e2e-tests/**/public 11 | examples/**/public 12 | integration-tests/**/public 13 | www/public 14 | 15 | # cache-dirs 16 | **/.cache 17 | 18 | # ignore built packages 19 | packages/**/*.js 20 | !packages/gatsby/cache-dir/**/*.js 21 | !packages/*/src/**/*.js 22 | packages/gatsby/cache-dir/commonjs/**/*.js 23 | 24 | # fixtures 25 | **/__testfixtures__/** 26 | **/__tests__/fixtures/** 27 | 28 | infrastructure 29 | 30 | # coverage 31 | coverage 32 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > **Warning** 2 | > This project is outdated and has been archived 3 | 4 | # gatsby-wpgraphql-inline-images 5 | 6 | ## Description 7 | 8 | Source plugins don't process links and images in blocks of text (i.e. post contents) which makes sourcing from CMS such as WordPress problematic. This plugin solves that for content sourced from WordPress using GraphQL by doing the following: 9 | 10 | - Downloads images and other files to Gatsby `static` folder 11 | - Replaces `` linking to site's pages with `` component 12 | - Replaces `` with Gatsby `` component leveraging all of the [gatsby-image](https://www.gatsbyjs.org/docs/using-gatsby-image/) rich functionality 13 | 14 | A [major update](https://github.com/gatsbyjs/gatsby/issues/19292) is in the works for the WordPress source plugin and WPGraphQL. This plugin will be radically changed or even become redundant after V4 is completed. 15 | 16 | ### Dependencies 17 | 18 | This plugin processes WordPress content sourced with GraphQL. Therefore you must use `gatsby-source-graphql` and your source WordPress site must use [WPGraphQL](https://github.com/wp-graphql/wp-graphql). 19 | 20 | _Attention:_ does not work with `gatsby-source-wordpress`. 21 | 22 | ## How to install 23 | 24 | ```bash 25 | yarn add gatsby-wpgraphql-inline-images 26 | ``` 27 | 28 | ```javascript 29 | { 30 | resolve: 'gatsby-wpgraphql-inline-images', 31 | options: { 32 | wordPressUrl: 'https://mydomain.com/', 33 | uploadsUrl: 'https://mydomain.com/wp-content/uploads/', 34 | processPostTypes: ['Page', 'Post', 'CustomPost'], 35 | graphqlTypeName: 'WPGraphQL', 36 | httpHeaders: { 37 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 38 | } 39 | }, 40 | }, 41 | ``` 42 | 43 | ## Available options 44 | 45 | `wordPressUrl` and `uploadsUrl` contain URLs of the source WordPress site and it's `uploads` folder respectively. 46 | 47 | `processPostTypes` determines which post types to process. You can include [custom post types](https://docs.wpgraphql.com/getting-started/custom-post-types) as defined in WPGraphQL. 48 | 49 | `customTypeRegistrations` allows additional registration of parsed html content for arbitrary graphql types. For more information, see examples below. 50 | 51 | `keyExtractor` a function that extracts the cache key for a specific node, typically this is the `uri` of a post. 52 | 53 | `graphqlTypeName` should contain the same `typeName` used in `gatsby-source-graphql` parameters. 54 | 55 | `generateWebp` _(boolean)_ adds [WebP images](https://www.gatsbyjs.org/docs/gatsby-image/#about-withwebp). 56 | 57 | `httpHeaders` Adds extra http headers to download request if passed in. 58 | 59 | `debugOutput` _(boolean)_ Outputs extra debug messages. 60 | 61 | ## How do I use this plugin? 62 | 63 | Downloading and optimizing images is done automatically via resolvers. You need to include `uri` in all queries that will be processed by the plugin otherwise they will be ignored. 64 | 65 | ``` 66 | query GET_PAGES { 67 | wpgraphql { 68 | pages { 69 | nodes { 70 | uri 71 | content 72 | } 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | There is an additional step of processing content that must be added manually to a page template. This additional processing replaces remote urls with links to downloaded files in Gatsby's static folder. 79 | 80 | ```javascript 81 | import contentParser from 'gatsby-wpgraphql-inline-images'; 82 | ``` 83 | 84 | replace `
` with this 85 | 86 | ```javascript 87 |
{contentParser({ content }, { wordPressUrl, uploadsUrl })}
88 | ``` 89 | 90 | Where `content` is the original HTML content and URLs should use the same values as in the options above. `contenParser` returns React object. 91 | 92 | ### Featured image 93 | 94 | The recommended handling of `featuredImage` and WordPress media in general is described by [henrikwirth](https://github.com/henrikwirth) in [this article](https://dev.to/nevernull/gatsby-with-wpgraphql-acf-and-gatbsy-image-72m) and [this gist](https://gist.github.com/henrikwirth/4ba900b7f89b9e28ec81497466b12710). 95 | 96 | ### WordPress galleries 97 | 98 | WordPress galleries may need some additional styling applied and this was intentionally left out of the scope of this plugin. Emotion [Global Styles](https://emotion.sh/docs/globals) may be used or just import sass/css file. 99 | 100 | ```css 101 | .gallery { 102 | display: flex; 103 | flex-direction: row; 104 | flex-wrap: wrap; 105 | } 106 | .gallery-item { 107 | margin-right: 10px; 108 | } 109 | ``` 110 | 111 | ## Gatsby themes support 112 | 113 | Inserted `` components have `variant: 'styles.SourcedImage'` applied to them. 114 | 115 | ## Examples of usage 116 | 117 | I'm going to use [gatsby-wpgraphql-blog-example](https://github.com/wp-graphql/gatsby-wpgraphql-blog-example) as a starter and it will source data from a demo site at [yourdomain.dev](https://yourdomain.dev/). 118 | 119 | Add this plugin to the `gatsby-config.js` 120 | 121 | ```javascript 122 | { 123 | resolve: 'gatsby-wpgraphql-inline-images', 124 | options: { 125 | wordPressUrl: `https://yourdomain.dev/`, 126 | uploadsUrl: `https://yourdomain.dev/wp-content/uploads/`, 127 | processPostTypes: ["Page", "Post"], 128 | graphqlTypeName: 'WPGraphQL', 129 | }, 130 | }, 131 | ``` 132 | 133 | Change `url` in `gatsby-source-graphql` options to `https://yourdomain.dev/graphql` 134 | 135 | Page templates are stored in `src/templates`. Let's modify `post.js` as an example. 136 | 137 | Importing `contentParser` 138 | 139 | ```javascript 140 | import contentParser from 'gatsby-wpgraphql-inline-images'; 141 | ``` 142 | 143 | For simplicty's sake I'm just going to add URLs directly in the template. 144 | 145 | ```javascript 146 | const pluginOptions = { 147 | wordPressUrl: `https://yourdomain.dev/`, 148 | uploadsUrl: `https://yourdomain.dev/wp-content/uploads/`, 149 | }; 150 | ``` 151 | 152 | and replace `dangerouslySetInnerHTML` with this 153 | 154 | ```javascript 155 |
{contentParser({ content }, pluginOptions)}
156 | ``` 157 | 158 | The modified example starter is available at [github.com/progital/gatsby-wpgraphql-blog-example](https://github.com/progital/gatsby-wpgraphql-blog-example). 159 | 160 | ## Examples of advanced usage 161 | 162 | Add this plugin to the `gatsby-config.js` 163 | 164 | ```javascript 165 | { 166 | resolve: 'gatsby-wpgraphql-inline-images', 167 | options: { 168 | wordPressUrl: `https://yourdomain.dev/`, 169 | uploadsUrl: `https://yourdomain.dev/wp-content/uploads/`, 170 | graphqlTypeName: 'WPGraphQL', 171 | customTypeRegistrations: [ 172 | { 173 | graphqlTypeName: "WPGraphQL_Page_Acfdemofields", 174 | fieldName: "fieldInAcf", 175 | }, 176 | ], 177 | keyExtractor: (source, context, info) => source.uri || source.key, 178 | }, 179 | }, 180 | ``` 181 | 182 | This example assumes that there is a GraphQL type `WPGraphQL_Page_Acfdemofields` that has a field `fieldInAcf`, e.g. 183 | 184 | ``` 185 | query MyQuery { 186 | wpgraphql { 187 | pages { 188 | nodes { 189 | acfDemoFields { 190 | fieldInAcf 191 | } 192 | } 193 | } 194 | } 195 | } 196 | ``` 197 | 198 | ## How to contribute 199 | 200 | This is a WIP and any contribution, feedback and PRs are very welcome. Issues is a preferred way of submitting feedback, but you can also email to [andrey@progital.io](mailto:andrey@progital.io). 201 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | const { GraphQLString } = require('gatsby/graphql'); 2 | 3 | // parses content sourced from WordPress/WPGraphQL 4 | exports.createResolvers = require('./src/createResolvers'); 5 | 6 | // adds `originalSourceUrl` field that contains original URL 7 | // for future extensions, not used at the moment 8 | exports.setFieldsOnGraphQLNodeType = ({ type }, pluginOptions) => { 9 | if (type.name !== 'File') { 10 | return {}; 11 | } 12 | 13 | return { 14 | originalSourceUrl: { 15 | type: GraphQLString, 16 | resolve: source => source.url, 17 | }, 18 | }; 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./src/contentParser"); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-wpgraphql-inline-images", 3 | "description": "A Gatsby plugin to turn remote inline images into local static images", 4 | "version": "0.2.5", 5 | "main": "index.js", 6 | "author": "Andrey Shalashov ", 7 | "keywords": [ 8 | "gatsby", 9 | "gatsby-plugin", 10 | "image", 11 | "wordpress", 12 | "wpgraphql" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/progital/gatsby-wpgraphql-inline-images.git" 17 | }, 18 | "license": "MIT", 19 | "dependencies": { 20 | "@babel/runtime": "^7.0.0", 21 | "@emotion/core": "^10.0.14", 22 | "@mdx-js/react": "^1.0.27", 23 | "cheerio": "^1.0.0-rc.3", 24 | "gatsby": "^2.20.25", 25 | "gatsby-image": "^2.3.4", 26 | "gatsby-plugin-sharp": "^2.5.6", 27 | "gatsby-source-filesystem": "^2.2.4", 28 | "gatsby-transformer-sharp": "^2.4.6", 29 | "html-react-parser": "^0.10.3", 30 | "lodash": "^4.17.11", 31 | "theme-ui": "^0.3.1", 32 | "urijs": "^1.19.1" 33 | }, 34 | "devDependencies": { 35 | "cross-env": "^7.0.2", 36 | "eslint": "^6.0.1", 37 | "eslint-config-google": "^0.14.0", 38 | "eslint-config-prettier": "^6.0.0", 39 | "eslint-plugin-import": "^2.18.0", 40 | "eslint-plugin-jsx-a11y": "^6.2.3", 41 | "eslint-plugin-prettier": "^3.1.0", 42 | "eslint-plugin-react": "^7.14.2", 43 | "prettier": "^2.0.4" 44 | }, 45 | "peerDependencies": { 46 | "gatsby": "^2.13.12", 47 | "react": "^16.8.6", 48 | "react-dom": "^16.8.6" 49 | }, 50 | "scripts": { 51 | "test": "echo \"Error: no test specified\" && exit 1" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/contentParser.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx } from 'theme-ui'; 3 | import getByPath from 'lodash/get'; 4 | import { Link } from 'gatsby'; 5 | import Img from 'gatsby-image'; 6 | import parser, { domToReact } from 'html-react-parser'; 7 | import { Styled } from 'theme-ui'; 8 | import URIParser from 'urijs'; 9 | 10 | /** 11 | * swaps external URLs in
and elements if they were downloaded and are available locally 12 | * returns React elements 13 | * @param {string} content post content 14 | * @param {string} wordPressUrl wordpress uploads url 15 | * @param {string} uploadsUrl wordpress site url 16 | * @return {React} React elements 17 | * 18 | * contentParser(content, pluginOptions) 19 | */ 20 | export default function contentParser( 21 | { content }, 22 | { wordPressUrl, uploadsUrl } 23 | ) { 24 | if (typeof content === 'undefined') { 25 | console.log( 26 | 'ERROR: contentParser requires content parameter to be string but got undefined.' 27 | ); 28 | } 29 | 30 | if (typeof content !== 'string') { 31 | return content; 32 | } 33 | 34 | const subdirectoryCorrection = (path, wordPressUrl) => { 35 | const wordPressUrlParsed = new URIParser(wordPressUrl); 36 | // detect if WordPress is installed in subdirectory 37 | const subdir = wordPressUrlParsed.path(); 38 | return path.replace(subdir, '/'); 39 | }; 40 | 41 | const parserOptions = { 42 | replace: domNode => { 43 | let elementUrl = 44 | (domNode.name === 'a' && domNode.attribs.href) || 45 | (domNode.name === 'img' && domNode.attribs.src) || 46 | null; 47 | 48 | if (!elementUrl) { 49 | return; 50 | } 51 | 52 | let urlParsed = new URIParser(elementUrl); 53 | 54 | // TODO test if this hash handling is sufficient 55 | if (elementUrl === urlParsed.hash()) { 56 | return; 57 | } 58 | 59 | // handling relative url 60 | const isUrlRelative = urlParsed.is('relative'); 61 | 62 | if (isUrlRelative) { 63 | urlParsed = urlParsed.absoluteTo(wordPressUrl); 64 | elementUrl = urlParsed.href(); 65 | } 66 | 67 | // removes protocol to handle mixed content in a page 68 | let elementUrlNoProtocol = elementUrl.replace(/^https?:/i, ''); 69 | let uploadsUrlNoProtocol = uploadsUrl.replace(/^https?:/i, ''); 70 | let wordPressUrlNoProtocol = wordPressUrl.replace(/^https?:/i, ''); 71 | 72 | let className = getByPath(domNode, 'attribs.class', ''); 73 | // links to local files have this attribute set in sourceParser 74 | let wasLinkProcessed = getByPath( 75 | domNode, 76 | 'attribs[data-gts-swapped-href]', 77 | null 78 | ); 79 | 80 | // replaces local links with element 81 | if ( 82 | domNode.name === 'a' && 83 | !wasLinkProcessed && 84 | elementUrlNoProtocol.includes(wordPressUrlNoProtocol) && 85 | !elementUrlNoProtocol.includes(uploadsUrlNoProtocol) 86 | ) { 87 | let url = urlParsed.path(); 88 | url = subdirectoryCorrection(url, wordPressUrl); 89 | return ( 90 | 91 | {domToReact(domNode.children, parserOptions)} 92 | 93 | ); 94 | } 95 | 96 | // cleans up internal processing attribute 97 | if (wasLinkProcessed) { 98 | delete domNode.attribs['data-gts-swapped-href']; 99 | } 100 | 101 | // data passed from sourceParser 102 | const fluidData = 103 | domNode.name === 'img' && 104 | getByPath(domNode, 'attribs[data-gts-encfluid]', null); 105 | 106 | if (fluidData) { 107 | const fluidDataParsed = JSON.parse(fluidData); 108 | 109 | let altText = getByPath(domNode, 'attribs.alt', ''); 110 | let imageTitle = getByPath(domNode, 'attribs.title', null); 111 | 112 | if (imageTitle && !altText) { 113 | altText = imageTitle; 114 | } 115 | 116 | // respects original "width" attribute 117 | // sets width accordingly 118 | let extraSx = {}; 119 | if ( 120 | domNode.attribs.width && 121 | !Number.isNaN(Number.parseInt(domNode.attribs.width, 10)) 122 | ) { 123 | extraSx.width = `${domNode.attribs.width}px`; 124 | } 125 | 126 | return ( 127 | {altText} 137 | ); 138 | } 139 | }, 140 | }; 141 | 142 | return parser(content, parserOptions); 143 | } 144 | -------------------------------------------------------------------------------- /src/createResolvers.js: -------------------------------------------------------------------------------- 1 | const sourceParser = require('./sourceParser'); 2 | const debugLog = require('./utils').debugLog; 3 | 4 | const findExistingNode = (uri, allNodes) => 5 | allNodes.find(node => node.sourceUri === uri); 6 | 7 | const postsBeingParsed = new Map(); 8 | 9 | module.exports = async function createResolvers(params, pluginOptions) { 10 | const contentNodeType = 'ParsedWordPressContent'; 11 | const { 12 | createResolvers, 13 | createNodeId, 14 | createContentDigest, 15 | getNodesByType, 16 | } = params; 17 | const { 18 | actions: { createNode }, 19 | } = params; 20 | const { 21 | processPostTypes = [], 22 | customTypeRegistrations = [], 23 | debugOutput = false, 24 | keyExtractor = (source, context, info) => source.uri, 25 | } = pluginOptions; 26 | 27 | const logger = (...args) => { 28 | args.unshift('>>>'); 29 | debugLog(debugOutput, ...args); 30 | }; 31 | 32 | // `content` field Resolver 33 | // - passes content to sourceParser 34 | // - saves (caches) the result to a `ParsedWordPressContent` node 35 | // - repeat request for the same content (determined by uri) returns cached result 36 | const contentResolver = async (source, args, context, info) => { 37 | // const { uri, path } = source; 38 | let uri = keyExtractor(source, context, info); 39 | let parsedContent = ''; 40 | logger('Entered contentResolver @', uri || 'URI not defined, skipping'); 41 | let content = source[info.fieldName]; 42 | 43 | // uri works as a key for caching/processing functions 44 | // bails if no uri 45 | if (!uri) { 46 | return content; 47 | } 48 | 49 | // if a node with a given URI exists 50 | const cached = findExistingNode(uri, getNodesByType(contentNodeType)); 51 | // returns content from that node 52 | if (cached) { 53 | logger('node already created:', uri); 54 | return cached.parsedContent; 55 | } 56 | 57 | // returns promise 58 | if (postsBeingParsed.has(uri)) { 59 | logger('node is already being parsed:', uri); 60 | return postsBeingParsed.get(uri); 61 | } 62 | 63 | const parsing = (async () => { 64 | try { 65 | logger('will start parsing:', uri); 66 | parsedContent = await sourceParser( 67 | { content }, 68 | pluginOptions, 69 | params, 70 | context 71 | ); 72 | return parsedContent; 73 | } catch (e) { 74 | console.log(`Failed sourceParser at ${uri}`, e); 75 | return content; 76 | } 77 | 78 | logger(`[ORIGINAL CONTENT @ ${uri}]`, content); 79 | logger(`[PARSED CONTENT @ ${uri}]`, parsedContent); 80 | 81 | let payload = { 82 | parsedContent, 83 | sourceId: source.id, 84 | sourceUri: source.uri, 85 | sourcePageId: source.pageId, 86 | }; 87 | 88 | let node = { 89 | ...payload, 90 | id: createNodeId(source.uri, contentNodeType), 91 | children: [], 92 | parent: null, 93 | internal: { 94 | type: contentNodeType, 95 | contentDigest: createContentDigest(payload), 96 | }, 97 | }; 98 | 99 | logger('done parsing, creating node:', uri); 100 | await createNode(node); 101 | 102 | return parsedContent; 103 | })(); 104 | 105 | postsBeingParsed.set(uri, parsing); 106 | 107 | return parsing; 108 | }; 109 | 110 | processPostTypes.forEach(element => { 111 | let params = {}; 112 | params[`${pluginOptions.graphqlTypeName}_${element}`] = { 113 | content: { 114 | resolve: contentResolver, 115 | }, 116 | }; 117 | logger('Registering ', `${pluginOptions.graphqlTypeName}_${element}`); 118 | 119 | createResolvers(params); 120 | }); 121 | customTypeRegistrations.forEach(registration => { 122 | let params = {}; 123 | params[registration.graphqlTypeName] = { 124 | [registration.fieldName]: { 125 | resolve: contentResolver, 126 | }, 127 | }; 128 | logger('Registering custom resolver ', registration.graphqlTypeName); 129 | 130 | createResolvers(params); 131 | }); 132 | }; 133 | -------------------------------------------------------------------------------- /src/plugin-values.js: -------------------------------------------------------------------------------- 1 | module.exports = pathPrefix => { 2 | const imageOptions = { 3 | maxWidth: 1380, 4 | wrapperStyle: ``, 5 | backgroundColor: `white`, 6 | linkImagesToOriginal: false, 7 | showCaptions: false, 8 | withWebp: true, 9 | tracedSVG: false, 10 | pathPrefix, 11 | }; 12 | 13 | const supportedExtensions = { 14 | jpeg: true, 15 | jpg: true, 16 | png: true, 17 | webp: true, 18 | tif: true, 19 | tiff: true, 20 | }; 21 | 22 | return { 23 | imageOptions, 24 | supportedExtensions, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/sourceParser.js: -------------------------------------------------------------------------------- 1 | const { 2 | downloadMediaFile, 3 | convertFileNodeToFluid, 4 | copyToStatic, 5 | } = require(`./utils`); 6 | const cheerio = require('cheerio'); 7 | const URIParser = require('urijs'); 8 | const getPluginValues = require(`./plugin-values`); 9 | 10 | /** 11 | * Parses sourced HTML looking for and tags 12 | * that come from the WordPress uploads folder 13 | * Copies over files to Gatsby static folder 14 | * Also does additional processing to "fix" WordPress content 15 | * - unwraps

that contain 16 | * @param {string} content original sourced content 17 | * @param {string} uploadsUrl WordPress uploads url 18 | * @param {string} wordPressUrl WordPress site url 19 | * @param {string} pathPrefix Gatsby pathPrefix 20 | * @param {bool} generateWebp is WebP required? 21 | * @param {object} httpHeaders custom httpHeaders 22 | * @param {bool} debugOutput enables extra logging 23 | * @param {object} params Gatsby API object 24 | * 25 | * @return {string} processed HTML 26 | * 27 | * sourceParser(source, pluginOptions, params) 28 | */ 29 | 30 | module.exports = async function sourceParser( 31 | { content }, 32 | { 33 | uploadsUrl, 34 | wordPressUrl, 35 | pathPrefix = '', 36 | generateWebp = true, 37 | httpHeaders = {}, 38 | debugOutput = false, 39 | }, 40 | params, 41 | context 42 | ) { 43 | const { 44 | actions, 45 | store, 46 | cache, 47 | reporter, 48 | createNodeId, 49 | getNodeAndSavePathDependency, 50 | } = params; 51 | const { createNode } = actions; 52 | 53 | const { imageOptions, supportedExtensions } = getPluginValues(pathPrefix); 54 | 55 | if (!content) { 56 | return ''; 57 | } 58 | 59 | const $ = cheerio.load(content, { xmlMode: true, decodeEntities: false }); 60 | 61 | let imageRefs = []; 62 | let pRefs = []; 63 | let swapSrc = new Map(); 64 | 65 | $('a, img').each((i, item) => { 66 | let url = item.attribs.href || item.attribs.src; 67 | let urlKey = url; 68 | 69 | if (!url) { 70 | return; 71 | } 72 | 73 | // removes protocol to handle mixed content in a page 74 | let urlNoProtocol = url.replace(/^https?:/i, ''); 75 | let uploadsUrlNoProtocol = uploadsUrl.replace(/^https?:/i, ''); 76 | // gets relative uploads url 77 | let uploadsUrlRelative = new URIParser(uploadsUrl).path(); 78 | // handling relative url 79 | const urlParsed = new URIParser(url); 80 | const isUrlRelative = urlParsed.is('relative'); 81 | 82 | // if not relative root url or not matches uploads dir 83 | if ( 84 | !(isUrlRelative && url.startsWith(uploadsUrlRelative)) && 85 | !urlNoProtocol.startsWith(uploadsUrlNoProtocol) 86 | ) { 87 | return; 88 | } 89 | 90 | if (isUrlRelative) { 91 | url = urlParsed.absoluteTo(wordPressUrl).href(); 92 | } 93 | 94 | if (imageRefs.some(({ url: storedUrl }) => storedUrl === url)) { 95 | // console.log('found image (again):' , url); 96 | return; 97 | } 98 | 99 | // console.log('found image:' , url); 100 | 101 | imageRefs.push({ 102 | url, 103 | urlKey, 104 | name: item.name, 105 | elem: $(item), 106 | }); 107 | 108 | // wordpress wpautop wraps with

109 | // this causes react console message when replacing with component 110 | // code below unwraps and removes parent

111 | if (item.name === 'img') { 112 | $(item) 113 | .parents('p') 114 | .each(function(index, element) { 115 | pRefs.push($(element)); 116 | $(element) 117 | .contents() 118 | .insertAfter($(element)); 119 | }); 120 | } 121 | }); 122 | 123 | // deletes

elements 124 | pRefs.forEach(elem => elem.remove()); 125 | 126 | await Promise.all( 127 | imageRefs.map(async item => { 128 | const fileNode = await downloadMediaFile({ 129 | url: item.url, 130 | cache, 131 | store, 132 | createNode, 133 | createNodeId, 134 | httpHeaders, 135 | }); 136 | 137 | // non-image files are copied to the `/static` folder 138 | if (!supportedExtensions[fileNode.extension]) { 139 | let staticFile = copyToStatic({ 140 | file: fileNode, 141 | getNodeAndSavePathDependency, 142 | context, 143 | pathPrefix, 144 | }); 145 | 146 | swapSrc.set(item.urlKey, { 147 | src: staticFile, 148 | id: fileNode.id, 149 | }); 150 | 151 | console.log(`Downloaded file: ${item.url}`); 152 | return; 153 | } 154 | 155 | try { 156 | const fluidResult = await convertFileNodeToFluid({ 157 | generateWebp, 158 | fileNode, 159 | imageOptions, 160 | reporter, 161 | cache, 162 | }); 163 | 164 | swapSrc.set(item.urlKey, { 165 | src: fluidResult.originalImg, 166 | id: fileNode.id, 167 | encoded: JSON.stringify(fluidResult), 168 | }); 169 | } catch (e) { 170 | console.log('Exception fluid', e); 171 | } 172 | 173 | console.log(`Downloaded file:`, item.url); 174 | }) 175 | ); 176 | 177 | $('img').each((i, item) => { 178 | let url = item.attribs.src; 179 | let swapVal = swapSrc.get(url); 180 | if (!swapVal) { 181 | return; 182 | } 183 | 184 | // console.log('swapping src',$(item).attr('src'), '=>', swapVal.src) 185 | $(item).attr('src', swapVal.src); 186 | if (swapVal.encoded) { 187 | $(item).attr( 188 | 'data-gts-encfluid', 189 | swapVal.encoded.replace(/"/g, '"') 190 | ); 191 | } 192 | $(item).removeAttr('srcset'); 193 | $(item).removeAttr('sizes'); 194 | }); 195 | 196 | $('a').each((i, item) => { 197 | let url = item.attribs.href; 198 | let swapVal = swapSrc.get(url); 199 | if (!swapVal) { 200 | return; 201 | } 202 | 203 | // console.log('swapping href',$(item).attr('src'), '=>', swapVal.src) 204 | $(item).attr('href', swapVal.src); 205 | // prevents converting to in contentParser 206 | $(item).attr('data-gts-swapped-href', 'gts-swapped-href'); 207 | }); 208 | 209 | return $.html(); 210 | }; 211 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const { createRemoteFileNode } = require('gatsby-source-filesystem'); 2 | const { fluid } = require(`gatsby-plugin-sharp`); 3 | const fs = require(`fs-extra`); 4 | const path = require(`path`); 5 | const getPluginValues = require(`./plugin-values`); 6 | 7 | // all-in-one function that is not used 8 | const downloadImage = async ( 9 | url, 10 | { 11 | actions, 12 | store, 13 | cache, 14 | reporter, 15 | createNodeId, 16 | getNodeAndSavePathDependency, 17 | }, 18 | pathPrefix, 19 | generateWebp = true, 20 | httpHeaders = {} 21 | ) => { 22 | const { createNode } = actions; 23 | 24 | const { imageOptions } = getPluginValues(pathPrefix); 25 | 26 | const fileNode = await downloadMediaFile({ 27 | url, 28 | cache, 29 | store, 30 | createNode, 31 | createNodeId, 32 | httpHeaders, 33 | }); 34 | 35 | const fluidResult = await convertFileNodeToFluid({ 36 | generateWebp, 37 | fileNode, 38 | imageOptions, 39 | reporter, 40 | cache, 41 | }); 42 | 43 | return fluidResult; 44 | }; 45 | 46 | // downloads media file to gatsby folder 47 | const downloadMediaFile = async ({ 48 | url, 49 | cache, 50 | store, 51 | createNode, 52 | createNodeId, 53 | httpHeaders = {}, 54 | }) => { 55 | let fileNode = false; 56 | try { 57 | fileNode = await createRemoteFileNode({ 58 | url, 59 | store, 60 | cache, 61 | createNode, 62 | createNodeId, 63 | httpHeaders, 64 | }); 65 | } catch (e) { 66 | console.log('FAILED to download ' + url); 67 | } 68 | 69 | return fileNode; 70 | }; 71 | 72 | // generates fluid object (gatsby-image) from the file node 73 | const convertFileNodeToFluid = async ({ 74 | generateWebp = true, 75 | fileNode, 76 | imageOptions, 77 | reporter, 78 | cache, 79 | }) => { 80 | let fluidResult = await fluid({ 81 | file: fileNode, 82 | args: imageOptions, 83 | reporter, 84 | cache, 85 | }); 86 | 87 | if (generateWebp) { 88 | const fluidWebp = await fluid({ 89 | file: fileNode, 90 | args: { ...imageOptions, toFormat: 'webp' }, 91 | reporter, 92 | cache, 93 | }); 94 | 95 | fluidResult.srcSetWebp = fluidWebp.srcSet; 96 | } 97 | 98 | return fluidResult; 99 | }; 100 | 101 | // source: gatsby-source-filesystem/src/extend-file-node.js 102 | // copies file to the `/static` folder 103 | const copyToStatic = ({ 104 | file, 105 | getNodeAndSavePathDependency, 106 | context, 107 | pathPrefix, 108 | }) => { 109 | const details = getNodeAndSavePathDependency(file.id, context.path); 110 | const fileName = `${file.name}-${file.internal.contentDigest}${details.ext}`; 111 | 112 | const publicPath = path.join(process.cwd(), `public`, `static`, fileName); 113 | 114 | if (!fs.existsSync(publicPath)) { 115 | fs.copy(details.absolutePath, publicPath, err => { 116 | if (err) { 117 | console.error( 118 | `error copying file from ${details.absolutePath} to ${publicPath}`, 119 | err 120 | ); 121 | } 122 | }); 123 | } 124 | 125 | return `${pathPrefix}/static/${fileName}`; 126 | }; 127 | 128 | // helper function for logging messages 129 | const debugLog = (debugOutput, ...args) => { 130 | debugOutput && console.log(...args); 131 | }; 132 | 133 | module.exports = { 134 | downloadMediaFile, 135 | downloadImage, 136 | convertFileNodeToFluid, 137 | debugLog, 138 | copyToStatic, 139 | }; 140 | --------------------------------------------------------------------------------