How I use Mkdocs¶
Estimated time to read: 16 minutes
- Last Updated: June, 2024
Overview¶
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.
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.
-
on_page_markdown
:The page_markdown event is called after the page's markdown is loaded from file and can be used to alter the Markdown source text. The meta- data has been stripped off and is available as page.meta at this point.
-
on_page_context
:The page_context event is called after the context for a page is created and can be used to alter the context for that specific page only.
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.
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
-
{{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 thez-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.
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 %}