After going through WordPress, Movable Type, Jekyll, and Middleman, I decided to build my own static site generator in PHP, with help from Claude Code. In this post I'll document how it works, its philosophy, and where it currently stands — plus how I'm using it to run my three blogs: garron.me/en, garron.me/es, and this one.
The idea is to have something lightweight that does exactly what I need. I think this is a great way to use AI at the stage the technology is at right now — God knows what will be possible with it down the road.
I already told the origin story — why I moved off Middleman, the AI-collaboration workflow, the ten build sprints — in I Asked an AI to Build My Blog Engine. This post is the reference version: what's actually in the GitHub repo, how the builder works under the hood, and a snapshot of where the project stands today.
Quick start
Everything runs inside Docker. Nothing is installed on the host — no PHP, no Composer.
# build the image
docker compose build
# build every site
docker compose run --rm builder build
# preview locally
# garron.me → http://localhost:8080
# garron.blog → http://localhost:8083
docker compose --profile preview up
| Command | Description |
|---|---|
build |
Build all sites |
build <site-key> |
Build one site |
clean |
Remove all output files |
watch [site-key] |
Poll every 2s and rebuild on change |
migrate [site-key] |
Dry-run: preview front-matter migrations |
migrate --write |
Apply front-matter migrations |
import <site-key> <src> --write |
Import Middleman source files into content/ |
Site keys: garron_me_en, garron_me_es, garron_blog. There's also a small Bootstrap 5
admin UI — create and edit posts, manage drafts, upload images, trigger a build, commit and
push to git — that runs separately at localhost:8081.
Project structure
garronpress/
├── bin/build.php CLI entrypoint (build / clean / watch)
├── src/
│ ├── Builder.php Build orchestrator (collect → render → derive)
│ ├── LayoutRenderer.php Twig chain renderer
│ ├── FrontMatterParser.php YAML front matter splitter
│ ├── Page.php Immutable page value object
│ └── MiddlemanImporter.php Middleman → GarronPress importer
├── content/ Markdown source, one subdir per site
│ ├── garron.me/en/
│ ├── garron.me/es/
│ └── garron.blog/
├── layouts/ Twig templates
│ ├── default.html.twig Root layout — HTML shell, nav, RSS autodiscovery
│ ├── post.html.twig Single post layout (extends default)
│ ├── posts-index.html.twig Post list with pagination
│ └── admin/ Bootstrap 5 admin scaffold
├── assets/ Static files copied to output on every build
├── output/ Generated HTML (git-ignored)
├── admin/ PHP admin web interface
├── caddy/Caddyfile.example Production Caddy config
├── config/sites.yaml Site definitions
└── docs/ Sprint-by-sprint documentation
How the builder works
Every build run walks the same two-pass pipeline per site, implemented in
src/Builder.php using league/commonmark for
Markdown and Twig for layouts:
Pass 1 — collect parse every .md file → Page[] collection
Compute posts = filtered + sorted by date; site['has_posts'] set
Pass 2 — render Markdown → HTML → layout chain → write .html
Derived posts index (paginated), RSS feed, sitemap
Assets copy assets_source (or assets/) → assets_output (or output/)
Root files copy root_source → assets_output, if set
It needs two passes because the posts index and RSS feed have to know about every post before writing anything — you can't paginate a list you haven't finished collecting.
A handful of small, single-purpose classes do the actual work:
| Class | Role |
|---|---|
Builder |
Orchestrates both passes for every configured site; loads sites.yaml, wires CommonMark and Twig |
FrontMatterParser |
Splits the leading --- YAML block from the Markdown body |
LayoutRenderer |
Resolves and renders the nested Twig layout chain for a page |
Page |
Immutable value object: front matter + rendered content + derived URL |
MiddlemanImporter |
Converts legacy Middleman source files into GarronPress content |
Migrator |
Normalizes Hugo/Middleman front matter in place (the migrate command) |
Layout system and templates
Layouts are nestable, Jekyll-style. Content declares a layout: in its front matter, and a
layout can itself declare a parent. The builder resolves the full chain at build time and
renders inside-out: content → post → default → final HTML.
---
layout: default
---
<article>
<h1>{{ page.title }}</h1>
{% if page.date %}<time>{{ page.date|date('F j, Y') }}</time>{% endif %}
<div class="post-content">{{ content|raw }}</div>
</article>
That's layouts/post.html.twig in full. It extends default.html.twig, which owns the
<html> shell, meta tags, Open Graph, RSS autodiscovery, and hreflang links between the
English and Spanish versions of a page.
Templates have access to a small, predictable set of variables:
| Variable | Type | Description |
|---|---|---|
content |
string | Rendered HTML of the inner content |
page |
array | Front matter of the current page |
site |
array | Site config entry from sites.yaml |
posts |
array | Posts on the current page — posts-index.html.twig only |
current_page / total_pages |
int | Pagination state — posts-index.html.twig only |
prev_url / next_url |
string|null | Adjacent page URLs — posts-index.html.twig only |
On URLs: new content should set a url: key in front matter (e.g. /blog/my-post.html) —
it keeps URLs portable across future migrations. Legacy content with no url: falls back to
mirroring the source path, .md → .html. Yes, .html — ugly URLs, some would say, but I
come from a time when a URL was literally a file sitting on disk, extension included.
Site configuration — config/sites.yaml
| Key | Required | Description |
|---|---|---|
title / description |
yes | HTML <title> and RSS metadata |
source / output |
yes | Content and generated-HTML directories |
base_url |
yes | Canonical base URL for this site |
language |
yes | BCP 47 tag — en, es |
layout |
yes | Default layout for pages without an explicit layout: |
assets_source / assets_url / assets_output |
no | Per-domain asset tree; defaults to assets/ and base_url |
root_source |
no | Domain-level files (robots.txt, 404.html) copied after every build |
default_image |
no | Fallback og:image / twitter:image |
posts_per_page |
no | Defaults to 10 |
posts_homepage |
no | If true, the posts index also writes to the site root index.html |
garron.me/en and garron.me/es live on the same domain, so they share assets_output —
one CSS file, one set of images, two languages.
Design principles
- Markdown in, static HTML out — no database, no plugins, no runtime.
- URL-driven output — the
url:front-matter key controls the output path. - Nestable layouts — Jekyll-style layout chains, resolved at build time.
- Portable front matter — every key survives editor round-trips.
- Legacy URL compatibility — Middleman and Hugo sources import cleanly.
- Docker-only execution — zero host dependencies.
- Boring, maintainable PHP — no framework magic, no hidden state.
- Filename is the source of truth — dates and slugs derive the URL, not front matter.
Where things stand today
As of this post: 172 commits since the project started on May 22, 2026, across the ten
sprints described in the other post. The three sites add up to 928 Markdown files — 740 in
garron.me/en, 188 in garron.me/es, and 98 here on garron.blog.
The biggest ongoing effort is migrating content from the old go2linux.org archive: 149
posts moved into garron.me/en/go2linux/, 85 of them with Spanish translations live in
garron.me/es/gnu-linux/ so far — closing that gap, one batch at a time, is the current
weekly routine. The Caddy config in front of it all carries 69 redirects, mostly legacy
URLs from three prior platforms that still get traffic and shouldn't 404.
I'll come back and update this post at real milestones — not every commit, just when something meaningful changes — rather than turning it into a raw changelog.
The code is on GitHub at github.com/ggarron/phile (the admin interface isn't included in the public repo) if you want to see how it's put together or use it as a starting point for your own.