td204

Web development, one post per year since 2002

/so far

2026 in review: a flat-file CMS, an admin panel that writes itself, and this rebuild

Not a full year yet, so this is a mid-year note. Three things worth recording: I used a flat-file CMS, Grav, in anger for the first time, Filament removed most of an admin build, and AI stopped being a thing I use and started being a thing I ship.

And this blog moved off WordPress, which is the part you are reading.

Flat-file, for the right kind of site

Two small projects this summer and this one. No database. That single fact removes SQL injection as a category, removes the credentials in a config file, removes the nightly dump job, and turns the site into a folder you can copy.

Content is Markdown with YAML front matter:

---
title: 'Responsive circles with CSS3'
date: '2012-08-31 17:13'
taxonomy:
    category: blog
    tag: [css, html]
---

That is a post I wrote in 2012, in a format I will still be able to read in 2040, with cat if it comes to that. A folder under user/pages/ maps straight to a route, so old URLs survive a migration without a redirect table.

Where it is the wrong choice: anything with accounts, anything with a search that needs an index, anything where the client publishes more than a few times a week. The moment content is data rather than pages, you want a database.

Filament, an admin panel I did not build

The other thing that surprised me this year. For an internal tool, the entire CRUD layer, forms, tables, filters, permissions, came from declarative resource classes rather than from templates I wrote. What used to be two weeks of admin scaffolding is now an afternoon, and the client gets a better interface than I would have hand-built.

The trade is that you are now inside somebody's opinion about how an admin should work. For an internal tool that is a good trade. For a customer-facing interface it is not, and I would not use it there.

Shipping AI, not just using it

Last year AI was in my editor. This year it is in production for a client: a pipeline that generates draft content from structured source material, pushes it through an API, and puts it in front of an editor for approval before anything is published.

Three things I would tell anyone building the same:

A human approves before publish. Always. Not as a policy gesture. Because the failure mode is confidently wrong text with your client's name on it.

Version the prompt like code. It is logic. It belongs in the repository, in the diff, and in the review.

Store the input and the output, both. When a result is wrong six weeks later, the only useful question is what exactly was asked, and you cannot reconstruct it afterwards.

Rebuilding this blog

Which brings me here. This site ran on WordPress since 2012. On one side of the scale: the writing. On the other: a core install, six plugins, seven themes, an uploads folder holding 135MB of which 125MB was analytics data, and a firewall-log directory that existed purely because the site was worth attacking.

The migration itself is a copy-and-paste job at this scale. The only clever part is getting the content out cleanly:

for id in $(mysql -u USER -p DB -N -e \
    "SELECT ID FROM wp_posts WHERE post_status='publish' AND post_type='post';"); do
  mysql -u USER -p DB --skip-column-names --raw \
    -e "SELECT post_content FROM wp_posts WHERE ID=$id;" > post-$id.html
done

Then convert each file by hand, because the HTML in a fourteen-year-old database is a museum. The 2012 posts are raw HTML with <pre> blocks. The 2014 ones are wrapped in block-editor comments. One still carried a <!--?prettify linenums=true?--> directive for a syntax highlighter that has not been maintained since 2015.

Two things I would tell myself before starting. Check what the uploads folder actually contains: du -sh said 135MB, and everything that was not analytics came to 9.6MB. And keep your URLs, because there is never a good reason to break them.

How this blog got written, honestly

One more thing, because leaving it out would be dishonest given everything above.

I did not sit down and write twenty-five years of posts from memory. I pointed an AI at my own history: the git logs of every project I still have, a backup drive going back to 2001, my mail archive, and a folder of company paperwork. It worked out what changed in each year from the evidence, and drafted the posts.

That is the same tool I use for client work, applied to myself, and the same rules applied.

The evidence was real, and it was mine. First ddev commit, 14 December 2019, with a message saying the config mirrors the production server. Icon fonts in four formats across every 2015 project. Nine sites in 2008 sharing a byte-identical directory structure. Hundreds of .diff files in a 2004 upgrade folder. I did not remember any of that. It was all on disk.

It got things wrong, confidently. It put the year I started using Claude at 2015, which is impossible. It called 2003 my first PHP, when I had been writing PHP for years and simply do not have those files any more. It read a copied boilerplate .gitignore as proof of which editor I used. Every one of those reads as plausibly as the true parts, which is exactly the failure mode I wrote about last year.

So I corrected it, repeatedly. The lessons it wanted to end each post with were not my lessons, so those came out. It framed the early years as archaeology, writing about my own work as though it were somebody else's excavation, so that went too.

The split ended up being: the machine found the facts and produced the draft, and I decided what was true and what mattered. Which is the same division of labour I would defend for any serious work, and I would rather say so than let you assume otherwise.

In short

So far: a flat-file CMS in production, an admin panel generated rather than built, AI shipped to a client rather than just used, and this blog rebuilt with AI reading twenty-five years of my own files back to me.

The year in commits

Commits by month in 2026: 1170 in total, peaking at 285 in Apr.
Jan 2026: 133 commits J Feb 2026: 89 commits F Mar 2026: 83 commits M Apr 2026: 285 commits 285 A May 2026: 136 commits M Jun 2026: 171 commits J Jul 2026: 272 commits J Aug 2026: 1 commit A Sep 2026: no commits S Oct 2026: no commits O Nov 2026: no commits N Dec 2026: no commits D