├── .babelrc ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc.json ├── README.md ├── package-lock.json ├── package.json └── src ├── babel-loader.js ├── fragments.js ├── gatsby-node.js ├── gatsby-ssr.js ├── getRootQuery.js ├── graphql-nodes.js ├── index.js ├── pages.js ├── preview-template.js ├── preview.boilerplate.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["babel-preset-gatsby-package"] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: NathHorrigan 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /*.js 3 | .cache 4 | babel-plugin-remove-graphql-queries 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "tabWidth": 4, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # gatsby-source-wagtail 3 | 4 | > NOTE: This plugin requires that your Wagtail site use the [Wagtail-Grapple](https://github.com/torchbox/wagtail-grapple) 5 | library to build a compatible GraphQL endpoint. It does not work without a GraphQL endpoint. 6 | 7 | ## Features: 🚀 8 | * Stitches your Wagtail GraphQL endpoint into the internal Gatsby one. 9 | * Simple router that matches your Django models to Gatsby templates. 10 | * Redirect support, making your Wagtail redirects work with sites hosted on Netlify & S3. 11 | * Out-of-the-box Wagtail Preview with realtime update as you type in the admin. 12 | * Gatsby Image Support 🔥 13 | * Incremental builds using `GATSBY_EXPERIMENTAL_PAGE_BUILD_ON_DATA_CHANGES=true ` flag. 14 | 15 | ## How to use 16 | 17 | ### Installation 18 | 19 | `npm install gatsby-source-wagtail` 20 | 21 | ### Configuration 22 | 23 | Add the package to your `gatsby-config.js` with the url to your Wagtail GQL endpoint: 24 | 25 | ```js 26 | ... 27 | { 28 | resolve: "gatsby-source-wagtail", 29 | options: { 30 | url: "http://localhost:8000/graphql" 31 | }, 32 | }, 33 | ... 34 | ``` 35 | 36 | #### Available Config Options 37 | 38 | | Option | Required | Description | Datatype | Default | 39 | |--------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------| 40 | | url | Y | The Wagtail GraphQL endpoint URL | string | null | 41 | | websocketUrl | N* | The GraphQL subscriptions endpoint URL. It can be inferred during development, but needs to be set in a production env | string | N/A | 42 | | headers | N | A JSON object of headers you want appended to all HTTP requests (Gatsby Build + Page Preview). | json | {} | 43 | | fieldName | N* | The field name you want your remote endpoint accessible under. If you have multiple connections then you will need to provide a value for at least one of them. | string | wagtail | 44 | | typeName | N* | The remote schema's internal type name. When you have multiple connections, you will need to provide a value (just copy fieldName). | string | wagtail | 45 | | isDefault | N* | A settings that tells the plugin which Wagtail GraphQL endpoint is the primary/default one. Used for preview and page generation. If you have multiple connections, you must choose which one you will generate pages from. Multiple site page generation is planned for future development. | string | true | 46 | 47 | 48 | ### Page Router 49 | This source plugin provides a simple router that maps a Django model to a specific Gatsby template. Pass a JSON map to the function in your `gatsby-node.js`. 50 | The router also adds Wagtail Preview to your Gatsby site automagically! Now point your backend to the Gatsby site and everything will work: [How to link Wagtail & Gatsby](LINK TO BACKEND DOCS). 51 | 52 | To map a Django model with the `home.BlogPage` ContentType to a template located at `./src/templates/blog.js` 53 | 54 | ```js 55 | const { createWagtailPages } = require("gatsby-source-wagtail/pages.js") 56 | 57 | exports.createPages = ({ graphql, actions }) => { 58 | return createWagtailPages({ 59 | "home.BlogPage": "templates/blog.js", 60 | }, graphql, actions, []) 61 | } 62 | ``` 63 | 64 | The example template: 65 | 66 | 67 | ```jsx 68 | ... 69 | 70 | export default ({ data }) => { 71 | const { page } = data.wagtail 72 | 73 | return ( 74 |
75 |

{ page.title }

76 |
77 | ) 78 | } 79 | 80 | export const query = graphql` 81 | query($slug: String) { 82 | wagtail { 83 | page(slug: $slug) { 84 | ...on BlogPage { 85 | title 86 | } 87 | } 88 | } 89 | } 90 | ` 91 | ``` 92 | 93 | Some page specific information is passed to page through the Gatsby context prop. The following variables are passed, thus are available in templates: 94 | 95 | * $id: Int 96 | * $slug: String 97 | * $url: String 98 | * $contentType: String 99 | 100 | ### Redirects 101 | The plugin queries your Wagtail endpoint for any defined redirects and pass them to the Gatsby `createRedirect` function. 102 | 103 | ### Image Fragments 104 | You can take advantage of the [Gatsby Image](https://www.gatsbyjs.org/packages/gatsby-image/) processing abilites by allowing Gatsby to download your images and progressively enhance them on the page. 105 | 106 | ```jsx 107 | import React from "react" 108 | import { graphql } from "gatsby" 109 | import Img from "gatsby-image" 110 | 111 | export default function BlogTemplate({ data }) { 112 | const page = data.wagtail.page 113 | return ( 114 |
115 |

page?.title

116 | 117 |
118 | ) 119 | } 120 | 121 | export const query = graphql` 122 | query BlogIndexQuery($slug: String) { 123 | wagtail { 124 | page(slug: $slug) { 125 | ...on BlogPage { 126 | title 127 | cover { 128 | imageFile { 129 | childImageSharp { 130 | square: fixed(width: 300, height: 300) { 131 | ...GatsbyImageSharpFixed 132 | } 133 | } 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | ` 141 | ``` 142 | 143 | `gatsby-transformer-sharp` and `gatsby-plugin-sharp` are required for local image processing. 144 | 145 | The following fragments work with `gatsby-source-wagtail`: 146 | * GatsbyImageSharpFixed 147 | * GatsbyImageSharpFixed_noBase64 148 | * GatsbyImageSharpFixed_tracedSVG 149 | * GatsbyImageSharpFixed_withWebp 150 | * GatsbyImageSharpFixed_withWebp_noBase64 151 | * GatsbyImageSharpFixed_withWebp_tracedSVG 152 | * GatsbyImageSharpFluid 153 | * GatsbyImageSharpFluid_noBase64 154 | * GatsbyImageSharpFluid_tracedSVG 155 | * GatsbyImageSharpFluid_withWebp 156 | * GatsbyImageSharpFluid_withWebp_noBase64 157 | * GatsbyImageSharpFluid_withWebp_tracedSVG 158 | * GatsbyImageSharpFluidLimitPresentationSize 159 | 160 | When previewing the page using Wagtail Preview, the image processing is mocked and the plugin will use the raw source files from your Wagtail's media host. It should, however, respect the image dimension constraints. 161 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-source-wagtail", 3 | "description": "Gatsby plugin which adds Wagtail Grapple support to Gatsby", 4 | "version": "0.5.1", 5 | "author": "NathHorrigan ", 6 | "bugs": { 7 | "url": "https://github.com/NathHorrigan/gatsby-source-wagtail/issues" 8 | }, 9 | "main": "index.js", 10 | "files": [ 11 | "*.js", 12 | "babel-plugin-remove-graphql-queries/*.js", 13 | "index.d.ts" 14 | ], 15 | "dependencies": { 16 | "@babel/runtime": "^7.12.1", 17 | "apollo-boost": "^0.4.9", 18 | "apollo-cache-inmemory": "^1.6.6", 19 | "apollo-client": "^2.6.10", 20 | "apollo-link-http": "^1.5.17", 21 | "apollo-link-ws": "^1.0.20", 22 | "apollo-utilities": "^1.3.4", 23 | "babel-plugin-remove-graphql-queries": "^2.9.20", 24 | "fs-extra": "^8.1.0", 25 | "gatsby": "^2.25.0", 26 | "gatsby-source-filesystem": "^2.4.0", 27 | "gatsby-source-graphql": "^2.7.6", 28 | "graphql": "^14.7.0", 29 | "graphql-2-json-schema": "^0.2.0", 30 | "graphql-request": "^3.3.0", 31 | "graphql-tag": "^2.11.0", 32 | "graphql-tools": "^3.1.1", 33 | "lodash": "^4.17.20", 34 | "lodash.clonedeep": "4.5.0", 35 | "lodash.get": "^4.4.2", 36 | "lodash.merge": "^4.6.2", 37 | "query-string": "^6.13.6", 38 | "react": "^16.14.0", 39 | "subscriptions-transport-ws": "^0.9.18", 40 | "traverse": "^0.6.6" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "^7.12.1", 44 | "@babel/core": "^7.12.3", 45 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 46 | "babel-preset-gatsby-package": "^0.1.4", 47 | "cross-env": "^5.2.1", 48 | "prettier": "2.0.5" 49 | }, 50 | "homepage": "https://github.com/NathHorrigan/gatsby-source-wagtail//#readme", 51 | "keywords": [ 52 | "gatsby", 53 | "gatsby-plugin", 54 | "graphql", 55 | "universal" 56 | ], 57 | "peerDependencies": { 58 | "gatsby": "^2.13.77" 59 | }, 60 | "resolutions": { 61 | "graphql": "14.5.0" 62 | }, 63 | "license": "MIT", 64 | "repository": "https://github.com/NathHorrigan/gatsby-source-wagtail/", 65 | "scripts": { 66 | "build": "babel src/ --out-dir . --ignore **/__tests__", 67 | "prepare": "cross-env NODE_ENV=production npm run build", 68 | "watch": "babel -w src --out-dir . --ignore **/__tests__", 69 | "format": "prettier --write src/**/*.js", 70 | "clean": "rm -rf ./*.js" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/babel-loader.js: -------------------------------------------------------------------------------- 1 | const babelLoader = require(`babel-loader`) 2 | 3 | const { 4 | getCustomOptions, 5 | mergeConfigItemOptions, 6 | } = require(`gatsby/dist/utils/babel-loader-helpers`) 7 | const { prepareOptions } = require(`./utils`); 8 | 9 | /** 10 | * Gatsby's custom loader for webpack & babel 11 | * 12 | * Gatsby allows sites to either use our Babel setup (the default) 13 | * or to add a .babelrc to take control. 14 | * 15 | * Our default setup is defined in the fallbackPlugins/fallbackPresets arrays 16 | * below. 17 | * 18 | * After using either the fallback or user supplied setup, we add on a handful 19 | * of required plugins and finally merge in any presets/plugins supplied 20 | * by Gatsby plugins. 21 | * 22 | * You can find documentation for the custom loader here: https://babeljs.io/docs/en/next/babel-core.html#loadpartialconfig 23 | */ 24 | module.exports = babelLoader.custom((babel) => { 25 | const toReturn = { 26 | // Passed the loader options. 27 | customOptions({ stage = `test`, reactRuntime = `classic`, ...options }) { 28 | return { 29 | custom: { 30 | stage, 31 | reactRuntime 32 | }, 33 | loader: { 34 | cacheDirectory: true, 35 | sourceType: `unambiguous`, 36 | ...getCustomOptions(stage), 37 | }, 38 | ...options 39 | } 40 | }, 41 | 42 | // Passed Babel's 'PartialConfig' object. 43 | config(partialConfig, { customOptions }) { 44 | let { options } = partialConfig 45 | const [ 46 | reduxPresets, 47 | reduxPlugins, 48 | requiredPresets, 49 | requiredPlugins, 50 | fallbackPresets, 51 | ] = prepareOptions(babel, customOptions) 52 | 53 | // If there is no filesystem babel config present, add our fallback 54 | // presets/plugins. 55 | if (!partialConfig.hasFilesystemConfig()) { 56 | options = { 57 | ...options, 58 | plugins: requiredPlugins, 59 | presets: [...fallbackPresets, ...requiredPresets], 60 | } 61 | } else { 62 | // With a babelrc present, only add our required plugins/presets 63 | options = { 64 | ...options, 65 | plugins: [...options.plugins, ...requiredPlugins], 66 | presets: [...options.presets, ...requiredPresets], 67 | } 68 | } 69 | 70 | // Merge in presets/plugins added from gatsby plugins. 71 | reduxPresets.forEach(preset => { 72 | options.presets = mergeConfigItemOptions({ 73 | items: options.presets, 74 | itemToMerge: preset, 75 | type: `preset`, 76 | babel, 77 | }) 78 | }) 79 | 80 | reduxPlugins.forEach(plugin => { 81 | options.plugins = mergeConfigItemOptions({ 82 | items: options.plugins, 83 | itemToMerge: plugin, 84 | type: `plugin`, 85 | babel, 86 | }) 87 | }) 88 | 89 | return options 90 | }, 91 | } 92 | 93 | return toReturn 94 | }) 95 | -------------------------------------------------------------------------------- /src/fragments.js: -------------------------------------------------------------------------------- 1 | exports.generateImageFragments = type => ` 2 | import { graphql } from 'gatsby' 3 | 4 | export const query = graphql\` 5 | 6 | fragment WagtailImageFixed on ${type} { 7 | base64 8 | width 9 | height 10 | src 11 | srcSet(sizes: [300, 400, 800, 1400]) 12 | } 13 | 14 | fragment WagtailImageFixed_tracedSVG on ${type} { 15 | width 16 | height 17 | src 18 | srcSet(sizes: [300, 400, 800, 1400]) 19 | tracedSVG 20 | } 21 | 22 | fragment WagtailImageFixed_noBase64 on ${type} { 23 | width 24 | height 25 | src 26 | srcSet(sizes: [300, 400, 800, 1400]) 27 | } 28 | 29 | fragment WagtailImageFluid on ${type} { 30 | base64 31 | aspectRatio 32 | src 33 | srcSet(sizes: [300, 400, 800, 1400]) 34 | sizes 35 | } 36 | 37 | fragment WagtailImageFluid_tracedSVG on ${type} { 38 | tracedSVG 39 | aspectRatio 40 | src 41 | srcSet(sizes: [300, 400, 800, 1400]) 42 | sizes 43 | } 44 | 45 | fragment WagtailImageFluid_noBase64 on ${type} { 46 | aspectRatio 47 | src 48 | srcSet(sizes: [300, 400, 800, 1400]) 49 | sizes 50 | } 51 | \` 52 | ` 53 | -------------------------------------------------------------------------------- /src/gatsby-node.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra') 2 | const request = require('graphql-request') 3 | const { createRemoteFileNode } = require('gatsby-source-filesystem') 4 | const { sourceNodes } = require('./graphql-nodes') 5 | const { getRootQuery } = require('./getRootQuery') 6 | const { generateImageFragments } = require('./fragments') 7 | const fetch = require(`node-fetch`) 8 | 9 | const queryBackend = (query, url, headers) => 10 | fetch(url, { 11 | method: 'POST', 12 | headers: { 'Content-Type': 'application/json', ...headers }, 13 | body: JSON.stringify({ 14 | variables: {}, 15 | query 16 | }) 17 | }).then(result => result.json()) 18 | 19 | // Monkeypatch options to allow default fieldName and typeName 20 | exports.onPreInit = ({}, options) => { 21 | options.fieldName = options.fieldName || 'wagtail' 22 | options.typeName = options.typeName || 'wagtail' 23 | } 24 | 25 | exports.sourceNodes = sourceNodes 26 | 27 | exports.onCreateWebpackConfig = ({ stage, actions, getConfig }) => { 28 | const config = getConfig() 29 | if (stage.indexOf('html') >= 0) { 30 | return 31 | } 32 | 33 | const replaceRule = ruleUse => { 34 | if ( 35 | ruleUse.loader && 36 | ruleUse.loader.indexOf(`gatsby/dist/utils/babel-loader.js`) >= 0 37 | ) { 38 | ruleUse.loader = require.resolve( 39 | `gatsby-source-wagtail/babel-loader.js` 40 | ) 41 | } 42 | } 43 | 44 | const traverseRule = rule => { 45 | if (rule.oneOf && Array.isArray(rule.oneOf)) { 46 | rule.oneOf.forEach(traverseRule) 47 | } 48 | 49 | if (rule.use) { 50 | if (Array.isArray(rule.use)) { 51 | rule.use.forEach(replaceRule) 52 | } else { 53 | replaceRule(rule.use) 54 | } 55 | } 56 | } 57 | config.module.rules.forEach(traverseRule) 58 | actions.replaceWebpackConfig(config) 59 | } 60 | 61 | exports.onPreExtractQueries = async ({ store, actions }, options) => { 62 | const { createRedirect } = actions 63 | return queryBackend( 64 | `{ 65 | imageType 66 | redirects { 67 | oldPath 68 | newUrl 69 | isPermanent 70 | } 71 | __schema { 72 | types { 73 | kind 74 | name 75 | fields { 76 | name 77 | } 78 | possibleTypes { 79 | name 80 | } 81 | } 82 | } 83 | }`, 84 | options.url, 85 | options.headers 86 | ).then(({ data }) => { 87 | // Check if fields added by wagtail-gatsby are visible 88 | const wagtailGatsbyInstalled = !!data.__schema.types 89 | .find(objectType => objectType.name == data.imageType) 90 | .fields.find(field => field.name == 'tracedSVG') 91 | 92 | // Build schema file for Apollo, here we're filtering out any type information unrelated to unions or interfaces 93 | const filteredData = data.__schema.types.filter( 94 | type => type.possibleTypes !== null 95 | ) 96 | data.__schema.types = filteredData 97 | fs.writeFile( 98 | './node_modules/gatsby-source-wagtail/fragmentTypes.json', 99 | JSON.stringify(data), 100 | err => { 101 | if (err) { 102 | console.error( 103 | 'Gatsby-source-wagtail: Error writing fragmentTypes file', 104 | err 105 | ) 106 | } 107 | } 108 | ) 109 | 110 | // Generate Image Fragments for the servers respective image model. 111 | const program = store.getState().program 112 | const fragments = wagtailGatsbyInstalled 113 | ? generateImageFragments(data.imageType) 114 | : '' 115 | fs.writeFile( 116 | `${program.directory}/.cache/fragments/gatsby-source-wagtail-fragments.js`, 117 | fragments, 118 | err => { 119 | if (err) console.error(err) 120 | } 121 | ) 122 | 123 | // Copy the boilerplate file and replace the placeholder with actual modal name 124 | fs.readFile( 125 | './node_modules/gatsby-source-wagtail/preview.boilerplate.js', 126 | (err, fileData) => { 127 | if (err) 128 | return console.error( 129 | 'Could not read preview boilerplate file', 130 | err 131 | ) 132 | // Replace placeholder 133 | let jsFile = fileData 134 | .toString() 135 | .replace('CustomImage', data.imageType) 136 | // Rewrite file so it's accessible 137 | fs.writeFile( 138 | `./node_modules/gatsby-source-wagtail/preview.js`, 139 | jsFile, 140 | err => { 141 | if (err) 142 | console.error('Could not write preview file', err) 143 | } 144 | ) 145 | } 146 | ) 147 | 148 | // Generate redirects for Netlify, controlled by Wagtail Admin. 149 | data.redirects.map(redirect => 150 | createRedirect({ 151 | fromPath: redirect.oldPath, 152 | toPath: redirect.newUrl, 153 | isPermanent: redirect.isPermanent, 154 | force: true 155 | }) 156 | ) 157 | }) 158 | } 159 | 160 | exports.createResolvers = ( 161 | { actions, getCache, createNodeId, createResolvers, store, reporter }, 162 | options 163 | ) => { 164 | const { createNode } = actions 165 | return queryBackend( 166 | `{ 167 | imageType 168 | }`, 169 | options.url, 170 | options.headers 171 | ).then(({ data }) => { 172 | createResolvers({ 173 | [data.imageType]: { 174 | imageFile: { 175 | type: `File`, 176 | resolve(source, args, context, info) { 177 | return createRemoteFileNode({ 178 | url: source.src, 179 | store, 180 | getCache, 181 | createNode, 182 | createNodeId 183 | }).catch(err => console.error(err)) 184 | } 185 | } 186 | } 187 | }) 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /src/gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | exports.onRenderBody = ({ setHeadComponents }, pluginOptions) => { 4 | const { 5 | typeName, 6 | fieldName, 7 | isDefault = true, 8 | url, 9 | websocketUrl = null, 10 | headers 11 | } = pluginOptions 12 | 13 | const connectionName = isDefault ? 'default' : fieldName 14 | 15 | setHeadComponents([ 16 |