July 20, 2022
Today we will set up a new dev blog. We will build it with Gatsby.
The initial goals are just the basic functionality of an index page with links to individual posts, and some niceties for presenting a lot of code, like syntax highlighting and a copy-to-clipboard button. We will be storing our content in markdown files.
We could install gatbsy-cli
globally or we can run it with NPX, but either way, use the CLI to bootstrap the project:
npx gatsby new
Run the CLI and follow the prompts to choose your styling library, plugins, typescript or not, that sort of thing. This time we selected MDX support, responsive images, and React Helmet. For styling, we're going to use plain CSS modules.
The CLI will generate some files, and the first one to know about it is gatsby-config.js/ts
:
import type { GatsbyConfig } from 'gatsby'const config: GatsbyConfig = {siteMetadata: {title: `My New Dev Blog`,siteUrl: `http://my.blog.local`},graphqlTypegen: true,plugins: ['gatsby-plugin-image','gatsby-plugin-react-helmet','gatsby-plugin-mdx','gatsby-plugin-sharp','gatsby-transformer-sharp',{resolve: 'gatsby-source-filesystem',options: {name: 'images',path: './src/images/'},__key: 'images'},{resolve: 'gatsby-source-filesystem',options: {name: 'pages',path: './src/pages/'},__key: 'pages'},]}export default config
First, a few things for our initial setup:
gatsby-node.js
npm i esm
require = require('esm')(module)module.exports = require('./gatsby-node.esm.js')
const path = require('path')export onCreateWebpackConfig = ({ actions }) => {actions.setWebpackConfig({resolve: {alias: {'@components': path.resolve(__dirname, 'src', 'components')}}})}
{"compilerOptions": {"paths": {"@components/*": ["./src/components/*"]},...
If your setup is like mine and you're using Typescript + CSS modules, you'll probably also want a definitions file along these lines to prevent the compiler from finding fault with our CSS module imports:
declare module '*.css' {const content: { [className: string]: string }export = content}
Our posts (as in the mdx files) will be organized by date in subfolders. The mdx behind this post is at ./blog/2022/07/20/setting-up-with-gatsby/index.mdx
in the local filesystem, for example.
Our file system will also determine the URL of our post, so the way we organize our files is extremely important. This folder structure is what we want:
π¦blogβ π2022β β π07β β β π20β β β β πsetting-up-with-gatsbyβ β β β β πindex.mdxβ β β β β πmdx-graphql.pngβ β β π22β β β β πsetting-up-with-gatsby-iiβ β β β β πgithub-pages-settings.pngβ β β β β πindex.mdx
Make a folder called blog
in the project root. This is where we will keep the markdown files that comprise our collection of blog posts.
We tell Gatsby about this blog
folder by adding one more item at the bottom of the plugins
array in our gatsby-config
, just below the other gatsby-source-filesystem
definitions.
In effect, this makes the files in the blog
folder visible to our GraphQL queries:
plugins: [...{resolve: 'gatsby-source-filesystem',options: {name: 'blog',path: './blog/'},__key: 'blog'}]
Start the development server...
npm start> dev-blog@1.0.0 start> gatsby develop -o -p 8000
At localhost:8000/___graphql, you get the GraphQL query interface. Basically this is just a nice UI to query Gatsby's data layer, meaning the things you have configured Gatsby to be able to get data from (like our blog
folder). We use the UI to develop the queries we need to power our site.
For example, this query is basically what we will run to get the data to generate the index page. Try running this query while the blog
folder is still empty:
query BlogPosts {allMdx(sort: {order: DESC, fields: [frontmatter___date]},limit: 10) {edges {node {slugfrontmatter {date(formatString: "DD MMMM YYYY")title}wordCount {words}}}}}
What happens is that GraphQL will throw an error: Value "frontmatter___date" does not exist in "MdxFieldsEnum" enum
, because our query basically says ORDER BY frontmatter___date
, which doesn't exist in the GQL schema.
If we had any files with frontmatter___date
set, it would have been added to the schema dynamically. If not, GraphQL has no way of knowing that it exists.
However, if we really wanted our query to work in this situation and return an empty result set instead of an error, we could do something like this:
export createSchemaCustomization = ({ actions }) => {const { createTypes } = actionscreateTypes(`type Mdx implements Node {frontmatter: MdxFrontmatter}type MdxFrontmatter {date: Date @dateformat}`)}
This isn't necessary because we don't plan on having an empty blog or inconsistent metadata in our MDX files. It is mentioned just to clarify how GraphQL queries are working with frontmatter in Gatsby.
In any case, we need at least one MDX file with a date to exist in our blog folder before we can continue. Once we have that, our query should work:
Now, to create our index page, replace the contents of src/pages/index.jsx
file with the following:
import * as React from 'react'import { graphql, Link } from 'gatsby'const Blog = ({ data }) => {return (<>{data.allMdx.nodes.map((node) => (<article key={node.id}><h2><Link to={`/${node.slug}`}>{node.frontmatter.title}</Link></h2><p>{node.frontmatter.date}</p><p>{node.slug}</p></article>))}</>)}export const query = graphql`query {allMdx(sort: { fields: frontmatter___date, order: DESC }) {nodes {frontmatter {date(formatString: "MMMM D, YYYY")title}slugid}}}`export default Blog
As you have probably noticed, there is some kind of extra something going on here. It's true. Gatsby works a bit of magic, taking the results of the exported query
and then passing it into the component as a prop called data
.
It is a super-simple pattern where each page component has a query right alongside it, and the fact that GQL syntax resembles ES6 makes it even better, as the shape of the object we are deferencing in our component is made plainly evident just by glancing at the GQL query.
Create a blog post template:
import * as React from 'react'import { graphql } from 'gatsby'import { MDXRenderer } from 'gatsby-plugin-mdx'const BlogPost = ({ data }) => {return (<><p>{data.mdx.frontmatter.date}</p><MDXRenderer>{data.mdx.body}</MDXRenderer></>)}export const query = graphql`query ($id: String) {mdx(id: { eq: $id }) {frontmatter {titledate(formatString: "MMMM D, YYYY")}body}}`export default BlogPost
The {mdx.slug}
in the filename will be automatically expanded to the value returned by the query. For example: 2022/07/20/setting-up-with-gatsby/
Now, we should be able to click the links on the index page and be brought to a page with the full content of the post.
The main thing we are in need of is some kind of navigation control. Eventually we need a header, footer, and that sort of thing. Getting our content nicely centered would also be nice.
We will create a shared Layout component to address these things:
import React from 'react'import * as styles from './layout.module.css'const Layout = ({ children }) => (<div className={styles.pageContainer}><nav><ul className={styles.navLinks}><li className><Link to='/'>Home</Link></li></ul></nav><div className={styles.innerContainer}>{children}</div></div>)export default Layout
.page-container {margin: 0 auto;padding: 0;box-sizing: border-box;max-width: 860px;}.inner-container {margin-top: 7em;padding: 12px;}.nav-links {column-count: 2;column-gap: 85%;display: flex;list-style: none;padding-left: 0;}
Speaking of styles, let's also make a global CSS file for rules that need to apply everywhere:
body {margin: 0px;line-height: 1.6;text-rendering: optimizeLegibility;}
That needs to be referenced by a gatsby-browser file, so create one:
import './src/styles/global.css'
We could wrap each of our pages with our new layout component, like this:
import Layout from '@components/Layout'const BlogPost = ({ data }) => {return (<><Layout><p>{data.mdx.frontmatter.date}</p><MDXRenderer>{data.mdx.body}</MDXRenderer></Layout></>)}
import Layout from '@components/Layout'const Blog = ({ data }) => {return (<><Layout>{data.allMdx.nodes.map((node) => (<article key={node.id}><h2><Link to={`/${node.slug}`}>{node.frontmatter.title}</Link></h2><p>Posted: {node.frontmatter.date}</p><p>{node.slug}</p></article>))}</Layout></>)}
However, we want every page wrapped, so we can do this instead to acheive the same thing, and new pages we add will be wrapped by default:
Add the following to gatsby-browser.js
and export it from gatsby-ssr.js
:
import * as React from 'react'import Layout from '@components/Layout'import './src/styles/global.css'const wrapPageElement = ({ element, props }) => {return <Layout {...props}>{element}</Layout>}export { wrapPageElement }
export { wrapPageElement } from './gatsby-browser.js'
Storing image files directly alongside the markdown files is obviously ideal in our case.
We want to be able to reference images in our Markdown files like this:
## MDX β€οΈ
We need to make a change to the configuration for the gatsby-plugin-mdx
plugin due to the way our files are organized.
Replace the existing reference to 'gatsby-plugin-mdx'
with a new object definition, like this:
'gatsby-plugin-mdx',{resolve: `gatsby-plugin-mdx`,options: {gatsbyRemarkPlugins: [{resolve: `gatsby-remark-images`,}]}},
Upon restarting the development server, we can reference images as described and everything works.
As a bonus, we also now get a neat progressive loading effect, as well as responsive thumbnails.
Our use case involves the frequent usage of code blocks, so we might as well make them as easy to digest and as visually pleasing as possible. Prism.js gives us syntax highlighting in almost any language with fairly minimal effort. To get the basic functionality we need only do three things:
npm i prism-react-renderer
import React from 'react'import Highlight, { defaultProps } from 'prism-react-renderer'import theme from 'prism-react-renderer/themes/github'const Code = ({ codeString, language, ...props }) => {return (<Highlight{...defaultProps}code={codeString}language={language}theme={theme}>{({ className, style, tokens, getLineProps, getTokenProps }) => (<preclassName={className}style={{ ...style, padding: '20px', position: 'relative' }}>{tokens.map((line, i) => (<div key={i} {...getLineProps({ line, key: i })}>{line.map((token, key) => (<span key={key} {...getTokenProps({ token, key })} />))}</div>))}</pre>)}</Highlight>)}export default Code
import * as React from 'react'import { graphql } from 'gatsby'import { MDXRenderer } from 'gatsby-plugin-mdx'import { MDXProvider } from '@mdx-js/react'import Code from '@components/Code'import Layout from '@components/Layout'const BlogPost = ({ data }) => {return (<Layout><MDXProvider components={components}><p>{data.mdx.frontmatter.date}</p><MDXRenderer>{data.mdx.body}</MDXRenderer></MDXProvider></Layout>)}export const query = graphql`query ($id: String) {mdx(id: { eq: $id }) {frontmatter {titledate(formatString: "MMMM D, YYYY")}body}}`const components = {pre: preProps => {if (preProps.children?.props?.mdxType !== 'code') {return <pre {...preProps} />}const {children: codeString,className = '',...props} = preProps.children.propsconst match = className.match(/language-([\0-\uFFFF]*)/)return (<Code{...props}codeString={codeString.trim()}className={className}language={match != null ? match[1] : ''}/>)}}export default BlogPost
That's basically it. This looks pretty nice once it is working, but since presenting code is a fundamental part of a dev blog, we're going to want to improve some things. Line numbers, for example, are something we will want to add.
Given the endless possibilities we will have to have a post entirely dedicated to prism.js and advanced options for it. For now, we'll just be happy about how nice and colorful things already are.
If you've made it to this point, you should have a pretty spiffy looking setup coming together. As far as our initial list of desired features, the only thing we have yet to do is the Copy-to-Clipboard button. This is the last task of our initial setup. We need to change only one file to get this working. Add the following code to it:
import React from 'react'import Highlight, { defaultProps } from 'prism-react-renderer'import theme from 'prism-react-renderer/themes/github'import * as styles from './code.module.css'const CopyButton = props =><button className={styles.button} {...props} />const Code = ({ codeString, language, ...props }) => {const [isCopied, setIsCopied] = React.useState(false)return (<Highlight{...defaultProps}code={codeString}language={language}theme={theme}>{({ className, style, tokens, getLineProps, getTokenProps }) => (<preclassName={className}style={{ ...style, padding: '20px', position: 'relative' }}><CopyButtononClick={() => {navigator.clipboard.writeText(codeString)setIsCopied(true)setTimeout(() => setIsCopied(false), 4000)}}>{isCopied ? <i>Copied!</i> : 'Copy'}</CopyButton>{tokens.map((line, i) => (<div key={i} {...getLineProps({ line, key: i })}>{line.map((token, key) => (<span key={key} {...getTokenProps({ token, key })} />))}</div>))}</pre>)}</Highlight>)}export default Code
.button {position: absolute;top: 0;right: 0;margin: 8px;padding: 8px 12px;border-radius: 8px;border-width: 0;background: deepskyblue;}
We now drop markdown and image files into our blog folder and know that when our app gets built and deployed, our files will be processed into blazing fast pre-built static pages, looking good and ready to be served.
Markdown and React go good together. Gatsby's data layer is a perfect use case for GraphQL. These things are really meant for each other, and it is not surprising that this stack is a popular way to build websites.
For the second part of this post, we will concern ourselves with getting our new Gatsby blog deployed.