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.