Introduction
A good software development principle is to manage as many aspects as possible through code. This includes functionality (obviously), load tests, alarms, and networking policies. This blog post extends everything as code to documentation. I will explain why I prefer Markdown files, which tools I use in my development workflow, and how to publish the documentation using Zensical and GitHub Pages.
Plain Markdown files
In order to handle documentation as code, we need to use a format that gives us the freedom to edit in a regular text editor. Thus, we should avoid WYSIWYG editing and instead choose a format that works well with version control. Additionally, the format should be open source, which typically enables a wider range of tools. One such format is regular Markdown files. They let you use your favorite editor (Neovim, btw) and work with your regular development flow with PRs, building, linting, and more.
Local flow
I usually set up a compelling local developer flow using pre-commit hooks, which I previously blogged about. There are also numerous plugins for different editors that render Markdown files locally, e.g., https://github.com/iamcco/markdown-preview.nvim.
Zensical, which we will explain shortly, is easy to render locally: zensical serve.
Building
Now, a local build is not enough, and in my CI setup I use the DavidAnson/markdownlint-cli2-action GH action with the following .markdownlint.yaml configuration
---
MD013: # Line length rule
line_length: 80
tables: false
This file is also used locally with the pre-commit hook. I turn off the line length validation for tables; everything else stays at the default. I use a limit of 80 characters to keep the text readable in terminals and GH PR views. For a full list of the Markdown rules, see: https://github.com/DavidAnson/markdownlint/tree/main/doc.
The building is pretty straightforward. Simply add the following step to your GH action workflow:
- name: markdown lint
uses: DavidAnson/markdownlint-cli2-action@v25.0.0
with:
globs: |
**/*.md
Rendering using Zensical
With many Markdown files, it can become difficult to navigate. I also like to render the Mermaid diagrams to make them easier to read. Previously, I used MkDocs with the Material theme. However, for my latest project, I noticed a notification in the MkDocs build that the team is working on a new project, https://zensical.org, and that MkDocs will be deprecated. They have migration steps to move from MKDocs Material to Zensical, and if you are new to rendering Markdown files, the Zensical CLI makes it really easy to start.
Setup Zensical
I like to use a Python virtual environment to keep the repo as self contained as possible. To set up Zensical, I simply add a requirements.txt file with zensical==0.1.0, or whatever the latest Zensical version is. I use a centralized Renovate to keep the dependencies updated.
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
zensical new .
I like how Zensensical gives me an “all batteries included” setup, and the default zensical.toml makes it easy to opt in to new functionality.
Navigation configuration
I usually turn off the navigation in the zensical.toml file and let the folder structure under docs dictate the layout. This makes it easier to restructure files without always having to update the nav section links. Simply add a folder with an index.md file, and the structure is replicated in the rendered page.
# nav = [
# { "Get started" = "index.md" },
# { "Markdown in 5min" = "markdown.md" },
# ]
Additionally, I comment in navigation.tabs to get a horizontal navigation bar at the top.
[project.theme]
features = [
"navigation.tabs"
]
Mermaid support
Mermaid is a powerful diagram tool that supports the everything as code principle. We enable the support in Zensical using:
[project.markdown_extensions]
pymdownx.superfences.custom_fences = [
{ name = "mermaid", class = "mermaid", format = "pymdownx.superfences.fence_code_format" },
]
Full GH build pipeline
---
name: Documentation
on:
push:
branches:
- main
concurrency:
group: docs-${{ github.ref }}
cancel-in-progress: false
jobs:
deploy:
permissions:
contents: read
pages: write
id-token: write # OIDC for actions/deploy-pages
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: markdown lint
uses: DavidAnson/markdownlint-cli2-action@v25.0.0
with:
globs: |
**/*.md
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: 3.x
cache: pip
- run: pip install -r requirements.txt
- run: zensical build --clean
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: site
- uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
id: deployment
Example repository
I created an example repository that demonstrates the complete setup described in this blog post, including Markdown linting, Zensical configuration, and GitHub Pages deployment: https://github.com/fredrkl/doc-as-code-demo.
