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.
  • 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.

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!


  1. 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.

  2. @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).

  3. gatsby-source-filesystem has to know about our file. Add a new instance of it in the plugin list in gatsby-config and point it to the target directory.

  4. 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.