A Stackoverflow Question & a Use Case for Gatsby's Field Extension
Last week I saw a pretty interesting question on Stackoverflow link (with highlight added):
I've followed Gatsby tutorial for 'Working With Images in Markdown Posts and Pages' which is working well but what I want to achieve is to fetch image from a static location instead of using a relative path for the image.
Would like to reference image like this (in frontmatter)
featuredImage: img/IMG_20190621_112048_2.jpg
Where
IMG_20190621_112048_2.jpg
is in/src/data/img
instead of same directory as markdown file under/src/posts
.
In summary, the asker has a site structure similar to this:
root
└── src
├── posts
| └── hello-world.md
└── data
└── img
└── photo.jpg
And in hello-world.md
, they want to link a frontmatter field to a file in data/img
without using a relative path such as ../../data/img/photo.jpg
.
Solutions
onCreateNode
I've done exactly this at least 3 times with the onCreateNode
hook in gatsby-node.js
docs:
Whenever Gatsby creates a MarkdownRemark node:
- Resolve the image's node path
- Get all nodes from the Gatsby node store via
getNodes
- Find the node with matching the absolute path
- Link that node to the Remark node by creating a new field with the kinda-secret suffix
___NODE
docs
After that, the resolved File node lives in a custom field since we can't directly mutate a node in this hook.
query Post {
markdownRemark {
frontmatter {
featuredImage # <--- Path string
}
fields {
featuredImageFile { # <--- Custom field
childImageSharp {
fluid {
...gatsbyImageSharpFluid
}
}
}
}
}
}
While this approach is suitable for a one-time job, I don't enjoy having to query the file node in a different field. Also, I think this approach hurts readability in onCreateNode
since we often have to do other customization or listen in for a different node type.
gatsby-remark-relative-images
gatsby-remark-relative-images
link can handle this for you neatly. On top of handling image paths inside markdown content, it exports a helpful fmImagesToRelative
function that converts every path in every frontmatter field to a relative path that Gatsby can understand. Because of that, it is an attractive solution if you want to use relative paths in both frontmatter & markdown note0.
Customize the Schema
We can also solve this by using the new-ish createSchemaCustomization
docs hook.
Customize Gatsby’s GraphQL schema by creating type definitions, field extensions, or adding third-party schemas.
Basically, in this hook, you can further process a field's value, change its type, or resolve it to something else completely. The detail docs is well-written & worths a read link. Let's see how one might resolve frontmatter.featuredImage
to a File node instead of a string using this hook.
There're two steps to this:
- Define the field type with
createTypes.
- Resolve to a File node with
createFieldExtension.
Change the field type
New types can be defined in the GraphQL Schema Definition Language (SDL) link, or Gatsby's type builder link. I'll use the SDL to ensure featuredImage
will be resolved to a File node:
type Frontmatter {
featuredImage: File
}
Since Gatsby needs to know which field to apply this new Frontmatter
type, I pass it to MarkdownRemark.
The @infer
flag should also be explicitly added to both of these types to enable strict inference docs.
- type Frontmatter {
+ type Frontmatter @infer {
featureImage: File
}
+ type MarkdownRemark implements Node @infer {
+ frontmatter: Frontmatter
+ }
Now the new definition is ready to be passed to createTypes
:
exports.createSchemaCustomization = ({ actions }) => {
const { createTypes } = actions
const typeDefs = `
type Frontmatter @infer {
featureImage: File
}
type MarkdownRemark implements Node @infer {
frontmatter: Frontmatter
}
`
createTypes(typeDefs)
}
Let's fire up the GraphiQL at localhost:8000/___graphql
and query the image:
Error: Cannot return null for non-nullable field File.id.
While Gatsby now resolves featuredImage
to a File node, it has no idea where that File is.
At this point, I can use the createResolvers
hook to resolve the field to a file node:
// gatsby-node.js
exports.createResolvers = ({ createResolvers }) => {
createResolvers({
Frontmatter: {
featuredImage: {
resolve: (source, args, context, info) => { ... }
}
}
})
}
This works, however, if I have another field that contains a file path, I'll have to write another resolver.
It turns out the best tool for this task is already given to us in createSchemaCustomization
: creating field extensions.
createFieldExtension
A field extension is a convenient way to write a generic resolver that can apply to any field.
Gatsby by default includes 4 really helpful extensions docs: @link
, @date
, @fileByRelativePath
note1 and @proxy
. You can read more about them in the linked docs.
The meat of building a field extension is the resolve
function, which is not too different than the one we'd use in createResolvers.
resolve(source, args, context, info)
params | description |
---|---|
source | The data of the parent field (in this case, frontmatter ) |
args | The arguments passed to this field (and the field extension!). We won't need this yet |
context | contains nodeModel, which we'll use to get nodes from Gatsby node store |
info | metadata about this field + the whole schema |
The attack plan is simple:
-
Create an absolute path to the file
- Find the original path (img/photo.jpg) from
source,
if that field exists - Glue it to src/data to get a complete absolute path.
- Find the original path (img/photo.jpg) from
-
Return the matching File node
- Query the
nodeModel
to find a File node with the matching absolute path note2 - Return the found File node; otherwise, return null.
- Query the
With that, here's what I have:
resolve: function (src, args, context, info) {
// look up original string, i.e img/photo.jpg
const { fieldName } = info
const partialPath = src[fieldName]
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
// check out the query object, it's the same as a regular query filter
const fileNode = 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
}
Finally, I pass this resolve function to createFieldExtension
and 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 an absolute path (from src/data/
) in markdown frontmatter.
Field extension is elegant because all the code responsible for modifying a single field type can be put in just one resolve function. In a way, it reminds me of React hook -- a messy logic implemented in one or two lifecycles can now live in a single reusable function.
Still, there's one more thing to do: The base path in my resolve function is hard-coded into the field extension. To make this snippet truly useful, I can make a plugin with this path configurable note3 in gatsby-config.js.
So that's what I've made: gatsby-schema-field-absolute-path
. Have a look!
Other field extensions you may find useful
- Converting markdown string to HTML. One of the examples in the Gatsby docs link does this, but I haven't seen it as a plugin.
- Specifying a default value for a nullable field.
- Converting a remote image to a local node for sharp processing with cache and everything
Tweet at me if you have other ideas.
That's all. Thank you for reading!
-
Thanks avianey on Stackoverflow link for pointing this out, I didn't know
↩gatsby-remark-relative-images
does this. If you're interested, I also wrote a remark plugin to handle any paths in markdown content here:gatsby-remark-images-anywhere.
-
↩@link
and@fileByRelativePath
is very close to our custom field extension here, but we need to be able to use an absolute path. If there were a way to compose field extension, that'd be so neat, i.e., make the absolute path and then pass it to@link(by: absolutePath).
-
↩gatsby-source-filesystem
has to know about our file. Add a new instance of it in the plugin list ingatsby-config
and point it to the target directory. -
Half-way through writing this post, I realized I could have just exported a generic extension
@fileByAbsolutePath
that accept path as an argument, i.e.type Frontmatter @infer { featureImage: File @fileByAbsolutePath(path: "src/data") }
10x simpler! The package has been updated to use this pattern.
↩