Skip to content

How I use Mkdocs

Estimated time to read: 16 minutes

  • Last Updated: June, 2024

Overview

Built with Material for MkDocs

I've built this site using Mkdocs and the Material theme for Mkdocs. While the documentation is great, I'm keeping this post to track some details of the specific configurations I'm using.

Customizations

I'm trying to keep reliance on custom config and plugins/extensions to a minimum but here are some of the customizations I use.

List of New Posts

This customization builds a page containing a list of new posts. The list is determined by the page status which Mkdocs Material introduced in the 9.2.0 release.

File and directory structure

This is a simplified overview of my Mkdocs directory structure to show the relevant files used in this customization. As seen in the example above, the Markdown posts have a status: new if they should be displayed on the new posts page.

There is a standard index.md file in the new folder which is used to display the list.

index.md
---
template: new_posts.html
---

# New posts

The index.md code above references a template, new_posts.html, stored in the overrides folder. Overrides allow you to alter the HTML source to extend themes.

This provides the layout structure for the page using a combination of HTML and Jinja templating. The relevant code required exists within the two for loops, {% for ... in ... %}. First the section is printed as a header e.g. "Networking / ACI". Next, each post within that section is printed as a link within an unordered list.

new_posts.html
{% extends "main.html" %}

<!-- Page content -->
{% block container %}
  <div class="md-content" data-md-component="content">
    <div class="md-content__inner">

      <!-- Header -->
      <header class="md-typeset">
        {{ page.content }}
        <div class="custom_table">
              {% for sections in new_posts %}
              <h3>{{sections}}</h3>
                {% for post in new_posts[sections] %}
                <ul>
                  <li><a href="{{ post.url }}">{{ post.title }}</a></li>
                </ul>
                {% endfor %}
              {% endfor %}
        </div>
      </header>
    </div>
  </div>
{% endblock %}

How can I find all pages which have the status as new?

This is achieved using an Mkdocs hook which is a Python script that I've stored in the hooks folder and referenced in the mkdocs.yml file.

new_posts_page.py
new_posts = {}

def build_categories(page_section, categories):

    categories.append(page_section.title)
    if page_section.parent and page_section.parent.title != "Categories":
        build_categories(page_section.parent, categories)

    return categories

def on_page_markdown(markdown, page, config, files):
    categories = []

    # Check if the page has metadata 'status: new'
    if page.meta.get('status') == 'new':

        if page.parent:
            if page.parent.title != "Categories":
                categories = build_categories(page.parent, categories)

        formatted_categories = ' / '.join(reversed(categories))

        if formatted_categories not in new_posts:
            new_posts[formatted_categories] = []
            new_posts[formatted_categories].append({
                'title': page.title,
                'url': page.canonical_url
            })
        else:
            new_posts[formatted_categories].append({
                'title': page.title,
                'url': page.canonical_url
            })

    return markdown

def on_page_context(context, page, config, nav):
    if page.meta.get("template") != "new_posts.html":
        return

    context["new_posts"] = new_posts
    return context

The script runs when the site is built and has two main functions.

First the script will check if the status is new using the on_page_markdown function. The headers are built using a recursive function, build_categories. This checks if there's a parent section or if it's reached the top. Categories is the root so I want to stop before and not include this in the header. For example, a header may be Docker or Networking / ACI.

The second half of the on_page_markdown function appends the post to the specific category so you end up with a result that looks like this:

{
    'Docker': [{
        'title': 'Things I Keep Forgetting',
        'url': 'http://127.0.0.1:8000/categories/docker/'
    }],
    'Networking / ACI': [{
            'title': 'FMC Endpoint Update App',
            'url': 'http://127.0.0.1:8000/categories/networking/aci/fmc-endpoint-update/'
        },
        {
            'title': 'L3Out',
            'url': 'http://127.0.0.1:8000/categories/networking/aci/l3out/'
        }
    ],
    'Security / Firewalls': [{
        'title': 'Stateful Firewalls',
        'url': 'http://127.0.0.1:8000/categories/security/firewalls/stateful-firewalls/'
    }]
}

