How to Build a GatsbyJS Guitar-Chords-MDX Theme From Scratch
In this tutorial, we're building a GatsbyJS theme that adds the ability to create live-editable chord diagrams in MDX posts on your website. If this sounds like something that you'd like to know more about, here's the count: one...two...three...four... let's get playing!
This post is heavily based on live-streams and tutorials from Jason Lengstorf, you can follow him on Twitter for more fun and educational web development content.
Hereâs what weâre going to build in this tutorial:
What should I have installed in order to follow along?
- A somewhat recent version of NodeJS
- Youâll need to install Yarn. See system-specific installation methods here
Initial setup
In order to follow along with this blog post, clone this starter repo from GitHub. It contains two branches, start and finish. Make sure that youâre on the start branch and youâre all set to go!
The starter repo contains some boilerplate Yarn workspaces setup, but if you want to go completely from scratch, I suggest you to start with the Yarn Workspaces Setup For GatsbyJS Theme Development.
A little intro
If youâre new to Gatsby themes you might be wondering, âWhat the heck is a theme and why should I care?â
Well, a theme is a composable Gatsby config. đ - âYep, that made everything so clear, master explainerâ.
Alright, Iâll try harder. You see, unlike well-known WordPress themes, a Gatsby theme can contain not only the presentation and styles but also functionality. So, theoretically, you can package up the whole site and then publish it as a Gatsby theme package.
The cool thing about themes is that you can compose them horizontally. Itâs possible to install an e-commerce theme that powers your shopping cart and then install a blog theme that powers your blog, and then install some other custom theme that cooks an omelet for you in the mornings. đ
You can install all these themes as child themes, or they can be composed into a single theme that uses three of them and overlays styling on top.
So, for the scope of this tutorial, our plan will be to create a theme where we can live-edit chord names and immediately see the preview of how to play them on the guitar neck.
This theme will also allow us to create mdx
pages with chord diagrams really fast without any additional setup on the site where the theme will be installed.
Pretty cool, right?
And thatâs just the icing on the top of the cake. Interested now? Ok, letâs write some code.
Pre-Bootstrap setup
When weâre developing a theme, this theme can use some data. For example, a theme can look for a folder with Markdown files and then show them on the page. So, what we need to do here is to make sure that any folders that weâre going to use exist.
But first, weâre going to install one very useful library called mkdirp thatâll allow us to recursively create directories. Weâll need this library later.
In our root folder that we got after cloning the repo gatsby-theme-guitar-chords-tutorial
, run the following command:
yarn workspace gatsby-theme-guitar-chords add mkdirp
Default theme options
Once weâve installed that, weâll need to make a utility function that will give us default options for the theme.
In our theme folder gatsby-theme-guitar-chords
create the directory utils
and inside of it create a file default-theme-options.js
with the following:
module.exports = ({ basePath = '/', contentPath = 'content', useExternalMDX = false}) => ({basePath, contentPath, useExternalMDX});
Here weâre exporting a function that returns the default options for our theme.
basePath
- URL, this means that weâll be creating pages at the root of our sitecontentPath
- this is where our content will liveuseExternalMDX
- by default weâll assume that the site is not using MDX, and weâll need to install it
Creating the content folder
Next, in our theme folder gatsby-theme-guitar-chords
create a file gatsby-node.js
with the following contents:
const path = require('path');const fs = require('fs');const mkdirp = require('mkdirp');const withDefaults = require('./utils/default-theme-options');
exports.onPreBootstrap = ({ store }, options) => { const { program } = store.getState() const { contentPath } = withDefaults(options) const dir = path.join(program.directory, contentPath) if (!fs.existsSync(dir)){ mkdirp.sync(dir); }}
Using that file, we can hook up into Gatsby lifecycle methods during the build and do a lot of interesting things. Letâs look at what we have here.
At the top, weâre importing necessary built-in Node libraries and our default theme options.
Following that weâre using Gatsby API hook called onPreBootstrap
to check if the content directory exists and if not weâll create it. This will help us to avoid the filesystem plugin to fail because of a missing directory.
Note: onPreBootstrap API hook is called once Gatsby has initialized itself and is ready to bootstrap your site.
store
gives us access to the information about the running program and the second argument there is the theme options. Out of the store we get a program
which gives us consistent information about our app no matter in what environment we are.
Weâre taking options from the user and extracting the one piece that weâre going to use.
Next, we need to find where weâre located and append a content path. To do this weâre using a built-in Node modules fs
and mkdirp
that we installed earlier. The mkdirp
library allows recursive creation of subdirectories.
So, basically, at this step weâve created our folder on the file system where our Markdown files should exist.
To test that everything works, letâs start the site in develop mode.
yarn workspace dev-site-for-theme develop
After doing that you should see that our content
folder was created.
Loading MDX files
So, the next thing that weâre going to do is to actually load our mdx
files.
Note: MDX is an authorable format that lets you seamlessly write JSX in your Markdown documents. You can import components, such as interactive charts or alerts, and embed them within your content. MDX is basically a Markdown with JSX.
In our content
folder create an index.mdx
file with the following content:
---title: This is our home page---Welcome to our newly created home page!
The area marked with triple dashes is called a Frontmatter, and itâs the place where we can put some meta-information about our page. We write frontmatter using YAML syntax.
Below frontmatter we have a body of the page and upon saving that we have a completely valid mdx
file that we can use however we want.
Adding source plugins
To load this file in our theme weâll need to install a couple of plugins.
yarn workspace gatsby-theme-guitar-chords add gatsby-source-filesystem gatsby-plugin-mdx @mdx-js/mdx @mdx-js/react
Note: gatsby-source-filesystem allows us to use a folder in the project as a data source.
- gatsby-source-filesystem will grab files from the specified directory and put them into the GraphQL layer so that we can use them.
- gatsby-plugin-mdx is going to convert all
.mdx
files into GraphQL nodes, which means that weâll be able to get their content.
How this works:
gatsby-source-filesystem
createsfile
nodes. After that,gatsby-plugin-mdx
creates childmdx
nodes out of them, but we still have access to the parentfile
nodes.
The rest of the plugins that we installed is just a dependencies needed for gatsby-plugin-mdx
.
Setting up configs
Now that weâve installed necessary plugins we need to get them configured in our gatsby-config.js
for the theme.
In our gatsby-theme-guitar-chords
folder, next to gatsby-node.js
file create a gatsby-config.js
file.
const withDefaults = require('./utils/default-theme-options')
module.exports = options => { const { contentPath, useExternalMDX } = withDefaults(options)
return { plugins: [ { resolve: 'gatsby-source-filesystem', options: { name: 'gatsby-theme-guitar-chords', path: contentPath } }, !useExternalMDX && { resolve: 'gatsby-plugin-mdx', options: { defaultLayouts: { default: require.resolve('./src/components/layout.js'),
} } } ].filter(Boolean) }}
At the top, weâre importing our default options.
Usually, configs are written as simple objects, but since weâre in a theme, it becomes a function that receives those default options.
Next, weâre using contentPath
again, because we need to show the filesystem module where to look for the files.
useExternalMDX
is telling us whether or not to load our mdx
plugin in the theme.
After that, we return the plugins
array. In Gatsby, if a plugin has options we need to specify this plugin as an object with the resolve
and options
keys, instead of a simple string.
For gatsby-source-filesystem
plugin in the options, we use the name
key to be able to identify which files were loaded by our theme. We donât want to do anything with all other files that may be used by some other theme and grab files strictly from our specified content path.
So, this will give us files that we want in our file system from the specified path.
For gatsby-plugin-mdx
plugin if the boolean useExternalMDX
is set to true
, weâre going to just skip ahead. Otherwise, we specify the default layout which weâll create down below.
Next, weâre wrapping our mdx
files in this wrapper component that weâll create in the specified folder.
And, finally, weâre filtering our plugins array so that it drops any false values.
Ok, now in our theme create the layout file with the following path: /src/components/layout.js
import React from 'react'
const Layout = ({ children }) => ( <> <header>Gatsby-theme-guitar-chords</header> <main>{children}</main> </>)
export default Layout
Here weâre importing React, but weâve never installed it in our theme. Thatâs because it will be available from our development site.
Following that, weâre creating a Layout
component that receives children as the content of markdown files.
Then weâre basically returning our theme name and children wrapped in a header
and main
tags.
Now itâs time to check that everything is ok.
yarn workspace dev-site-for-theme develop
If everything went well, you should see a 404 development page as before. Itâs not currently showing anything because weâve only added the information from our file to the GraphQL layer.
Now we can explore all available data in the GraphQL explorer. Navigate to the http://localhost:8000/___graphql
in your browser and youâll see some interesting things available for querying. We can now query AllDirectory
, AllFile
, AllMdx
and other things.
Just to try things out, select AllMdx/nodes/frontmatter/title
and AllMdx/nodes/rawBody
and click the play button at the top - youâll see the result of the query in the right pane.
Schema customization
To be able to easily get things like title, body out of our mdx
, we need to use Schema customization API.
In our themeâs gatsby-node.js
file at the bottom add the following:
exports.createSchemaCustomization = ({ node, actions, getNode, createNodeId }, options) => { actions.createTypes(` type ChordsPage implements Node @dontInfer { id: ID! title: String! path: String! updated: Date! @dateformat body: String! } `)}
Here, we use createSchemaCustomization
API, which gives us a lot of data:
- node - itâs the actual node that we then can extend and modify as we wish
- actions - object of available actions that we can take
- getNode - a helper that gets a particular node
- createNodeId - a helper to create a node id
The only action that weâll need is the createTypes
action. Here weâre basically defining that some new entity will exist in Gatsbyâs GraphQL layer.
So, weâre creating a new type called ChordsPage
and we want it to be queryable, so we need it to implement a Node
type, which is a core type in Gatsby. By defining that something is a node weâre getting some very useful stuff like querying, filtering and other stuff.
By adding @dontInfer
weâre telling it not to add any other fields except the fields that we specified.
Next, weâre adding fields to our type: id
, title
, path
, updated
and body
. They have types of ID
, String
, Date
and the exclamation point means that theyâre required.
To properly format the date we use a @dateformat
helper.
After that, start the server again and in GraphQL explorer youâll see that we now can query chords pages. And if we look inside of it weâll see all our fields available for querying.
Of course thereâs no any data if we try to run queries on these fields, but Gatsby now knows that this type exists and this is exactly what we wanted.
Creating ChordsPage nodes
Once weâve defined a type, we now need to actually create the ChordsPage nodes. Weâll do it by catching every mdx
node that gets created and creating a ChordsPage
node out of it.
For this, weâll use another API hook called onCreateNode
. In our themeâs gatsby-node.js
file at the bottom add the following:
exports.onCreateNode = ({ node, actions, getNode, createNodeId}, options) => { const { basePath } = withDefaults(options); const parent = getNode(node.parent); if ( node.internal.type !== 'Mdx' || parent.sourceInstanceName !== 'gatsby-theme-guitar-chords' ) { return }
const pageName = parent.name != 'index' ? parent.name : '';
actions.createNode({ id: createNodeId(`ChordsPage-${node.id}`), title: node.frontmatter.title || parent.name, updated: parent.modifiedTime, path: urlResolve(basePath, parent.relativeDirectory, pageName), parent: node.id, internal: { type: 'ChordsPage', contentDigest: node.internal.contentDigest, } })}
Here we first get our basePath
to build the pages later and then weâre catching a parent
node.
As mentioned earlier, gatsby-source-filesystem
creates file
nodes. After that, gatsby-plugin-mdx
creates child mdx
nodes out of them. When weâre catching those mdx
nodes we still have access to the parent file
node out of which we can get the name (we set this name in the gatsby-source-filesystem
plugin options).
Weâre doing all this because we want to make sure that we work only with the files that were loaded by our theme.
Next, weâre checking if the node type is not mdx
or the name is not gatsby-theme-chords
, and if itâs true, weâre making an early return.
After that, weâre setting the index page behavior by ignoring the file name if weâre on the index page.
Next, weâre actually creating a node using createNode
action. This action accepts a new node object. For this object we need to provide:
- id - this is just a unique id for our node. We can make it even more unique by prefixing it with the type name.
- title - we take out title here from the frontmatter with the fallback to the parent name.
- updated - this one we can take straight from the parent.
- path - here weâre stitching together base path, directory path from the parent and the page name with the help of
urlResolve
helper fromgatsby-core-utils
(donât forget to add itâs import at the top of the file).
const { urlResolve } = require(`gatsby-core-utils`)
Another variant here would be to use path.join
function, but when using that I faced the fact that URLs stop working if youâre on Windows. Ohhh.. OS inconsistenciesâŠ
- parent - we set
node.id
of themdx
node as the parent of ourChordsPage
node so we can reference it later in our resolver. - internal - every Gatsby node needs to have this
internal
property.- type - weâre going to set up the
ChordPage
for type here. This can be anything, it just needs to be unique. - contentDigest - after that weâre specifying the
contentDigest
which is like a hash of the node content to help Gatsby decide whether or not it should rebuild that node. Here we can take ourcontentDigest
straight frommdx
node.
- type - weâre going to set up the
Finding the body
You probably noticed that weâre not including body
here. Thatâs because not all of the node types fully resolve by the time we get to onCreateNode
. That creates a challenge for us.
If we want to get the mdx
body we canât just call it here because itâs not set yet.
This means that we have to create a custom resolver
. At the bottom of our gatsby-node.js
file add the following:
exports.createResolvers = ({ createResolvers }) => { createResolvers({ ChordsPage: { body: { type: 'String!', resolve: (source, args, context, info) => { const type = info.schema.getType('Mdx'); const mdxFields = type.getFields(); const resolver = mdxFields.body.resolve;
const mdxNode = context.nodeModel.getNodeById({ id: source.parent});
return resolver(mdxNode, args, context, { fieldName: 'body', });
} } } })}
Here weâre using the createResolvers
hook, which gives us a function called createResolvers
, where we get into a ChordsPage
type and set up a body
resolver.
For our body resolver, we need to specify the type, which as weâve declared earlier is a String
, and a resolver function.
The resolver function gets 4 arguments:
- source - the node itself
- args - arguments passed to that node
- context - some information about the request or the node
- info - general information about the GraphQL schema
So, here weâve created a pass-through resolver by first, pulling a type out of Gatsby schema definition for mdx
nodes, then loading all of the fields from that type, and finally getting the resolver for our body field.
The request to our ChordsPage
body type will behave in the same way as the request for the mdx
node request to the body type (thatâs why weâre calling it pass-through resolver).
Once we have the resolver, we get the mdx
node as the parent of our ChordsPage
node and, finally, resolve our body
field.
Now, effectively weâre getting all data (mdx, frontmatter, file system) all in one simple node API.
Since weâve modified the way that nodes work, we need to run the clean command:
yarn workspace dev-site-for-theme gatsby clean
And after that, start our development server again with the new GraphQL layer in place.
yarn workspace dev-site-for-theme develop
Now weâve got custom data that we need showing up in GraphQL explorer:
Defining page templates
Whenever we create our page programmatically in Gatsby, itâs a good idea to put the page template in a separate directory.
In our themeâs folder gatsby-theme-guitar-chords
create a page template: gatsby-theme-guitar-chords/src/templates/chords-page-template.js
with the following content:
import React from 'react'import { graphql } from 'gatsby'import ChordsPage from '../components/chords-page'
export const query = graphql` query ($pageID: String!) { chordsPage(id: {eq: $pageID}) { title updated(fromNow: true) body } }`
const ChordsPageTemplate = ({ data }) => <ChordsPage page={data.chordsPage} />
export default ChordsPageTemplate;
This page template is going to load the data via GraphQL query and pass it to a component, which is going to display it. We want to be sure that weâre separating sourcing of the data from the display of the data here, thatâs why weâll have 2 separate files.
So, here weâre exporting a page query, which loads the page by id that gets passed to it from the context in the createPage
API (weâll look at that later).
Note: This query runs only as the part of the page creation process by Gatsby in the
createPages
API hook.
During the build, Gatsby looks at all exported queries, executes them, gets the data and then injects that data as the data
prop into the component. Thatâs why we now have the ability to pass this data to the component that will display it. Letâs create that component now.
Create a file gatsby-theme-chords-page/src/components/chords-page.js
with the following:
import React from 'react';import { MDXRenderer } from 'gatsby-plugin-mdx';import Layout from './layout';
const ChordsPage = ({ page }) => ( <Layout> <h1>{page.title}</h1> <MDXRenderer>{page.body}</MDXRenderer> <p>This page was updated {page.updated}</p> </Layout>)
export default ChordsPage
Here weâre importing an MDXRenderer
that is used to render mdx
code into a readable format. Weâre creating a ChordsPage
component that uses our layout and renders our data on the page.
Using page templates to create pages
Now that weâve defined our templates, itâs time to actually use them. In our themeâs gatsby-node.js
file at the bottom add the following:
exports.createPages = async ({ actions, graphql, reporter }) => { const result = await graphql(` query{ allChordsPage{ nodes{ id path } } } `)
if( result.errors){ reporter.panic('error loading chords', result.errors); }
const pages = result.data.allChordsPage.nodes;
pages.forEach(page => { actions.createPage({ path: page.path, component: require.resolve('./src/templates/chords-page-template.js'), context: { pageID: page.id, } }) })}
Weâre using Gatsbyâs workhorse API called createPages
. Since weâre requesting data here, we need to mark our createPages
function as async
.
CreatePages API gives us:
- actions - an object that contains a number of useful helper functions
- graphql - a function to get some data
- reporter - a reporter function to log things to the console
So, here we start by requesting the id
, which we need to pass the context, and a path
, which weâll use as a definition of a URL.
Note: In the
createPages
API graphql is a function and not a template tag. You should keep that in mind.
Following that, we do a quick check for errors and if there are any, we log them to the console.
Next, weâre taking our pages, looping through them and for each page weâre running an action called createPage
, which accepts an object with fields:
- path - our path that weâve defined before
- component - the template that weâve defined earlier
- context - an arbitrary object where we pass our page
id
. This will become available as a GraphQL variable in the template
Now if we restart the development server, weâll finally see the contents of our page on the screen.
Setting up syntax highlighting
Alright, letâs set up syntax highlighting in our mdx
files. For this weâll need to install a couple more dependencies:
yarn workspace gatsby-theme-guitar-chords add prism-react-renderer mdx-utils
Next, in our theme create a component gatsby-theme-guitar-chords/src/components/code.js
with the following:
import React from 'react'import { preToCodeBlock } from 'mdx-utils';import Highlight, { defaultProps } from 'prism-react-renderer';import theme from 'prism-react-renderer/themes/nightOwl';
const Code = props => { const codeProps = preToCodeBlock(props);
if(!codeProps) { return <pre {...props} /> }
const {codeString, language} = codeProps;
return ( <Highlight {...defaultProps} code={codeString} language={language} theme={theme} > {({ className, style, tokens, getLineProps, getTokenProps}) => ( <pre className={className} style={style} > {tokens.map((line, i) => ( <div {...getLineProps({line, key: i})}> {line.map((token, key) => ( <span { ...getTokenProps({token, key})} /> ))} </div> ))} </pre> )} </Highlight> ) }
export default Code
After importing all necessary modules, weâre creating a component called Code
.
In this component, weâre using preToCodeBlock
that will allow us to get props out of <pre>
element, such as the language, code string, etc.
If we didnât get any props, that says to us that this code block isnât highlightable and we simply return <pre>
tag with props.
If we do have code props, we pull them out, return a Highlight
component and pass it some default props - codeString
and language
.
The Highlight
component uses RenderProps
pattern, which means that we get a function and inside weâre basically letting the highlighter do its job. It takes the code block, breaks it up into different tokens and does all bunch of stuff. Iâm not going to go into details here since itâs a bit out of the scope for this tutorial.
Components override
Next, we need to specify which component will be used to override specific HTML elements that Markdown compiles to. In out themeâs layout.js
file import our Code
component and MDXProvider
, then wrap our app in it like so:
import React from 'react'import { MDXProvider } from '@mdx-js/react'import Code from '../components/code'
const components = { pre: Code}
const Layout = ({ children }) => ( <> <MDXProvider components={components}> <header>Gatsby-theme-guitar-chords</header> <main>{children}</main> </MDXProvider> </>)
export default Layout
The components
object is a mapping between the HTML name and the desired component weâd like to render.
Now we can try how it works. Open our index.mdx
file and add the following code block at the bottom there:
```javascriptimport Code from '../components/code'
export default Code.``````this is a text without code highlighting```
Once you start your development server again, youâll see that the first block is now highlighted and the second one is not. This is just what we want! Great.
And finally, here comes the most interesting part of this tutorial - making live editable highlighted code block.
Setting up a live editable preview
This will allow us to type the chord name and see the fingering diagram preview. This is a really good way to try out and experiment with different types of chords. For this, weâre going to be using react-live and react-guitar-chord. Go ahead and install them:
yarn workspace gatsby-theme-guitar-chords add react-live react-guitar-chord
Once that installed, open our code.js
file and add the selected code there:
import React from 'react'import { preToCodeBlock } from 'mdx-utils';import Highlight, { defaultProps } from 'prism-react-renderer';import theme from 'prism-react-renderer/themes/nightOwl';import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live';import scope from '../scope';
const Code = props => { const codeProps = preToCodeBlock(props);
if(!codeProps) { return <pre {...props} /> }
const {codeString, language} = codeProps;
return codeProps['react-live'] ? ( <LiveProvider code={codeString} scope={scope} theme={theme}> <LiveEditor /> <LiveError /> <LivePreview /> </LiveProvider> ) : ( <Highlight {...defaultProps} code={codeString} language={language} theme={theme} > {({ className, style, tokens, getLineProps, getTokenProps}) => ( <pre className={className} style={style} > {tokens.map((line, i) => ( <div {...getLineProps({line, key: i})}> {line.map((token, key) => ( <span { ...getTokenProps({token, key})} /> ))} </div> ))} </pre> )} </Highlight> ) }
export default Code
Alright, a couple of words about how this works. react-live
takes our code and transpiles it with Bublé. The transpiled code is then rendered in the preview component LivePreview
.
Weâve added a check whether or not our field has an attribute react-live
and depending on the result show or donât show the LivePreview
area.
So, basically we have 3 possible output variants here:
- if we donât specify any language itâll show a regular
<pre>
tag - if we donât mark it as live editable, itâll output highlighted code using
prism-react-renderer
- if it is marked with
react-live
itâll show aLiveProvider
You may notice that at the top weâre importing scope
file. Thatâs because react-live
will allow us to use different components in the scope. That way we donât have to import them anytime that we use them.
Since this is a Gatsby theme, weâre going to need to create a scope
file at the src root. In that way, itâll be easy to override.
Create a file gatsby-theme-guitar-chords/src/scope.js
with the following content:
import GuitarChord from 'react-guitar-chord'
export default { GuitarChord};
Ok, itâs time to add a live-editable chord block. In our index.mdx
file at the bottom add the following:
```jsx react-live() => ( <div> <h2>A chord diagram!</h2> <GuitarChord chord={'C'} /> </div>)```
Just as an example here weâre exporting a simple component, which has a <GuitarChord/>
component in it. Now if we restart our development server again, we should see not only a syntax-highlighted code block but a working live preview.
Go ahead and try to edit the name of the chord to something different, D
for example, or add another chord with different quality:
```jsx react-live() => ( <div> <h2>A chord diagram!</h2> <GuitarChord chord={'C'} /> <GuitarChord chord={'D'} quality={'MIN'} /> </div>)```
Final words
So, here we go, weâve got ourselves working, live-editable chord diagrams with previews. Isnât it great? And because weâve created it as a theme, itâs going to work when you install this on some other Gatsby site. Anyone who wants to check this theme out will be able to install it and learn different guitar chords on the /chords
page of their site.
Of course, there are some things that can be done to make this theme even better, like adding Theme UI support and making our theme look prettier, but, I guess weâll save that for another blog post since this one is already pretty long, so stay tuned!
Thanks for reading and I hope you enjoyed it! Iâll go grab myself some apple juice. See yall in my next blog post.
Cheers!