Boost MkDocs Dev Server Functionality with Custom Endpoints


MkDocs is an awesome static site generator. It provides many features like navigation and search, but what I like is its extensibility. Hundreds or thousands of extensions and plugins allow you to render anything you want, the way you want. While the final output is what matters the most, I also wanted to share a method to enhance the developer experience (DX) by adding features at dev time (mkdocs serve
moment).
This hack is particularly useful if you want to develop a custom mkdocs plugin with very nice DX.
Use case
Why such a thing? Basically, I wanted to have an excalidraw editor. Not just a viewer where you copy and paste what you have exported from the online excalidraw app, but the true editor so that you can generate sketches during mkdocs serve
time (obviously, they must be rendered at build time). The final result can be seen in the mkdocs-shadcn documentation.
In the same way, you can imagine typing your markdown directly in the browser with contenteditable
and update your docs in live. Other ideas?
Needs
What do we need? I want to draw something in my browser and then save it to the filesystem. I could just insert the editor, add a download button, and be done, but the DX would be awful. The best would be to hide all this stuff. So, the other path would be the following (in dev mode):
edit something in the browser
on change, send the new data to the dev server
the dev server saves this data locally
So I need to add a custom route to the dev server!
Hack
MkDocs does not provide this feature, but it exposes some hooks when you develop a plugin, notably. The on_serve
hook is triggered when the server is started, and we have a mkdocs.livereload.LiveReloadServer
as an argument. In a nutshell, we should develop a plugin and do something with the dev server during this hook.
from mkdocs.plugins import BasePlugin
from mkdocs.livereload import LiveReloadServer
class ExcalidrawPlugin(BasePlugin):
def on_serve(self, server: LiveReloadServer, /, *, config, builder):
"""This method is called when the server is started. At the end of the mkdocs
workflow. At this moment we have access to the live server to inject our new
routes.
See https://www.mkdocs.org/dev-guide/plugins/#events to see the mkdocs workflow.
"""
self.extend_server(server)
The server handles incoming requests through its _serve_request
method. The idea is to decorate this method so it can also handles our custom requests.
from functools import wraps
class ExcalidrawPlugin(BasePlugin):
# ...
def extend_server(self, server: LiveReloadServer):
"""Extend the mkdocs dev server to add custom behavior."""
original = server._serve_request
@wraps(original)
def _serve_request(environ, start_response):
# put priority to base routes
# the handler returns None if it is not able to
# treat an unknown route
result = original(environ, start_response)
if result is not None:
return result
# TODO: HANDLE OUR CUSTOM REQUESTS HERE
# monkey patch the _serve_request method of the server
setattr(server, "_serve_request", _serve_request)
This methods seems to respect the WSGI spec. So we have to provide a custom WSGI handler. For this purpose we can use the lightweight bottle framework that exposes a WSGI handler.
from functools import wraps
from bottle import Bottle, request
def handler():
"""Our custom handler"""
data = request.json
print("data:", data)
# TODO: HANDLER TO IMPLEMENT
class ExcalidrawPlugin(BasePlugin):
def __init__(self):
# init a router
self.bottle = Bottle()
# ...
def extend_server(self, server: LiveReloadServer):
"""Extend the mkdocs dev server to add custom behavior."""
# attach our handler to the custom route
self.bottle.route("/excalidraw", method=["GET", "POST"])(handler)
original = server._serve_request
@wraps(original)
def _serve_request(environ, start_response):
result = original(environ, start_response)
if result is not None:
return result
# HANDLE OUR CUSTOM REQUESTS HERE
return self.bottle.wsgi(environ, start_response)
setattr(server, "_serve_request", _serve_request)
We create a router, add a route, and let it handle our custom requests. All that remains is to implement the handler
function, but this is not so relevant in this post.
Wiring
I have tried to go straight to the point to add a custom route to the dev server. In practice, you have to:
integrate your plugin code into your project (maybe you develop such a plugin)
add the plugin into your
mkdocs.yml
(for the dev who uses your plugin)add custom js to make the API calls (all your client-side logic)
For the latter point, you may need to provide a custom markdown extension that injects the js you need. So yes, there's still work to be done.
Subscribe to my newsletter
Read articles from Alban Siffer directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