Finally the on_page_context function runs when the new_posts.html page is built and the new_posts dictionary is passed to the new_posts.html page to be used within the Jinja template.

List of All Posts

Besides the page with new posts I also wanted to have an index page with a list of all posts. On most pages you might see some text starting with "Originally Written" at the top of the post. I use this string along with the headers from the new_posts to build an index of all posts, sorted by date.

Finding the date

I use the same on_page_markdown function from the new_posts page to parse each post when building the site. I've added some code to search the markdown for the "Originally Written" string and then extract the month and year. This is then used to build the nested dictionary containing articles grouped by date and category. The relevant code is as follows

new_posts_page.py
pattern = r"Originally Written:\s*(\w+),\s*(\d{4})"

# Search for the pattern in the text
match = re.search(pattern, markdown)

# If a match is found, return the date components
if match:
    month, year = match.groups()

    publish_date = f'{month} {year}'

...

    if publish_date not in all_posts:
        all_posts[publish_date] = {}

        if formatted_categories not in all_posts[publish_date]:
            all_posts[publish_date][formatted_categories] = []
            all_posts[publish_date][formatted_categories].append({
                'publish_datetimeobject': datetime.strptime(publish_date, '%B %Y'),
                'publish_date': f'{month}, {year}',
                'title': page.title,
                'url': page.canonical_url
            })
        else:
            all_posts[publish_date][formatted_categories].append({
                'publish_datetimeobject': datetime.strptime(publish_date, '%B %Y'),
                'publish_date': f'{month}, {year}',
                'title': page.title,
                'url': page.canonical_url
            })
    else:
        if formatted_categories not in all_posts[publish_date]:
            all_posts[publish_date][formatted_categories] = []
            all_posts[publish_date][formatted_categories].append({
                'publish_datetimeobject': datetime.strptime(publish_date, '%B %Y'),
                'publish_date': f'{month}, {year}',
                'title': page.title,
                'url': page.canonical_url
            })
        else:
            all_posts[publish_date][formatted_categories].append({
                'publish_datetimeobject': datetime.strptime(publish_date, '%B %Y'),
                'publish_date': f'{month}, {year}',
                'title': page.title,
                'url': page.canonical_url
            })

Sorting by date

Sorting by date was probably the biggest challenge with this feature. The all_posts dictionary is sorted in the on_page_context function when all pages are built. This uses the sort_by_date_desc function below.

sorted_articles_by_month = dict(sorted(all_posts.items(), key=sort_by_date_desc, reverse=True))
def on_page_context(context, page, config, nav)
def on_page_context(context, page, config, nav):

    if page.meta.get("template") == "new_posts.html":
        context["new_posts"] = new_posts
        return context
    elif page.meta.get("template") == "all_posts.html":
        # Sort the dictionary items by date in descending order
        # https://docs.python.org/3/howto/sorting.html#key-functions
        sorted_articles_by_month = dict(sorted(all_posts.items(), key=sort_by_date_desc, reverse=True))
        context["all_posts"] = sorted_articles_by_month
        return context
    else:
        return
def sort_by_date_desc(month_articles_pair)
# sort_by_date_desc is designed to work with a nested dictionary structure where each key represents a month and year
# (e.g., "December 2023"), and the corresponding value is another dictionary. The inner dictionary categorizes articles,
# with each key being a category name and the value being a list of article dictionaries.

# Function is called from within the on_page_context function
#   sorted_articles_by_month = dict(sorted(all_posts.items(), key=sort_by_date_desc, reverse=True))

# month_articles_pair is a tuple containing a month-year string
# and the corresponding dictionary of categories with article lists.

# Here is an example structure

