Home

Hello, Wagtail!

Building a blog with Python

May 5, 2023 • 4 min read

#python #django
Computers are useless. They can only give you answers.

— Pablo Picasso

For my first post here, I thought I'd talk a little bit about how I built this website. I've built many WordPress websites in the past, mostly in my previous role at Few and for the Arkansas state government. But WordPress is heavy, it's built on PHP, and I wanted to use this project as an opportunity to learn more about the tools that I use at work. For the Applied Machine Learning Group at Paramount, that means Python and Django.

Wagtail

In search of a CMS powered by Django, I stumbled across Wagtail, which is used by organizations like NASA and Google. If it's good enough for the Jet Propulsion Laboratory, it's good enough for me!

I'm going to walk through a few features I built for this website, but I won't cover all of the details. If you want to learn more, Wagtail has a great tutorial. There's also Learn Wagtail. I personally paid for the full course, but there are plenty of free tutorials that I still reference as well.

Wagtail's home page

Wagtail's home page at wagtail.org

Creating a blog page model

You can't have a blog in Wagtail without a blog page model. For this blog, a page is relatively simple and consists of only a few fields. There's a featured image (the image at the top), a subtitle, and a StreamField for content. For my WordPress people, StreamFields are basically ACF Flexible Content fields.

With the StreamField, I can choose from four different blocks that I've created separate models for: a regular rich text content block, a code block, an image block, and a quote block.

from django.db import models

from wagtail.models import Page
from wagtail.fields import StreamField
from wagtail.admin.panels import FieldPanel

from website.blocks import ContentBlock, CodeBlock, ImageBlock, QuoteBlock


class BlogPage(Page):
    featured_image = models.ForeignKey(
        "wagtailimages.Image",
        blank=False,
        null=True,
        related_name="+",
        on_delete=models.SET_NULL,
    )
    subtitle = models.CharField(max_length=200, blank=True, null=True)
    body = StreamField(
        [
            ("content", ContentBlock()),
            ("code", CodeBlock()),
            ("image", ImageBlock()),
            ("quote", QuoteBlock()),
        ],
        null=True,
        blank=True,
        use_json_field=True,
    )

    content_panels = Page.content_panels + [
        FieldPanel("featured_image"),
        FieldPanel("subtitle"),
        FieldPanel("body"),
    ]

Syntax highlighting with Prism

The content, image, and quote blocks are pretty straightforward. But the code block is a little more complex. Because I'm a software engineer and most of my posts will contain code of some kind, I wanted to be able to share code blocks with proper syntax highlighting. To do that, I first created a CodeBlock model with language and code fields.

from wagtail import blocks


class CodeBlock(blocks.StructBlock):
    language = blocks.CharBlock(max_length=50)
    code = blocks.TextBlock()

    class Meta:
        template = "blocks/code_block.html"

Then I created a template to render the code block with Prism, a JavaScript library for syntax highlighting with support for every language and framework that you could think of.

<pre><code class="line-numbers language-{{ self.language }}">{{ self.code }}</code></pre>

Calculating read time

Other than that, the only other interesting thing I'm doing is calculating the read time for a post, which is based on an average reading time of 200 words per minute. To do that, I added a property to the BlogPage model.

from website.utils import strip_tags_from_body


@property
def read_time(self):
    words = strip_tags_from_body(str(self.body))
    time = len(words) // 200
    if time > 60:
        return f"{time // 60} hr, {time % 60} min read"
    return f"{time} min read"

The strip_tags_from_body utility function strips any HTML and unnecessary whitespace from the page using regex and returns a list of non-blank words. The read time is computed from the length of that list of words.

import re


def strip_tags_from_body(body: str) -> str:
    without_tags = re.sub("<[^<]+?>", "", body)
    without_newlines = re.sub("[\n\r]", "", without_tags)
    return [word for word in without_newlines.strip().split(" ") if word != ""]

Styling 💅

I've been a professional front-end developer for the last 6 years, so it was really nice to work on a website with absolutely zero JavaScript (except for Prism for syntax highlighting). In fact, the only other front-end work I did was add some CSS to make it look nice.

Tailwind

I didn't want to put too much thought into the design prematurely, so I decided to use Tailwind to get up and running as quickly as possible. I've been using Tailwind a lot in my personal projects lately because it's easy to use and has a lot of nice defaults, especially when combined with the typography plugin. And it's pretty easy to integrate into a Django project.

First, I created a separate ui/ directory at the root level of the project. Inside that project is a package.json file with two scripts that run the Tailwind CLI on some CSS files, outputting the result to the Django app's static files directory. The package.json file ended up looking like the JSON below.

{
  "name": "ui",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "tailwind-watch": "tailwindcss -i ./styles/main.css -o ../website/static/css/website.css --watch",
    "tailwind-build": "tailwindcss -i ./styles/main.css -o ../website/static/css/website.css --minify",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@tailwindcss/typography": "^0.5.9",
    "tailwindcss": "^3.3.2"
  }
}

And then you just have to configure Tailwind to look for utility classes in your Django template files.

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["../**/templates/**/*.{html,js}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};

Deploying to DigitalOcean

To deploy my new Wagtail website, I used a combination of PostgreSQL, Spaces, and App Platform on DigitalOcean, which is kind of like a better version of Heroku. It was super easy to set up with the Dockerfile that Wagtail generates for you, and it automatically deploys when it detects changes to your GitHub repository. If you're trying to deploy Django on App Platform, I recommend this tutorial.

The Lighthouse score for this website

The Lighthouse report for this page if you care about that kind of thing. I was pleasantly surprised by the results. It helps that Wagtail can automatically convert images to .webp for you

You're bound to be unhappy if you optimize everything.

— Donald Knuth

Farewell

That's all I have to say about this site for now. I hope you enjoyed it and learned a thing or two, like I did. Look forward to more posts about Python, computer science, game dev, AR/VR, art, and anything else that grabs my attention!