Making a crank HTML only blog

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.

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.

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


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

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

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


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.
Member discussion