# {
#   'December 2023': {
#       'Documentation': [{
#           'publish_datetimeobject': datetime.datetime(2023, 12, 1, 0, 0),
#           'publish_date': 'December, 2023',
#           'title': 'Docs as Code',
#           'url': 'http://127.0.0.1:8000/categories/documentation/docs-as-code/'
#       }]
#   },
#   'June 2023': {
#       'Infrastructure': [{
#           'publish_datetimeobject': datetime.datetime(2023, 6, 1, 0, 0),
#           'publish_date': 'June, 2023',
#           'title': 'Packer',
#           'url': 'http://127.0.0.1:8000/categories/infrastructure/packer/'
#       }]
#   },
#   'January 2023': {
#       'Kubernetes': [{
#           'publish_datetimeobject': datetime.datetime(2023, 1, 1, 0, 0),
#           'publish_date': 'January, 2023',
#           'title': 'Kubernetes and DNS',
#           'url': 'http://127.0.0.1:8000/categories/kubernetes/kubernetes-and-dns/'
#       }]
#   },
#   'July 2020': {
#       'Kubernetes': [{
#           'publish_datetimeobject': datetime.datetime(2020, 7, 1, 0, 0),
#           'publish_date': 'July, 2020',
#           'title': 'Kubernetes Ingress and Static Assets',
#           'url': 'http://127.0.0.1:8000/categories/kubernetes/kubernetes-ingress-and-static-assets/'
#       }]
#   },

def sort_by_date_desc(month_articles_pair):
    month, categories = month_articles_pair
    # Find the earliest publish_datetimeobject among all categories for the month
    earliest_date = None
    for category, articles in categories.items():
        if articles:  # Check if there are articles in the category
            category_earliest_date = articles[0]['publish_datetimeobject']
            if earliest_date is None or category_earliest_date < earliest_date:
                earliest_date = category_earliest_date
    return earliest_date

Expand and Collapse All Categories

There are quite a few categories and posts on my site so I wanted to have a way to expand and collapse all categories in the navigation bar.

This can be achieved with a little bit of Javascript. I enabled extra Javascript (similar to extra CSS) and wrote the code below to perform the expand/collapse function.

First look for the Categories label (I may need to change this in the future but for now I looked in the source and saw it was using an ID of __nav_3).

I then create an Expand All button and insert it just above the Categories label. I was looking at other options but found this to be the easiest and most logical place to position the button. I use cursor: pointer to have the hand icon appear when you hover over the button.

A + or - symbol is inserted into the Categories label depending on the status. The label text is also updated to either be Expand All or Collapse All.

I saw that aria-expanded: true was set when the categories were expanded so when the button is clicked the toggleAllSections function is called and aria-expanded is set to either true or false depending on the state.

extra.js
document.addEventListener('DOMContentLoaded', function() {

    // Find the 'Categories' label
    var categoriesLabel = document.querySelector('label[for="__nav_3"]');
    var categoriesToggle = document.querySelector('input#__nav_3');

    // Create the 'Expand All' button
    var expandAllButton = document.createElement('button');
    expandAllButton.id = 'expand_all';
    expandAllButton.textContent = 'Expand All';
    expandAllButton.style = 'display: block; margin-bottom: 10px; cursor: pointer';

    // Insert the button above the 'Categories' label
    categoriesLabel.parentNode.insertBefore(expandAllButton, categoriesLabel);

    // Create an expand/collapse indicator
    var indicator = document.createElement('span');
    indicator.textContent = ' +';
    categoriesLabel.appendChild(indicator);

    // Function to update the indicator
    function updateIndicator() {
        var isExpanded = categoriesToggle.checked;
        indicator.textContent = isExpanded ? ' -' : ' +';
        expandAllButton.textContent = isExpanded ? 'Collapse All' : 'Expand All';
    }

    // Initialize the indicator based on the current state
    updateIndicator();

    // Function to toggle the aria-expanded state of all sections
    function toggleAllSections(expand) {
        // Get all toggles within the 'Categories' section
        var toggles = document.querySelectorAll('.md-nav__item--section .md-nav__toggle');
        toggles.forEach(function(toggle) {
            var nav = toggle.nextElementSibling; // The corresponding nav element
            if (nav) {
                nav.setAttribute('aria-expanded', expand);
            }
            toggle.checked = expand; // Check or uncheck the toggle
        });
        updateIndicator(); // Update the '+' or '-' indicator
    }

    // Click event listener for the indicator
    expandAllButton.addEventListener('click', function(event) {
        var isExpanded = categoriesToggle.checked;
        toggleAllSections(!isExpanded);
        event.preventDefault(); // Prevent the default action
    });

    // Event listener for when the 'Categories' checkbox changes state
    categoriesToggle.addEventListener('change', updateIndicator);
});

