Tools

Easy multi-language, multi-version documentation with Docsify, Git, and GitHub Actions

If you’re interested in translating or adapting this post, please.

Last year, I discussed documentation engines and the reasons why Docsify became my number one choice. Since then, more Evil Martians’ OSS projects have adopted the same approach (imgproxy, Clowne), and some of my projects reached new major versions. This post will show how to deal with multiple versions and translations of your docs and present a Docsify plugin for navigating complex documentation.

Read the “Keeping OSS documentation in check with docsify, Lefthook, and friends” post in our blog.

Writing documentation is an essential part of open source activities. The better the documentation, the easier it is for users to start working with your library or project. But what is the definition of “better”? Let me narrow it down to just two key metrics:

  • The number of issues asking “How to do X?” or simply saying “It doesn’t work, I don’t know why.”
  • The number of people given up on using your projects because it has no clear quick start instructions.

You need to keep these numbers as low as possible. How to do that? I don’t have the right answer but let me share a few points:

  • Update documentation to reflect solved issues from the project repository.
  • Use different forms of representation: instructions, tutorials, screencasts, examples.
  • Keep the information in sync with the code (outdated documentation is worse than no documentation).
  • Provide translations for at least “Getting started” guides (you might be surprised, but not everyone speaks English).

Today, I want to talk about the last two solutions: keeping docs in sync and building multi-language docs.

Umbrella documentation

In the previous post, I stated that one of the properties of maintainable documentation is being as close to code as possible. That solves the issue of keeping the information up-to-date: every pull request includes the corresponding documentation update. That’s it.

However, I deviated from this principle in AnyCable documentation. The project consists of multiple connected libraries/repositories. So, I decided to keep the documentation for all of them in a separate repo.

That made introducing changes and new features a more cumbersome: open PR in one place, then in another, don’t forget to merge the latter one.

I’ve started to think about a way to keep the docs along with the code (i.e., in multiple repos) and have an umbrella documentation project to combine them: every repo has its docs/ folder, and there is one additional repo to serve all the files via GitHub Pages.

My first thought was to leverage the power of CI (GitHub Actions in my case) to automatically pull library-specific updates to the documentation repo and update the website. I’ve been pondering this idea for a long time, looking at how others do similar things (e.g., dry-rb folks), and never had enough time to start working on this. I needed a simpler way of solving this problem. And recently I found it!

Once upon a time, while digging through the Docsify documentation, I noticed an interesting configuration option: alias.

The example from the documentation provides a very interesting use-case:

window.$docsify = {
  alias: {
    "/changelog":
      "https://raw.githubusercontent.com/docsifyjs/docsify/master/CHANGELOG",
  },
};

Wait, what? I can load pages from other GitHub repos?! No building, no deploying, no CI magic?! That was like a bolt from the blue.

I immediately moved library-specific docs back to their repositories and added a few lines of configuration for Docsify:

window.$docsify = {
  alias: {
    "/anycable-go/(.*)":
      "https://raw.githubusercontent.com/anycable/anycable-go/master/docs/$1",
    "/ruby/(.*)":
      "https://raw.githubusercontent.com/anycable/anycable/master/docs/$1",
    "/rails/(.*)":
      "https://raw.githubusercontent.com/anycable/anycable-rails/master/docs/$1",
  },
};

The only downside of this approach is that it requires some effort to make sure that all cross-project links are correct. But with the help of link checkers like liche this is not a big deal.

Overall, that was an easy win. I started exploring other opportunities for this tiny configuration option.

Versioning documentation

Docsify doesn’t provide any versioning tools out of the box. That wasn’t a problem for me last year: none of my projects with dedicated documentation websites had reached the first major version, so it was okay to document only the latest release.

While working on AnyCable 1.0 release, I wanted to ensure that the current users can still find the relevant information.

I “solved” this problem in a straightforward way: copied the old docs, put them into the v06 folder, moved the existing docs into the v1 folder. Thus, I just started maintaining two Docsify websites from a single repo.

Although this solution worked, it looked to me like a dirty hack, a technical debt with an inadequately high interest rate. And I found a way to pay it with the above-mentioned alias option.

We already have a versioning mechanism—our Git repository. Why not use it for documentation as well?

We can fetch documents not only from master, but from any Git branch, tag, or commit. I created a specific branch (1-0-stable) for each repo to hold the latest release in 1.0.x series and configured Docsify to serve /v1 from it:

