What changed
From late 2021 until now this blog was a Hugo site. The source lived in a hugo-site-generator repo, the theme was Ananke pulled in as a git submodule, and every push ran a GitHub Action that built the site and pushed the public/ directory into a second repo, ltratcliff.github.io, for GitHub Pages to serve.
It is now an Astro site with EmDash as the CMS, running on Cloudflare:
| Hugo | Astro + EmDash | |
|---|---|---|
| Content | Markdown files in git | D1 (Cloudflare's SQLite) |
| Media | static/ in git |
R2 bucket |
| Rendering | Built once, static HTML | Server-rendered by a Worker, cached at the edge |
| Writing | Editor, commit, push, wait for the Action | Admin UI, or push a Markdown file to the API |
| Theme | Ananke submodule plus overridden partials | Templates in the repo |
| Hosting | GitHub Pages | Cloudflare Workers Free plan |
Why
I wanted to try the latest hotness. I've been using Cloudflare a lot lately. and got notified of their CMS offering Emdash. After reading a bit, became intrigued with Astro and Emdash, and here we are.
I wanted an editor sometimes, and files the rest of the time. EmDash gives me an admin UI at /_emdash/admin where a change is live on save, and a REST API I can push Markdown at from my own editor. I use both. round trip.
The import
The important part was not losing anything. Old posts had to keep their slugs, dates, summaries, tags and categories, and the images and PDFs they pointed at.
scripts/import-hugo.mjs reads the Hugo content/ directory and writes everything through the EmDash REST API:
content/posts/*.mdbecome posts with the same slug and date. Hugo'ssummarybecomes the excerpt.content/about/_index.mdbecomes theaboutpage.- Any file under
static/that a post references is uploaded to the media library and the link is rewritten. - Markdown is converted to Portable Text, which is how EmDash stores rich text.
- A slug that already exists is skipped, so the script can be re-run until the output looks right.
npm run import:hugo -- --dry-run --out /tmp/converted # convert only
npm run import:hugo # local dev server
npm run import:hugo -- --url https://blog.ltratcliff.comAgainst the live site it waits a few seconds between requests, because the Workers Free plan has a small CPU allowance per request and a burst of image uploads runs into it.
Shortcodes
Hugo runs shortcodes before it renders Markdown, so there is nothing to port them to. The import expands each one into what it produced on the published page:
{{< youtube id >}}became an EmDash embed block.{{< param name >}}became the front matter value, formatted the way Go's templates print it.{{< year >}}became the year, the same as Hugo'snowat build time.{{< rangeshortcode >}}(a demo from the "Fun With Hugo" post) became its rendered output, frozen as static content.{{< tech >}}, the tag cloud of things I work with, needed a real replacement.
An escaped shortcode, written to show the syntax, comes through as literal text. An unknown one is left as text with a warning, so nothing disappears silently.
Custom blocks
Two things in the old posts had no EmDash equivalent, so they became small site-local plugins under src/plugins/. Each one adds a block to the editor and renders it on the page:
- Tech cloud. A packed word cloud drawn with wordcloud2.js, in colours that follow the light and dark theme. The import emits it wherever the
techshortcode was. - PDF viewer. Draws a media-library PDF's pages inline with pdf.js and keeps a download link. The import emits it for PDFs embedded in Hugo content.
Running on the Workers Free plan
Rendering a post with Portable Text, a table of contents and syntax highlighting costs more CPU than the free plan allows per request. The fix is not to render on most requests. Public pages are cached at Cloudflare's edge for an hour and served stale for up to a week while they refresh, and EmDash purges the affected pages when content changes:
routeRules: {
...Object.fromEntries(
["/", "/posts", "/posts/[slug]", "/pages/[slug]", "/category/[slug]",
"/tag/[slug]", "/archives/[...period]", "/rss.xml"].map(
(route) => [route, { maxAge: 3600, swr: 604800 }],
),
),
// Uploaded files never change (each upload gets a new key).
"/_emdash/api/media/file/[...key]": { maxAge: 31536000 },
},Syntax highlighting runs on the server with Speed Highlight, so a code block does not ship any JavaScript to the reader.
Writing posts now
Most posts, this one included, start as a Markdown file in posts/:
---
title: My post
excerpt: One-line summary
tags: [nix, homelab]
categories: [Linux]
date: 2026-10-01
draft: true
---
Body in Markdown. Images next to the file are uploaded: npm run post posts/my-post.md # local dev server, to preview
npm run post:live posts/my-post.md # blog.ltratcliff.comThe file name is the slug, and pushing an existing slug overwrites that post, so the file stays the source of truth. The date field sets the publish date, which is why this post is dated the day the migration started rather than the day I finished writing it up.
What I gave up
- A static site. There is now a database, a Worker and a cache to reason about. If Cloudflare is down, so is the blog.
- Git as the record of every post. Posts written in the admin UI live only in D1. The ones I write as files are still in git.
- Disqus and Google Analytics. Comments are EmDash's own, and page counts come from GoatCounter, which I prefer due to the private nature.
What's left
- Time will tell!




No comments yet