Page progress bar

Some of my posts are quite long so I wanted to have a progress bar across the top of the page to indicate where you are in the document. Yes, there is the scroll bar and the navigation side bar but the scroll bar is sometimes hidden and the navigation bar doesn't tell you how long each section is. Plus as you'll see the code is quite minimal.

First I extend and override the theme. There is a new main.html file in the overrides folder with the code shown below.

main.html

{% extends "base.html" %}

{% block header %}

    {{super()}}

    <div id='progress-bar' style="z-index:100; position: fixed; top: 53px; height: 10px; background-color: teal;"></div>

{% endblock %}
  • {{super()}} includes the original content i.e. the header in this case. I then add a new <div> below the header and have it a fixed height and colour.

  • When elements on the page overlap, the z-index; controls the order of the vertical stack with the higher numbered ones on the top of the stack. e.g. <div id="a" style="z-index:2"> will be displayed on top of <div id="b" style="z-index:1">. Think of it like layers in Photoshop or when making a presentation in Powerpoint. In this case I wanted the progress bar to always be on top of the post content and so made the z-index:100.

Without specifying the z-index

When using z-index: 100

Then I add the following code to the extra.js file in the scripts folder. I already had this file for the expand all function above so I just appended the code to the end of the file.

The function determines the height of the page and how far you've scrolled, then calculates how wide the make the progress bar. The <div> width is then adjusted.

extra.js

// Adjusts the progress bar under the header as you scroll down the page
window.onscroll = function () {
    const distanceFromPageTop = document.body.scrollTop || document.documentElement.scrollTop;
    const height = document.documentElement.scrollHeight - document.documentElement.clientHeight;
    const scrolled = (distanceFromPageTop / height) * 100;
    document.querySelector("#progress-bar").style.width =  `${scrolled}%`;
};

Notification box for mobile users

I had an issue on my phone where the navigation bar on the left-hand side was not displaying all the categories and posts correctly. I don't have time to fix it right now and found it easier to have a small notification box at the top of each page with the link to the All Posts page.

Here is the Javascript used to detect a mobile device or one with a smaller screen width. This code is appended to the main.html file which was created in the previous section for the progress bar. The notification box should be at the top of each post and therefore I extended the content block.

I haven't tested this on many different phone/tablet models so try it out yourself if you need the same functionality. The innerWidth can be changed if you want to increase it to capture the larger models.

I found the following page useful for knowing the screensize in pixels of various phone and tablet models.

https://screensiz.es/phone

Success

{% block content %}
    <script>
        document.addEventListener('DOMContentLoaded', function() {

            function isMobile() {

                var userAgent = navigator.userAgent || navigator.vendor || window.opera;
                var isMobileUserAgent = /android|avantgo|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od|ad)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(40|60)|symbian|treo|up\.(browser|link)|vodafone|wap|windows (ce|phone)|xda|xiino/i.test(userAgent);

                var isSmallViewport = window.innerWidth <= 768;

                return isMobileUserAgent || isSmallViewport;
            }

            function createMobileAdmonition() {
                if (isMobile()) {
                    var container = document.querySelector('#mobile-users-container');
                    if (container) {
                        container.style.display = "block";
                    }
                }
            }

            createMobileAdmonition();

        });

    </script>

    <details id="mobile-users-container" class="warning" style="display:none;" open>
        <summary>Mobile or small screen users</summary>
        <div>
            <p>
                If you find that the navigation bar on the left-hand side is not displaying correctly you can view all the posts here.
            </p>
            <p>
                <a href="https://tl10k.dev/categories/all/">https://tl10k.dev/categories/all/</a>
            </p>
        </div>
    </details>

    {{super()}}

{% endblock content %}

Comments