alias: {
  // Explicitly specify the sidebar location. That allows avoiding unnecessary attempts to fetch
  // a relative sidebar in case you only have a top-level one.
  '/v1/.*_sidebar.md': 'https://raw.githubusercontent.com/anycable/docs.anycable.io/1-0-stable/docs/_sidebar.md',
  '/.*/_sidebar.md': '/_sidebar.md',
  '/anycable-go/(.*)': 'https://raw.githubusercontent.com/anycable/anycable-go/master/docs/$1',
  '/ruby/(.*)': 'https://raw.githubusercontent.com/anycable/anycable/master/docs/$1',
  '/rails/(.*)': 'https://raw.githubusercontent.com/anycable/anycable-rails/master/docs/$1',
  '/v1/anycable-go/(.*)': 'https://raw.githubusercontent.com/anycable/anycable-go/1-0-stable/docs/$1',
  '/v1/ruby/(.*)': 'https://raw.githubusercontent.com/anycable/anycable/1-0-stable/docs/$1',
  '/v1/rails/(.*)': 'https://raw.githubusercontent.com/anycable/anycable-rails/1-0-stable/docs/$1',
  '/v1/(.*)': 'https://raw.githubusercontent.com/anycable/docs.anycable.io/1-0-stable/docs/$1',
}

To ensure that the stable branches are up-to-date, I added a tiny GitHub Action workflow to update the branch every time a new matching tag is pushed:

name: Push Stable

on:
  push:
    tags:
      - v1.0.*

jobs:
  push-1-0-stable:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          persist-credentials: false
          fetch-depth: 0
      - uses: ad-m/github-push-action@v0.6.0
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branch: 1-0-stable

We configured the routing. Now we need a way for users to switch between versions.

I decided to go with a plain old <select> tag and a bit of JavaScript. We can use the name option to add a custom HTML to the sidebar header and write a custom plugin to implement interactive features:

window.$docsify = {
  // ...
  name:
    'div class="sidebar-logo">' +
    '  <span class="sidebar-logo-title">AnyCable</span>' +
    "</div>" +
    '<select id="version-selector" name="version" class="sidebar-version-select">' +
    '<option value="">v1.0-edge</value>' +
    '<option value="v1">v1.0</value>' +
    '<option value="v06">v0.6</value>' +
    "</select>",
  // ...
  plugins: [
    function (hook, vm) {
      hook.mounted(function () {
        Docsify.dom.find("#version-selector", function (e) {
          // I'm using the "hash" router mode
          window.location.hash = e.target.value;
        });
      });
    },
  ],
};

Another problem I encountered is compatibility with the searching plugin: I want to preserve the current version in the search results. To handle this, I proposed a new option, pathNamespaces, to the plugin. It has been merged and become available in the latest releases.

Unfortunately, such a simple solution doesn’t cover all the scenarios. For example, we need to handle direct links and update the select tag value correspondingly. Also, our sidebar links need to “know” about the current namespace: I don’t want to change the contents of _sidebar.md to reflect the documentation routing. The dependency needs to go one way. Our sidebar from the 1-0-stable branch has the same links for similar articles as the one from the master branch.

To make Docsify work seamlessly with alias-based namespacing, I’ve built a plugin called docsify-namespaced. It takes care of select input states, sidebar links, and more:

window.$docsify = {
  // ...
  namespaces: [
    {
      id: "version",
      values: ["v1", "v06"],
      default: "v1",
      optional: true,
      selector: "#version-selector",
    },
  ],
  // ...
};

In the example above, we also configure Docsify to serve /v1 by default (i.e., when a user navigates directly to the website home page). I’m serving edge docs from the root namespace, and specific versions of docs are namespaced to make URLs more descriptive.

As you might notice, the namespaces option accepts an array of namespaces, i.e., there could be more than one dimension of your documentation. For example, when we also have translations.

Dealing with translations

TestProf documentation is now fully translated into Chinese and available here.

Recently, a Chinese developer, Yuan, started translating Evil Martians blog posts to Chinese. While working on TestProf articles, he noticed that it would be great to translate the documentation and offered his help. So, I needed to figure out how to make it easy to add translations to the existing documentation.

