Syntax Highlighted Code Blocks with Wagtail CMS

An important part of any tech blog is sharing code samples, terminal sessions, and configuration files. These should use a monospaced font and have syntax highlighting for the best clarity and readability.

While the Wagtail ecosystem offers many 3rd-party packages and even two dedicated to highlighted code blocks, I found nothing that had exactly the flexibility I needed here. Normally I lean towards using off-the-shelf solutions, but this was a small enough feature to write and will play an important role in the blogging experience, so I decided to roll my own Wagtail code block.


Writing your own Wagtail Code Block

My Wagtail project has one app called blog. In its models.py I use the StreamField for freeform content:

blog/models.py
from wagtail.core import blocks
from wagtail.core.models import Page
from wagtail.core.fields import StreamField
from wagtail.admin.edit_handlers import StreamFieldPanel

# ...

class BlogEntryPage(Page):
    body = StreamField(
        [
            # We'll look at how to define these custom blocks later
            ("heading", HeadingBlock()),
            ("paragraph", blocks.RichTextBlock()),
            ("figure", FigureBlock()),
            ("quote", QuoteBlock()),
            ("code", CodeBlock()),
        ],
    )

    content_panels = Page.content_panels + [
        StreamFieldPanel("body"),
    ]

The StreamField uses blocks, most of which are custom types. Here's my CodeBlock:

blog/models.py
from django.utils.safestring import mark_safe

from wagtail.core import blocks

import pygments
import pygments.lexers
import pygments.formatters

# A custom block should normally inherit from StructBlock
class CodeBlock(blocks.StructBlock):

    # Get all "lexers" from pygments to populate a language 
    # choices in the content editing interface
    language = blocks.ChoiceBlock(
        choices=[
            (lexer[1][0], lexer[0])
            for lexer in pygments.lexers.get_all_lexers()
            if lexer[1]
        ],
    )
    filename = blocks.CharBlock(required=False, max_length=250)
    # form_classname allows using custom CSS in the editing interface
    code = blocks.TextBlock(form_classname="monospace")

    def get_context(self, value, parent_context=None):
        context = super().get_context(value, parent_context=parent_context)

        # Get the user-selected lexer
        lexer = pygments.lexers.get_lexer_by_name(value["language"])

        formatter = pygments.formatters.get_formatter_by_name(
            "html",
            cssclass="pygments-highlight",
            filename=value["filename"],
        )

        highlighted_code = pygments.highlight(value["code"], lexer, formatter)
        # highlighted_code is an HTML string that we want to 
        # render without escaping, so use mark_safe
        context["highlighted_code"] = mark_safe(highlighted_code)

        return context

    class Meta:
        # Define the presentation in a separate 
        # file that consumes the context from above
        template = "blog/blocks/code.html"

and the template:

blog/blocks/code.html
{{ highlighted_code }}

In this case Pygments' HTML formatter does the heavy lifting by rendering the HTML with appropriate class attributes, so the template is trivial.

Styling

The highlighted_code value contains HTML with class attributes, but there is no CSS to style those elements unless you make it.

Pygments comes with built-in styles, but getting a static CSS file out of them takes a little bit of Python-fu. The trick is mentioned here:

The get_style_defs(arg='') method of a HtmlFormatter returns a string containing CSS rules for the CSS classes used by the formatter.

What this means is if you open a Python REPL session and get a reference to a Pygments style object, you can get the CSS out like this:

