Versioned Docs with mdBook
Versioned Docs with mdBook
If you’ve ever worked on an open-source library that’s used by other people’s code, you know how important documentation is. But deploying and organizing it can be challenging and expensive, especially with commercial solutions like GitBook.
The Rust project launched mdBook a few years ago to solve the problem of deployment: it allows you to build an entirely self-contained ’book’ for your project in Markdown, which can then be served as a set of static files wherever you like!
In this post, I’ll outline how to use mdBook to create documentation for a project, and how to set up multiple versions of that documentation, including a next
version that can be constantly worked on. By the end, we’ll have a fully functional documentation system that can be deployed to GitHub Pages for free!
Setup
To begin, you’ll want to create a docs
directory at the root of your project. This is where we’ll put everything to do with the book. You’ll also want to install mdBook itself, which can be done easily if you have Rust installed already, just run cargo install mdbook
, or otherwise you can check out their installation guide here.
This tutorial will also cover automating many common tasks with documentation management using a tool called Bonnie (full disclosure: I’m the maintainer), which you can install with cargo install bonnie
, or by looking through it’s installation guide.
Creating a structure
What we’re building is a versioned book, meaning that it will have a separate book for each version of your project. In beta, these should correspond to minor version (e.g. 0.1.x, 0.2.x, etc.), and in stable these should correspond to major versions (e.g. 1.x.x, 2.x.x, etc.).
To achieve this, we’ll need a separate mdBook for each version, and an additional one called next
, which will house the documentation for the next version, which allows you to work on documentation dynamically, without having to add it all at the end for fear of disrupting existing users.
To begin, we’ll create a .gitignore
under docs/
and put the following inside:
book dist
This tells Git to ignore directories with the name book/
or dist/
, wherever under docs/
they might occur.
Then, create the following directories:
common
– stores files common to all versions, which will be kept in sync with symlinksnext
– stores documentation for the next version to be releasedsrc
- A directory for each version of your project thus far (e.g.
0.1.x
,1.x.x
,2.x.x
, etc.)src
Writing a book
Now it’s time to create some actual documentation! In the src
directory for each version, you can write documentation in Markdown, and you can create a sidebar in the specially named src/SUMMARY.md
file.
But, before you can serve your book, you’ll need to create a book.toml
to define its options. However, this will be the same for every version, so we’ll store it once in common/
and symlink it to each version.
Create common/book.toml
with the following content:
[book] authors = ["your name here"] language = "en" multilingual = false src = "src" title = "Name of Your Book" [rust] edition = "2018"
This sets some metadata (including disabling internationalization, which you can read more about in mdBook’s documentation), and also sets the version of Rust to use for your book’s integrated playground as the 2018 version of Rust. mdBook comes with a Rust interpreter built-in, which essentially adds a play button to any snippets of Rust code you declare in your book (you can stop a snippet from being played by setting the language to rust,no_playground,no_run
instead of just rust
). If you’re not using Rust, this part is irrelevant to you.
Now we need to make sure that every version of our book has access to that common book.toml
file, which we can do with symlinks (references to another file’s location on Unix systems, like Linux and MacOS). Run the following command for each of your versions, as well as next
(while in docs/
):
ln -s ../common/book.toml [VERSION-HERE-OR-next]/book.toml
If you’re unfamiliar with symlinks, that first argument is relative to the location of the second, which allows the OS to find the file without an absolute path.
Now that we’ve done that, you should be able to cd
into any of your versions and run mdbook serve
to preview that version of your book in your browser at http://localhost:3000!
Adding warnings
All this is well and good, but there will only be one version of your documentation that is stable at any one time, all others will be outdated, and next
will be unpublished. Users may well accidentally navigate to the wrong version, and we should display warnings to make sure they know where they are!
Fortunately, mdBook makes this process very easy by allowing us to modify the internals of our book under the theme/
directory. We’ll start off by doing so for the next
version.
Start off by creating a new directory theme/
under docs/next/
, and create a new file called header.hbs
. This is a Handlebars file, which is like HTML with bonuses, and it’s what mdBook uses internally. The contents of this file will be appended to the top of every page of your book, which means it’s the perfect place to define a warning for users that alerts them that they may be looking at the wrong version of your documentation.
Inside header.hbs
, put the following code:
<style> header.warning { background-color: rgb(242, 222, 222); border-bottom-color: rgb(238, 211, 215); border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; border-bottom-style: solid; border-bottom-width: 0.666667px; border-image-outset: 0 0 0 0; border-image-repeat: stretch stretch; border-image-slice: 100% 100% 100% 100%; border-image-source: none; border-image-width: 1 1 1 1; border-left-color: rgb(238, 211, 215); border-left-style: solid; border-left-width: 0.666667px; border-right-color: rgb(238, 211, 215); border-right-style: solid; border-right-width: 0.666667px; border-top-color: rgb(238, 211, 215); border-top-left-radius: 4px; border-top-right-radius: 4px; border-top-style: solid; border-top-width: 0.666667px; color: rgb(185, 74, 72); padding-bottom: 8px; padding-left: 14px; padding-right: 35px; padding-top: 8px; text-align: center; margin-bottom: 0px; margin-left: 0px; margin-right: 0px; margin-top: 30px; } </style> <header class="warning"> This documentation is for the <strong>unpublished</strong> next version of Perseus, and features documented here may not yet be available in the existing releases. <br> You can find documentation for the most recently released version of Perseus <a href="https://arctic-hen7.github.io/perseus/stable.html">here</a>. </header>
Most of this is copied from Perseus, in which I have this documentation setup deployed.
Now create another file called common/header_old.hbs
, and put the same content in there, but with different text that tells the user that version is outdated. Now, we’ll symlink that into every outdated version with these commands (run once for each old version):
ln -s ../../common/index.hbs [VERSION]/theme/index.hbs ln -s ../../common/header_old.hbs [VERSION]/theme/header.hbs
Unfortunately, at the time of writing, there’s an open issue on mdBook here whereby that warning will be half invisible, so we’ll also need to modify the body of the book to fix that. This is a pretty easy fix, but it does involve a very large amount of copy-pasta. Note that this file will be the same for every version with a warning (so next
and all the old versions), so we can create this as common/index.hbs
. Then, put the code in this Gist in there (all I’ve done is switched lines 117 and 118 so the header is below the menu bar).
Now, we’ll link that common file to all the versions that need a warning with this command (run once for each version):
ln -s ../common/index.hbs [VERSION-HERE-OR-next]/theme/index.hbs
Now, if you run mdbook serve
in any of your versions with a warning, you should see it at the top of the page!
Making the stable.html
alias
You’ve probably noticed that, in the example warning I provided before, I included a link to the latest stable version of the documentation. If you have a ton of old versions, you don’t want to be updated this constantly, hence, we can use a stable.html
file to redirect the user to the latest stable version, and, when we push a new version, we only have to change one thing!
This process is very simple, just create a stable.html
file at the root of docs/
, and add the following:
<!-- This file redirects the user to the latest stable version. --> <!DOCTYPE html> <html> <head> <meta http-equiv="refresh" content="0; url = https://arctic-hen7.github.io/perseus/0.1.x" /> </head> </html>
This document has no <body>
, it literally just redirects the user. Of course, replace https://arctic-hen7.github.io/perseus
with your own link. When you push a new version, you can update this by changing 0.1.x
to 0.2.x
or the like!
Building everything
Now that we’ve got versioning set up, it’s time to build the book into one single directory of static files that we can serve, for example on GitHub Pages. We’ll do this with a simple Bash script, which we’ll put at docs/build.sh
.
In that file, put the following:
#!/bin/bash mkdir -p dist rm -rf ./dist/* # Build ~next~ cd next mdbook build -d ../dist/ cd ../ # Loop through all directories that aren't ~common~ or ~next~ (or ~dist~ of course) for d in */ ; do if \[\[ d == "next/" || $d == "dist/" \]\]; then continue else cd $d mdbook build -d ../dist/$d cd ../ fi done # Copy in the redirection for the latest stable version cp stable.html dist/stable.html
Here’s what this code does (note that it should be executed in docs/
):
- Make sure the
dist/
directory exists and that it’s empty. - Build the
next
version to the root of that directory. This means that users will, by default, be served with the latest version of your documentation, which avoids problems of initial redirection. This is also common practice with most OSS projects. - Loop through all directories in
docs/
that aren’tnext
,common
, ordist
(the directories for each version), and build them todist/[VERSION]
. - Copy
stable.html
into the root ofdist/
.
You can run this script with this command (we don’t need to make it executable):
bash build.sh
After that, open dist/index.html
in your browser, and you should see the next
version of your documentation, with a link to the latest stable version! If you go to 0.1.x
or some other old version, you should see a warning saying it’s outdated and a link back to the latest stable version!
Automating with Bonnie
This process of updating your documentation for a new version can be tedious, but it can be automated easily with a tool I built a little while ago called Bonnie. If you haven’t installed this yet (cargo install bonnie
or see the installation docs), do so now.
First off, a code dump for your bonnie.toml
(this should be in the root of your project, outside docs/
).
version="0.3.2" [scripts] docs.cmd = [ "cd docs/next", "mdbook serve" ] docs.desc = "hosts the latest version of the book locally at http://localhost:3000" docs.subcommands.version.cmd = [ "cd docs/%version", "mdbook serve" ] docs.subcommands.version.args = [ "version" ] docs.subcommands.version.desc = "hosts the given version of the book locally at http://localhost:3000" docs.subcommands.deprecate.cmd = [ "cd docs/%version", "mkdir theme", "ln -s ../../common/index.hbs theme/index.hbs", "ln -s ../../common/header_old.hbs theme/header.hbs" ] docs.subcommands.deprecate.args = [ "version" ] docs.subcommands.deprecate.desc = "marks the given version of the docs as old and links to the latest" docs.subcommands.create.cmd = [ "mkdir docs/%version", "cd docs/%version", "ln -s ../common/book.toml book.toml", "cp -r ../next/src src", "cd ../", "sed -i -E 's/perseus\\/(.+)\"/perseus\\/0.2.x\"/' stable.html" ] docs.subcommands.create.args = [ "version" ] docs.subcommands.create.desc = "creates documentation for a new version from ~next~ and marks it as stable (doesn't deprecate old versions though)" docs.subcommands.build.cmd = [ "cd docs", "bash ./build.sh" ] docs.subcommands.build.desc = "builds the book for deployment to GitHub pages or the like"
First, this declares the version of Bonnie we’re using (v0.3.2 at time of writing), which you can get by running bonnie -v
in your terminal. Then, we define the following commands (you can see details by typing bonnie help
in the root of your project):
docs
– hosts thenext
version of your docs at http://localhost:3000docs version <version>
– hosts version<version>
of your docs at http://localhost:3000docs deprecate <version>
– adds an outdated banner to<version>
docs create <version>
– creates a new version of your docs fromnext
and marks it as stabledocs build
– runsdocs/build.sh
and builds your docs todocs/dist/
The only thing we need to change here is in the last line of docs.subcommands.create.cmd
, which is a sed
substitution that replaces the current stable version with the new one in docs/stable.html
. Replace perseus
in this with whatever comes directly before the version of your docs. For example, if your docs for 1.x.x
are hosted at example.com/docs/1.x.x
, this would be docs
. If they were directly at example.com
, this would be com
. You can use this tool to make sure that substitution works.
After you’ve done that, you can now control your documentation with ease!
GitHub Pages and Actions
Now it’s time to deploy your book to the internet! We’ll do this on GitHub Pages, a free service operated by GitHub that lets you create a website associated with your user account (mine is https://arctic-hen7.github.io, which is what you’re looking at right now!).
We can use another service called GitHub Actions to automatically build your book and deploy it whenever you push to your repository, which is free for public repositories, and paid for private (with a small free tier). I’ll assume here that you’ve already set up a Pages site for your account, but if not, there’s a great tutorial here by GitHub themselves.
Now, to create a GitHub Actions workflow, create a new file at the root of your repository/project called .github/workflows/book.yml
and put the following inside:
name: Compile and Deploy Book on: push: paths: - "docs/**" - ".github/workflows/book.yml" # If we change this build script, it should rerun jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup mdBook uses: peaceiris/actions-mdbook@v1 with: mdbook-version: "latest" - name: Build book run: bash build.sh working-directory: docs - name: Deploy book to GitHub Pages uses: peaceiris/actions-gh-pages@v3 if: github.ref == 'refs/heads/main' with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: docs/dist
This creates a new workflow called Compile and Deploy Book
that runs whenever you push to GitHub and there’s a change to either the docs/
folder or this script itself. Then, it runs a single job called deploy
, which will run on Ubuntu (in the cloud). Here’s what that does:
- Installs mdBook using this action.
- Builds your book using
docs/build.sh
. - Deploys the
docs/dist
to a newgh-pages
branch on your repository with this action.
Now try pushing to your repository on Github as usual (git push origin main
or something similar), and go to the Actions
tab of your repository on GitHub. You should see a new job running, which should complete pretty quickly (installing mdBook will be the longest-running part), and then you’ll be able to see a new gh-pages
branch in the Code
tab! Now, this won’t have deployed to Pages just yet, we’ll need to configure that under the Settings
tab. Go there, and then go to Pages
in the sidebar, and set the branch to gh-pages
and press Save! You should see a green message at the top of that page when you reload that has a URL to your newly deployed book!
Closing Words
In this tutorial, we’ve built a fully versioned and automated documentation system for your project, which can be deployed for free to the wider internet!
If you’re stuck on any part of this, you can see the entire setup in my Perseus project (under the v0.2.x
tag), which uses this setup verbatim.