Zum Inhalt

mkdocs

Following a quick rundown of how to configure the mkdocs project structure using macros, includes, a base page and external data files to better organize the data for our content.

Installation

We need the python packages mkdocs / mkdocs-material and mkdocs-macros-plugin.

poetry init --dependency mkdocs-material mkdocs-macros-plugin
cd mydocs
python -m venv venv
source venv/bin activate
pip install mkdocs-material mkdocs-macros-plugin

The mkdocs cli features a command to create a new project:

mkdocs new mydocs

Snippets

The mkdocs-macros plugin allows to add includes as *.md or *.html. Here snippets is next do the docs_dir, which is docs by default.

plugins:
  - search:
      lang: en
  - macros:
      include_dir: snippets

Glossary

In tech, we love cryptic abbreviations, right 😅? So to add a glossary, place a file glossary.md into the snippets directory:

snippets/glossary.md
*[OS]: Operating System (Betriebssystem)

At the bottom of your page, you can include the glossary like in this page example:

---
title: My title
---

My preferred OS is a Linux.

{% include 'glossary.md' %}

Macros

Macros are a little different to includes. They are more like functions, but can not return data, only text. To add a macros file, add a macros.md to snippets directory:

snippts/macros.md
{%- macro linkify(text) -%}
{% set t = text | string %}
{%- if t.startswith('https://') or t.startswith('http://') -%}
[{{ t }}]({{ t }})
{%- else -%}
{{ t }}
{%- endif -%}
{%- endmacro -%}

In order to use the macros on a page, you have to include it in the page:

{% import 'macros.md' as macros with context %}

Linkified link: {{ macros.linkify('https://www.python.org/') }}

See also the docs of the mkdocs-macros-plugin, which you can use to add your custom plugins in order to build your custom workflows.

Adding a base page

In order to achieve consistent results and make macros on each page available, you can add a base page that contains the macros.

snippets/page_base.md
{% import 'macros.md' as macros with context %}

{% block content %}
{% endblock %}

{% include 'glossary.md' %}

Reuse the base_page.md using the extends directive:

---
title: Mypage
---
{% extends 'page_base.md' %}
{% block content %}
# Page Title
{% endblock %}

Drive documentation with data files

Instead of adding variables to the extra key in mkdocs.yml, it might be useful to have dedicated data files that can be included using the mkdocs-macros plugin. For example, you might create a urls.yml file with all urls used in your project. It makes sense to have them in a central place and to reference them from there.

mkdocs.yml
plugins:
  - search:
      lang: de
  - macros:
      include_dir: snippets
      include_yaml:
        - urls: data/urls.yml
data/urls.yml
github: 'https://github.com'

You can then reference the url by a name: {{ urls.github }}. It is also possible to use relative paths like ../otherrepo/data/hosts.yml to fetch data from outside the git repo.

Re-render when data changes

By default, mkdocs does not watch for changes in the data directory. You can tell it to do so by using mkdocs serve --watch data.

Making drafts

I found that I like to add markdown pages as drafts and commit them. I made sure not to include them in the nav in mkdcos.yml. In that case, the draft page can be found using the search because it will be rendered as html. So there are other possibilities depending on your workflow

  • Pushing only to main, making draft visible for others, but ignored for html rendering: Put a . in front of the filename.
  • Pushing only to main, making draft visible for others, discoverable by search: Just exclude from nav.
  • Using feature branch: not really visible to others, normal workflow where you include the page in the nav where desired

Builtin filters

Filtering, ordering or modifying data using Jinja2 filters is often needed. You can include the code below on your page to help you find all available filters.

{% for filter in filters_builtin %}`{{ filter }}`, {% endfor %}
Result:

abs, attr, batch, capitalize, center, count, d, default, dictsort, e, escape, filesizeformat, first, float, forceescape, format, groupby, indent, int, join, last, length, list, lower, items, map, min, max, pprint, random, reject, rejectattr, replace, reverse, round, safe, select, selectattr, slice, sort, string, striptags, sum, title, trim, truncate, unique, upper, urlencode, urlize, wordcount, wordwrap, xmlattr, tojson,