>>> import pygments.styles
>>> for s in styles:
...     print(s)
... 
default
emacs
friendly
colorful
autumn
murphy
manni
monokai
perldoc
pastie
# etc.
>>> style = pygments.styles.get_style_by_name("monokai")
>>> import pygments.formatters
>>> formatter = pygments.formatters.get_formatter_by_name("html", cssclass="pygments-highlight", style=style)
>>> print(formatter.get_style_defs())
pre { line-height: 125%; }
td.linenos pre { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
span.linenos { color: #000000; background-color: #f0f0f0; padding-left: 5px; padding-right: 5px; }
td.linenos pre.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
.pygments-highlight .hll { background-color: #49483e }
.pygments-highlight .c { color: #75715e } /* Comment */
.pygments-highlight .err { color: #960050; background-color: #1e0010 } /* Error */
.pygments-highlight .k { color: #66d9ef } /* Keyword */
.pygments-highlight .l { color: #ae81ff } /* Literal */
.pygments-highlight .n { color: #f8f8f2 } /* Name */
.pygments-highlight .o { color: #f92672 } /* Operator */
.pygments-highlight .p { color: #f8f8f2 } /* Punctuation */
.pygments-highlight .ch { color: #75715e } /* Comment.Hashbang */
.pygments-highlight .cm { color: #75715e } /* Comment.Multiline */
.pygments-highlight .cp { color: #75715e } /* Comment.Preproc */
.pygments-highlight .cpf { color: #75715e } /* Comment.PreprocFile */
.pygments-highlight .c1 { color: #75715e } /* Comment.Single */
.pygments-highlight .cs { color: #75715e } /* Comment.Special */
.pygments-highlight .gd { color: #f92672 } /* Generic.Deleted */
.pygments-highlight .ge { font-style: italic } /* Generic.Emph */
.pygments-highlight .gi { color: #a6e22e } /* Generic.Inserted */
.pygments-highlight .go { color: #66d9ef } /* Generic.Output */
.pygments-highlight .gp { color: #f92672; font-weight: bold } /* Generic.Prompt */
.pygments-highlight .gs { font-weight: bold } /* Generic.Strong */
.pygments-highlight .gu { color: #75715e } /* Generic.Subheading */
.pygments-highlight .kc { color: #66d9ef } /* Keyword.Constant */
.pygments-highlight .kd { color: #66d9ef } /* Keyword.Declaration */
.pygments-highlight .kn { color: #f92672 } /* Keyword.Namespace */
.pygments-highlight .kp { color: #66d9ef } /* Keyword.Pseudo */
.pygments-highlight .kr { color: #66d9ef } /* Keyword.Reserved */
.pygments-highlight .kt { color: #66d9ef } /* Keyword.Type */
.pygments-highlight .ld { color: #e6db74 } /* Literal.Date */
.pygments-highlight .m { color: #ae81ff } /* Literal.Number */
.pygments-highlight .s { color: #e6db74 } /* Literal.String */
.pygments-highlight .na { color: #a6e22e } /* Name.Attribute */
.pygments-highlight .nb { color: #f8f8f2 } /* Name.Builtin */
.pygments-highlight .nc { color: #a6e22e } /* Name.Class */
.pygments-highlight .no { color: #66d9ef } /* Name.Constant */
.pygments-highlight .nd { color: #a6e22e } /* Name.Decorator */
.pygments-highlight .ni { color: #f8f8f2 } /* Name.Entity */
.pygments-highlight .ne { color: #a6e22e } /* Name.Exception */
.pygments-highlight .nf { color: #a6e22e } /* Name.Function */
.pygments-highlight .nl { color: #f8f8f2 } /* Name.Label */
.pygments-highlight .nn { color: #f8f8f2 } /* Name.Namespace */
.pygments-highlight .nx { color: #a6e22e } /* Name.Other */
.pygments-highlight .py { color: #f8f8f2 } /* Name.Property */
.pygments-highlight .nt { color: #f92672 } /* Name.Tag */
.pygments-highlight .nv { color: #f8f8f2 } /* Name.Variable */
.pygments-highlight .ow { color: #f92672 } /* Operator.Word */
.pygments-highlight .w { color: #f8f8f2 } /* Text.Whitespace */
.pygments-highlight .mb { color: #ae81ff } /* Literal.Number.Bin */
.pygments-highlight .mf { color: #ae81ff } /* Literal.Number.Float */
.pygments-highlight .mh { color: #ae81ff } /* Literal.Number.Hex */
.pygments-highlight .mi { color: #ae81ff } /* Literal.Number.Integer */
.pygments-highlight .mo { color: #ae81ff } /* Literal.Number.Oct */
.pygments-highlight .sa { color: #e6db74 } /* Literal.String.Affix */
.pygments-highlight .sb { color: #e6db74 } /* Literal.String.Backtick */
.pygments-highlight .sc { color: #e6db74 } /* Literal.String.Char */
.pygments-highlight .dl { color: #e6db74 } /* Literal.String.Delimiter */
.pygments-highlight .sd { color: #e6db74 } /* Literal.String.Doc */
.pygments-highlight .s2 { color: #e6db74 } /* Literal.String.Double */
.pygments-highlight .se { color: #ae81ff } /* Literal.String.Escape */
.pygments-highlight .sh { color: #e6db74 } /* Literal.String.Heredoc */
.pygments-highlight .si { color: #e6db74 } /* Literal.String.Interpol */
.pygments-highlight .sx { color: #e6db74 } /* Literal.String.Other */
.pygments-highlight .sr { color: #e6db74 } /* Literal.String.Regex */
.pygments-highlight .s1 { color: #e6db74 } /* Literal.String.Single */
.pygments-highlight .ss { color: #e6db74 } /* Literal.String.Symbol */
.pygments-highlight .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */
.pygments-highlight .fm { color: #a6e22e } /* Name.Function.Magic */
.pygments-highlight .vc { color: #f8f8f2 } /* Name.Variable.Class */
.pygments-highlight .vg { color: #f8f8f2 } /* Name.Variable.Global */
.pygments-highlight .vi { color: #f8f8f2 } /* Name.Variable.Instance */
.pygments-highlight .vm { color: #f8f8f2 } /* Name.Variable.Magic */
.pygments-highlight .il { color: #ae81ff } /* Literal.Number.Integer.Long */

You may have to massage the output manually to get the exact style you want.

Styling the Editing Interface

If we're writing code into the Wagtail interface, we want it to be monospaced, too.

Wagtail looks for a wagtail_hooks.py file in the root of every app, and we'll use that to inject CSS into the admin interface:

blog/wagtail_hooks.py
from django.templatetags.static import static
from django.utils.html import format_html

from wagtail.core import hooks


@hooks.register("insert_editor_css")
def editor_css():
    return format_html(
        '<link rel="stylesheet" href="{}">',
        static("blog/css/admin/monospace.css"),
    )

This adds a link to a CSS file we manually place here:

blog/css/admin/monospace.css
.monospace textarea {
  font-family: monospace;
}

The End Product

You've seen the output of this work as code blocks all throughout this blog post, but here's what the editing experience looks like:

code-block-screenshot.png
A screenshot of the Wagtail interface featuring this custom code block.