Boost MkDocs Dev Server Functionality with Custom Endpoints

Alban SifferAlban Siffer
4 min read

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.

demo

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.

0
Subscribe to my newsletter

Read articles from Alban Siffer directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Alban Siffer
Alban Siffer