I remembered that the Docsify’s own docs have multiple translations available. There is no other place like the tool documentation to demonstrate a canonical way of dealing with localization. So, I took a look at the source code and found a familiar code:

window.$docsify = {
  alias: {
    // ...
    "/zh-cn/(.*)": "https://cdn.jsdelivr.net/gh/docsifyjs/docs-zh@master/$1",
    "/de-de/(.*)":
      "https://raw.githubusercontent.com/docsifyjs/docs-de/master/$1",
    "/ru-ru/(.*)":
      "https://raw.githubusercontent.com/docsifyjs/docs-ru/master/$1",
    "/es/(.*)": "https://raw.githubusercontent.com/docsifyjs/docs-es/master/$1",
  },
  /// ...
};

Each locale has a dedicated repo; aliases are used to proxy the requests (the same way we deal with versions). English documentation lives in the main repository, along with the source code. Perfect!

I borrowed this idea and came up with the following configuration:

window.$docsify = {
  // ...
  alias: {
    "/(ru|zh-cn)/.*_sidebar.md":
      "https://raw.githubusercontent.com/test-prof/docs-$1/master/docs/_sidebar.md",
    "/.*/_sidebar.md":
      "https://raw.githubusercontent.com/test-prof/test-prof/master/docs/_sidebar.md",
    "/(ru|zh-cn)/(.*)":
      "https://raw.githubusercontent.com/test-prof/docs-$1/master/docs/$2",
    "/(.*)":
      "https://raw.githubusercontent.com/test-prof/test-prof/master/docs/$1",
  },
  fallbackLanguages: ["ru", "zh-cn"],
  namespaces: [
    {
      id: "lang",
      values: ["ru", "zh-cn"],
      optional: true,
      selector: "#lang-selector",
    },
  ],
  // ...
};

It turned out that the fallbackLanguages option doesn’t play well with aliases. No worries, I fixed that, too.

Note that I’m also using a fallbackLanguages option: it allows us to use the default locale (English) if an article is missing for the requested one. Thus, we can gradually introduce new languages by translating documents one by one.

I’m also using a naming convention for localization repositories to leverage the power of regular expressions and avoid configuration bloat.

TestProf hasn’t reached v1.0 yet, so there is no versioning yet. When we reach the first major release, I will update the Docsify configuration to work with two namespaces:

window.$docsify = {
  // ...
  alias: {
    "/(ru|zh-cn)/v(d+)-(d+)/.*_sidebar.md":
      "https://raw.githubusercontent.com/test-prof/docs-$1/$2-$3-stable/docs/_sidebar.md",
    "/(ru|zh-cn)/.*_sidebar.md":
      "https://raw.githubusercontent.com/test-prof/docs-$1/master/docs/_sidebar.md",
    "/.*/_sidebar.md":
      "https://raw.githubusercontent.com/test-prof/test-prof/master/docs/_sidebar.md",
    "/(ru|zh-cn)/v(d+)-(d+)/(.*)":
      "https://raw.githubusercontent.com/test-prof/docs-$1/$2-$3-stable/docs/$2",
    "/(ru|zh-cn)/(.*)":
      "https://raw.githubusercontent.com/test-prof/docs-$1/master/docs/$2",
    "/(.*)":
      "https://raw.githubusercontent.com/test-prof/test-prof/master/docs/$1",
  },
  fallbackLanguages: ["ru", "zh-cn"],
  namespaces: [
    {
      id: "lang",
      values: ["ru", "zh-cn"],
      optional: true,
      selector: "#lang-selector",
    },
    {
      id: "version",
      values: ["v1-0"],
      optional: true,
      selector: "#version-selector",
    },
  ],
  // ...
};

Keeping documentation useful and maintainable at the same time could be hard. Introducing conventions and building generalized solutions (like docsify-namespaced) instead of ad-hoc hacks helped make this process simpler. Next time I will need to work on the documentation website, I won’t need to solve these problems again. I have my framework, and now you have it too!

Throughout this post I reference the documentation for AnyCable—a drop-in extension for Rails Action Cable that uses a familiar API and serves as a reliable and performant backbone for real-time applications built with Rails or pure Ruby. If your product can benefit from AnyCable or you require a custom solution on top of it—make sure to drop us a line.

Humans! We come in peace and bring cookies. We also care about your privacy: if you want to know more or withdraw your consent, please see the Privacy Policy.