How this doc site is made

Following the up-to-date configurations that are used to build this site.

Dockerfile
FROM python:3.9.10-bullseye as python-base

# Setting up proper permissions, running app not as root
RUN mkdir -p /app \
    && groupadd --gid 1000 -r web \
    && useradd -d /app --uid 1000 -r -g web web \
    && chown web:web -R /app \
    && pip install poetry

ARG BUILD_DIR=/app/site

ENV PATH="/app/.local/bin:$PATH"

WORKDIR /app
USER web

COPY ./poetry.lock ./pyproject.toml ./
RUN poetry install

FROM python-base as development

# Expose MkDocs development server port
EXPOSE 8000

COPY --chown=web:web . .

# ARG cannot be used in CMD, but env vars can
CMD ["poetry", "run", "mkdocs", "serve", "--dev-addr=0.0.0.0:8000"]

FROM development as builder

RUN poetry run python -m pytest examples/
RUN poetry run mkdocs build --strict

FROM nginx:latest as production

COPY --from=builder /app/site /usr/share/nginx/html

To automatically deploy the generated website using a Gitlab CICD, I use the jwilder/nginx-proxy in combination with a docker-compose.yml.

docker-compose.yml
version: '3.8'

services:
  rd_docs:
    image: rd_docs:production
    environment:
      VIRTUAL_HOST: docs.real-digital.ch
      LETSENCRYPT_HOST: docs.real-digital.ch
      LETSENCRYPT_EMAIL: for-letsencrypt@pm.me
    networks:
      - default
      - proxy-tier

networks:
  default:
  proxy-tier:
    external:
      name: proxy-tier

And the CICD configuration using Gitlab with a small helper script:

build.sh
#!/usr/bin/env bash

target=${1:-development}
docker build --tag rd_docs:"$target" --target "$target" .
.gitlab-ci.yml
stages:
  - build
  - deploy

build-job:
  stage: build
  script:
    - ./build.sh production

deploy-job:
  stage: deploy
  script:
    - docker-compose up --force-recreate -d

shutdown-job:
  stage: deploy
  when: manual
  script:
    - docker-compose down

Examples

Add this javascript code to docs/js/custom.js and add it to mkdocs.yml:

extra_javascript:
  - js/custom.js
examples/mkdocs/external_links_new_window.js
function is_external_link(link) {
    if (link.href.startsWith('#')) {
        return false
    }
    if (link.href.includes('javascript:')) {
        return false
    }
    if (link.href.includes('mailto:')) {
        return false
    }
    if (link.href.includes('tel:')) {
        return false
    }
    return link.hostname !== window.location.hostname;
}

function init_external_links() {
    var links = document.links;
    for (var i = 0, linksLength = links.length; i < linksLength; i++) {
      if (is_external_link(links[i])) {
          links[i].target = '_blank';
      }
    }
}

init_external_links()

Table Macro

If you want to generate a tables from data (list of lists), you can use the following macro, that also uses the macro linkify mentioned above:

snippets/macros.md
{%- macro table_header(headers) %}
| {{ headers | join (' | ')}} |  
| {% for i in headers %} - |{% endfor -%}
{%- endmacro -%}

{%- macro table_row(row_data, row_macro=None) -%}
| {%- for item in row_data %} {{ linkify(item) }} | {% endfor %}
{%- endmacro %}

{%- macro table(headers, data) %}
| {{ headers | join (' | ')}} |  
| {% for i in headers %} - |{% endfor %}  
{% for line in data -%}
| {%- for row in line %} {{ linkify(row) }} | {% endfor %}
{% endfor %}
{%- endmacro %}

Note: The line and whitespace control using - in Jinja2 directives is important here to get the right formatting.


Letztes Update: March 25, 2023
Erstellt: August 7, 2022