These days, it's common for open source libraries to publish documentation for multiple versions of their software. That's what we do at Apollo, and many other popular tools do something similar:
It's helpful to provide your users a way to access docs for older versions in case they haven't upgraded to your newest version yet, or if they're stuck on an older version because migrating would be too impractical.
In this post, I'll explain the docs versioning strategy that we use at Apollo. It's important to note that we use Gatsby to build our docs from a set of MDX files that are located in the same repo as the code that they're documenting.
Below is an example of our directory structure. The library source code lives in the root-level src
directory, and docs
is a Gatsby website powered by MDX files in a content
directory.
src/├─ index.tsdocs/├─ content/│ ├─ index.mdx│ ├─ getting-started.mdx│ ├─ api-reference.mdx├─ gatsby-config.js├─ package.jsonpackage.json
Content for the current version is sourced from local files using gatsby-source-filesystem
and transformed into MDX nodes using gatsby-plugin-mdx
. I'll refer to this version as the local version in this post. I like to use the version from the package.json
at the root of the repo as the name
of my content source. This will come in handy later when we're rendering the version dropdown.
const { version } = require("../package.json");module.exports = {plugins: ["gatsby-plugin-mdx",{resolve: "gatsby-source-filesystem",options: {name: version,path: "content",},},],};
Using Gatsby's createPages
API, we query for these MDX nodes and generate a static page for each one using a template file written in React. Gatsby has a great tutorial that goes into more detail about templates and how to create pages in this manner.
exports.createPages = async ({ actions, graphql }) => {const { data } = await graphql(`query ListPages {allMdx {nodes {idslug}}}`);data.allMdx.nodes.forEach((node) => {actions.createPage({component: require.resolve("./src/templates/page"),path: "/" + node.slug,context: {id: node.id,},});});};
At Apollo, we maintain different versions on named git branches. The docs for those versions are also located in the same repo, usually in the same directory structure as the local version. We bring those docs into our main Gatsby site using gatsby-source-git
. I'll refer to these as remote versions in this post. This source plugin allows us to specify a git URL and branch name to source files from and incorporate them into our local Gatsby graph to query later using GraphQL.
npm install gatsby-source-git
You can configure multiple instances of gatsby-source-git
for each remote version you want to document. The name
option should be the label you wish to display in the version dropdown that I'll talk about later in this post.
💡 Did you know?
Check out the docs for
gatsby-source-git
to learn about all of the available configuration options.
module.exports = {plugins: [// ...other plugins configured above{resolve: "gatsby-source-git",options: {name: "1.10.x", // the name that will be used in the version dropdownremote: "https://github.com/your-username/your-repo",branch: "v1", // the branch where your older version is maintained// only source from the directory where your docs content livespatterns: "docs/content/**",},},],};
This plugin will look for files that match the glob pattern specified in the patterns
option and create File
nodes in your Gatsby graph. It will also add a gitRemote
field to those File
nodes to help you differentiate between local files and remote ones.
The ListPages
query can be updated to include this field by accessing the parent
union field on Mdx
nodes and asking for the gitRemote
field within it. The ref
field is the name of the git branch that the file was sourced from.
💡 Did you know?
A union is a type in GraphQL that can contain the fields of one or more types. The
... on Type
syntax must be used to access fields on a union.
query ListPages {allMdx {nodes {idslugparent {... on File {gitRemote {ref}}}}}}
With this information, we set the path
of each page to include the branch name in the URL. Local version docs will begin with /
while pages for remote versions will begin with /branch-name/
, or /v1/
in this example.
I use path.join
to help with this. If one of the path segments passed to join
is an empty string, it will be omitted from the computed path. This way, if gitRemote
is null there will be no path prefix added.
const path = require("path");exports.createPages = async ({ actions, graphql }) => {// query for MDX filesdata.allMdx.nodes.forEach((node) => {const { gitRemote } = node.parent;actions.createPage({component: require.resolve("./src/templates/page"),// prefix older version docs pages with their branch namepath: path.join("/", gitRemote?.ref || "", node.slug),context: {id: node.id,},});});};
This method works, however, the node.slug
property for pages sourced from git include the full directory structure in them, e.g., docs/content/getting-started
. For this strategy to work properly, the content directory must be stripped out.
Conveniently, this directory was supplied to gatsby-source-git
earlier in the form of a glob pattern. Using micromatch
, you can turn a glob pattern into a regular expression using their makeRe
function. The function takes a capture
option that turns the wildcard parts of a glob into regex capture groups.
const { makeRe } = require("micromatch");makeRe(glob, { capture: true }); // regex with capture group for wildcard (**)
Using this regular expression, we can remove the content directory from the slug by replacing any instance of the regex with only the value of the first capture group.
node.slug.replace(regex, "$1");
To find the pattern that pertains to a given node, we must first create a mapping of branch names to their configured pattern. First, I modify the ListPages
query to include plugin options for every instance of gatsby-source-git
in my Gatsby config.
query ListPages {allMdx {# selection of mdx fields as above}allSitePlugin(filter: {name: {eq: "gatsby-source-git"}}) {nodes {pluginOptions {patternsbranch}}}}
Then I use Array.reduce
to map branches to their respective regular expressions with makeRe
:
const versionPatterns = data.allSitePlugin.nodes.reduce((acc, node) => {const { patterns, branch } = node.pluginOptions;return {...acc,[branch]: makeRe(patterns, { capture: true }),};}, {});
Lastly, I modify my path logic to prefix paths for nodes with a gitRemote
by stripping out the content directory from the node.slug
:
data.allMdx.nodes.forEach((node) => {const { gitRemote } = node.parent;actions.createPage({component: require.resolve("./src/templates/page"),path: gitRemote? path.join("/",// add the branch name as a path segmentgitRemote.ref,// replace the slug with the value from the first capture groupnode.slug.replace(versionPatterns[gitRemote.ref], "$1")): "/" + node.slug, // create local paths normallycontext: {id: node.id,},});});
Now that our pages are created, we need a way to navigate between versions. A common way that docs do this is by using a dropdown menu with the different versions (including the local one) as options.
I use a Gatsby static query to fetch the name of the local version, as configured by the name
option that was supplied to gatsby-source-filesystem
, as well as the source name and branch name of each remote version.
Then, I render an HTML select
element with the local version as the first option, followed by the remote versions. Each option's value
prop should be the branch name so that when an option is selected, the onChange
function can navigate the user to the right page using event.target.value
. Remember that we prefixed remote version pages with the gitRemote.ref
property. ☝️
Finally, we rely on the parent of the version dropdown to pass a currentRef
prop to the component. If currentRef
is undefined, it falls back to an empty string—the value of the option
element for the local version. This prop is then passed along as the value
of the select so that the currently active version appears as the selected option.
import { graphql, navigate, useStaticQuery } from "gatsby";export default function VersionDropdown({ currentRef = "" }) {const { sitePlugin, allGitRemote } = useStaticQuery(graphql`query ListVersions {# get local version name from filesystem source pluginsitePlugin(name: { eq: "gatsby-source-filesystem" }) {pluginOptions {name}}# list all remote versions configured by gatsby-source-gitallGitRemote {nodes {idrefsourceInstanceName}}}`);return (<selectvalue={currentRef}// client side route change when an option is selectedonChange={(event) => navigate("/" + event.target.value)}><option value="">{/* the local version */}{sitePlugin.pluginOptions.name}</option>{data.allGitRemote.nodes.map((node) => (// remote versions sourced via git<option key={node.id} value={node.ref}>{node.sourceInstanceName}</option>))}</select>);}
You may remember that we specified a template file as the component
option of the createPage
function in the page creation loop. The role of the page template is to receive some identifying information about the page to be rendered and fetch the necessary information to render it.
This information is passed to the template via the context
option of createPage
. It's then made available as a pageContext
prop on the template component, and as variables in Gatsby page queries within the file. In this example, I use the $id
GraphQL variable to query for the MDX node in a page query.
I set the currentRef
on the version dropdown to parent.gitRemote?.ref
using optional chaining. If the current page belongs to the local version, the currentRef
will be undefined
, causing the default value (an empty string) to be used within the version dropdown.
💡 Did you know?
Optional chaining (
?.
) allows you to read an object's property without checking to see if the object exists. If the object isnull
orundefined
, the expression short-circuits with a return value ofundefined
.
import VersionDropdown from "../components/VersionDropdown";import { MdxRenderer } from "gatsby-plugin-mdx";import { graphql } from "gatsby";export default function PageTemplate({ data }) {const { body, parent } = data.mdx;return (<><VersionDropdown// pass ref of current page to the version dropdowncurrentRef={parent.gitRemote?.ref}/>{/* render MDX content */}<MdxRenderer>{body}</MdxRenderer></>);}export const pageQuery = graphql`query GetPage($id: String!) {mdx(id: { eq: $id }) {bodyparent {... on File {gitRemote {ref}}}}}`;
Now I've got a docs site that comfortably supports multiple versions, but there's one thing left to do. I noticed that links between pages within a remote version were going to the wrong places. Normally, I would write my links as absolute paths, i.e., [click here](/getting-started)
. However, if I used that path within a page living at /v1
, it wouldn't take the user to /v1/getting-started
as I would have hoped. Instead, they would go to /getting-started
at the site root. Understandable. 🤷
I decided to try using relative paths, i.e., ./getting-started
, but Gatsby treated them the same as the absolute ones. I needed to find a way to support relative links so that docs authors wouldn't need to consider the prefix that is being applied to page slugs automatically during the build process.
To accomplish this, I leaned on another great feature of MDX: component substitution. I created a custom RelativeLink
component that behaves differently depending on what the href
prop looks like.
import { createContext, useContext } from "react";import { isAbsolute, resolve } from "path-browserify";import { Link } from "gatsby";export const PathContext = createContext();export default function RelativeLink({ href, ...props }) {const path = useContext(PathContext);try {const url = new URL(href);return <a href={url.toString()} {...props} />;} catch (error) {return (<Link to={isAbsolute(href) ? href : resolve(path, href)} {...props} />);}}
A few things are going on in this component:
href
is a URL and render a normal anchor if soLink
component for instant client-side page transitionshref
is an absolute path, pass it to the to
prop of the Link
componenthref
using the path of the current page as the baseThe Node.js path
core module contains isAbsolute
and resolve
functions that help express this logic. Webpack <= 4 ships with polyfills for many core modules like path
, but webpack 5 stopped doing this. Since Gatsby 3 uses webpack 5, I had to import these functions from path-browserify
, a port of the path
module that works in the browser.
To take advantage of this custom link component in my MDX pages, I first import the PathContext
from the link component file and pass the template's path
prop as the value
of PathContext.Provider
.
Next, I need to substitute anchor tags in my page with the RelativeLink
component we created above. The MDXProvider
component from @mdx-js/react
allows us to do just that by using its components
prop. Simply pass an object that maps HTML elements to the React component you want to use to render them.
💡 Did you know?
For more information about component substitution in MDX, check out their docs on the subject.
import RelativeLink, { PathContext } from "../components/RelativeLink";import VersionDropdown from "../components/VersionDropdown";import { MDXProvider } from "@mdx-js/react";import { MDXRenderer } from "gatsby-plugin-mdx";const components = {// substitute anchors with a custom link componenta: RelativeLink,};export default function PageTemplate({ data, path }) {const { body, parent } = data.mdx;return (<><VersionDropdown currentRef={parent.gitRemote?.ref} />{/* provide the current path to the MDX content */}<PathContext.Provider value={path}><MDXProvider components={components}><MDXRenderer>{body}</MDXRenderer></MDXProvider></PathContext.Provider></>);}// page query same as before
And that's versioned documentation with Gatsby in a nutshell! 🥥 I hope you found this guide informative, and maybe even inspired to support multiple versions in your docs.
I put together an example repo to demonstrate how the techniques discussed in this post work when used together. You can also check out this hosted example to see these techniques in action.