Achieving this in Gatsby used to be pretty troublesome, but thanks to the new createSchemaCustomization
Node API docs (since Gatsby 2.5) it's relatively easy.
Here's a demo where I replicate your repo structure: github
Here's where the relevant code lives: github
Here's the code to make it work:
// gatsby-node.js
const path = require('path')
exports.createSchemaCustomization = ({ actions }) => {
const { createFieldExtension, createTypes } = actions
createFieldExtension({
name: 'fileByDataPath',
extend: () => ({
resolve: function (src, args, context, info) {
const partialPath = src.featureImage
if (!partialPath) {
return null
}
const filePath = path.join(__dirname, 'src/data', partialPath)
const fileNode = context.nodeModel.runQuery({
firstOnly: true,
type: 'File',
query: {
filter: {
absolutePath: {
eq: filePath
}
}
}
})
if (!fileNode) {
return null
}
return fileNode
}
})
})
const typeDefs = `
type Frontmatter @infer {
featureImage: File @fileByDataPath
}
type MarkdownRemark implements Node @infer {
frontmatter: Frontmatter
}
`
createTypes(typeDefs)
}
How it works:
There are 2 parts to this:
- Extend
markdownRemark.frontmatter.featureImage
so graphql resolves to a File node instead of a string via createTypes
- Create a new field extension
@fileByDataPath
via createFieldExtension
createTypes
Right now Gatsby's inferring frontmatter.featureImage
as a string. We'll ask Gatsby to read featureImage as a string instead, by modifying its parent type:
type Frontmatter {
featureImage: File
}
This is not enough however, we'll also need to pass this Frontmatter
type to its parent as well:
type Frontmatter {
featureImage: File
}
type MarkdownRemark implements Node {
frontmatter: Frontmatter
}
We'll also add the @infer
tag, which lets Gatsby know that it can infer other fields of these types, i.e frontmatter.title
, markdownRemark.html
, etc.
Then pass these custom type to createTypes
:
exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions
const typeDefs = `
type Frontmatter @infer {
featureImage: File
}
type MarkdownRemark implements Node @infer {
frontmatter: Frontmatter
}
`
createTypes(typeDefs)
}
Now, we can fire up localhost:8000/___graphql
and try to query the image
query Post {
markdownRemark {
frontmatter {
featureImage {
id
}
}
}
}
and we get...
Error: Cannot return null for non-nullable field File.id.
That is because while Gatsby now understands featureImage
should be a File node, it has no idea where to get that file.
At this point, we can either use createResolvers
to manually resolve the field to a File node, or createFileExtension
to do the same thing. I choose createFileExtension
because it allows more code reuse (you can extend any fields), while createResolvers
, in this case, is more useful for a specific field. Seeing that all you want is to resolve a file from the src/data
directory, I'll call this extension fieldByDataPath
.
createFileExtension
Let's just look at the resolve attribute. It is a function that takes in the following:
- source: The data of the parent field (in this case,
frontmatter
)
- args: The arguments passed to
featureImage
in a query. We won't need this
- context: contains
nodeModel
, which we'll use to get nodes from Gatsby node store
- info: metadata about this field + the whole schema
We will find the original path (img/photo.jpg
) from src.featureImage
, then glue it to src/data
to get a complete absolute path. Next, we query the nodeModel to find a File node with the matching absolute path. Since you have already pointed gatsby-source-filesystem
to src/data
, the image (photo.jpg) will be in Gatsby node store.
In case we can't find a path or a matching node, return null
.
resolve: async function (src, args, context) {
// look up original string, i.e img/photo.jpg
const partialPath = src.featureImage
if (!partialPath) {
return null
}
// get the absolute path of the image file in the filesystem
const filePath = path.join(__dirname, 'src/data', partialPath)
// look for a node with matching path
const fileNode = await context.nodeModel.runQuery({
firstOnly: true,
type: 'File',
query: {
filter: {
absolutePath: {
eq: filePath
}
}
}
})
// no node? return
if (!fileNode) {
return null
}
// else return the node
return fileNode
}
We've done 99% of the work. The last thing to do is to move this to pass this resolve function to createFieldExtension
; as well as add the new extension to createTypes
createFieldExtension({
name: 'fileByDataPath' // we'll use it in createTypes as `@fileByDataPath`
extend: () => ({
resolve, // the resolve function above
})
})
const typeDef = `
type Frontmatter @infer {
featureImage: File @fileByDataPath // <---
}
...
`
With that, you can now use relative path from src/data/
in frontmatter.
Extra
The way fileByDataPath
implemented, it'll only work with fields named featureImage
. That's not too useful, so we should modify it so that it'll work on any field that, say, whose name ended in _data
; or at the very least accept a list of field names to work on.
Edit had a bit of time on my hand, so I wrote a plugin that does this & also wrote a blog on it.
Edit 2 Gatsby has since made runQuery
asynchronous (Jul 2020), updated the answer to reflect this.