5 min read

Making a crank HTML only blog

I can't complain about accessibility if my site isn't
Making a crank HTML only blog
All you need (HTML element content categories)

One of my (two) readers this week informed me that my blog doesn't work on dillo. They were correct. It mostly renders but the article links are busted.

A screenshot of my site not working with dillo

I didn't have high expectations really for how well Ghost would work without JavaScript, but I had assumed it actually wouldn't work at all. Consulting the Ghost documentation suggested that it does serve static files, so that explains why it kind of worked. The documentation also suggested that if it's not degrading gracefully it's probably the theme's fault. I didn't feel like changing my theme trying to find one that both looked nice but had a fallback mode.

What I did not realize before now was how modular Ghost is.

Ghost Architecture Flowchart

What I thought was one monolithic application was three modular pieces. You can replace the reference frontend with any number of custom frontends of your choosing to consume their Content API. I refuse to learn Node.js, but I can throw together a Flask server in an afternoon.

A simple Flask Ghost Content API client

The JSON API is straightforward. You can request a set of the posts that have all of the attributes you would need by querying https://blog.wjboll.es/ghost/api/content/posts. The post content comes through as HTML already in html.

    {
      "id": "679041320d0e7900012a2122",
      "uuid": "18db0f12-4b9b-471c-97d9-5ea91f91e361",
      "title": "Blog back up",
      "slug": "blog-back-up",
      "html": "<p>I seemed to have botched the static files when doing an update a few months ago. I will have manually redo my old posts and there may be random bugs.</p>",
      "comment_id": "679041320d0e7900012a2122",
      "feature_image": "https://blog.wjboll.es/content/images/2025/01/bootloader-android.jpg",
      "featured": true,
      "visibility": "public",
      "created_at": "2025-01-21T19:52:02.000-05:00",
      "updated_at": "2025-03-07T11:20:33.000-05:00",
      "published_at": "2025-01-21T19:56:52.000-05:00",
      "custom_excerpt": "I figured Ghost out",
      "codeinjection_head": null,
      "codeinjection_foot": null,
      "custom_template": null,
      "canonical_url": null,
      "url": "https://blog.wjboll.es/blog-back-up/",
      "excerpt": "I figured Ghost out",
      "reading_time": 0,
      "access": true,
      "comments": true,
      "og_image": null,
      "og_title": null,
      "og_description": null,
      "twitter_image": null,
      "twitter_title": null,
      "twitter_description": null,
      "meta_title": null,
      "meta_description": null,
      "email_subject": null,
      "frontmatter": null,
      "feature_image_alt": null,
      "feature_image_caption": null
    },

An example of Content API JSON for posts

I looked over the Jinja2 documentation briefly and made a few simple HTML 5 templates.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>walter's dietblog</title>
    <link rel="stylesheet" href="/static/almond.lite.min.css"/>
</head>

<body>
{% include 'nav.html' %}
<h1>walter's dietblog</h1>
<p>random musings | more macro than a microblog (but we're watching our macros)</p>
<img src="/static/profile.png" alt="A photo of myself with my daughter wearing sunglasses. She is dressed like a bear">
{% for post in posts %}
    <div><p>{{ post.created_at }} — <a href="{{ post.path }}">{{ post.title }}</a></p></div>
{% endfor %}
</body>
{% include 'footer.html' %}
</html>

The homepage template

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>walter's dietblog | {{ post.title }}</title>
    <link rel="stylesheet" href="/static/almond.lite.min.css"/>
</head>

<body>
{% include 'nav.html' %}
<h1>{{ post.title }}</h1>
<p>{{ post.created_at }} — <i>{{ post.excerpt }}</i></p>
<p><img src="{{ post.feature_image }}" alt="Feature image"/></p>
<hr>
{{ post.cleaned_post|safe }}
</body>
{% include 'footer.html' %}
</html>

The posts template

For each request I grab the latest posts collection from the Content API, do a little cleanup pass in get_posts(), and pass the result down to the relevant template. I considered adding some sort of cache, but figured the reference Ghost frontend is likely just re-polling all of this on every request anyway.

@app.route('/<string:path>/')
def post_page(path):
    posts = get_posts()
    located_post = next((sub for sub in posts if sub['path'] == path), None)
    return render_template('post.html', post=located_post)

An example of routing posts

The main thing I am doing in the get_posts() function is the HTML that comes down from the Content API is filled with Ghost-specific classes and attributes – but we do not need these. Where we're going we don't need any styling whatsoever, and without this pass the images also end up with strange proportions. You can strip all of that out with lxml_html_clean.

from lxml_html_clean import Cleaner

cleaner = Cleaner(style=False, safe_attrs_only=True, safe_attrs=frozenset({'id', 'alt', 'src', 'href'}))

json_data['cleaned_post'] = cleaner.clean_html(json_data['html'])

An example of lxml_html_clean

And that's basically it. Just a little Flask additional boilerplate and json parsing.

Lastly I tossed together a quick Dockerfile and deployed it on my server

# syntax=docker/dockerfile:1

FROM python:3.13.2-slim-bookworm

WORKDIR /python-docker

COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt

COPY . .
EXPOSE 8080
CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0", "--port=8080"]

Flask Dockerfile

End result

I think it looks great. I'm tempted to make this the default site. For now I set it up on a new subdomain: https://dietblog.wjboll.es/making-a-crank-html-only-blog

dietblog homepage in Firefox
This post in Firefox

Checking in on dillo, we're looking a lot better.

dietblog homepage in dillo

I had to tweak the CSS a bit because the code tag was center aligning.

This post in dillo

But the real test is, does it work in lynx for those readers in a missile silo in Montana (yes).

dietblog homepage in lynx
This post in lynx

Changelog

  • 2025-03-07 – Fixed excess padding on screenshots

👨‍💻 Written by a human, not AI

I'm a software developer based in Raleigh, North Carolina, with a focus on C#, .NET, and the secure software development life cycle. Formerly a Bernie Sanders delegate representing North Carolina at the 2020 DNC. You may find me infrequently on Mastodon.