WordPress to Static With Claude Code: How I Rebuilt This Site in an Afternoon
- Category
- AI & Local LLM
- Posted
- July 11, 2026
- By
- Jacob Lloyd β written with AI assistance, post-project
- Read time
- 7 min read
In plain terms: The story of how this very website was rebuilt in one afternoon by an AI coding assistant, going from WordPress to a folder of simple files. The new site is faster, free to run, and has no login page for hackers to attack. It ends with the step-by-step recipe for doing the same to your own site.
This site used to run on WordPress. Now it's a folder of Markdown files and a 183-line Python script, built from a WordPress export by an AI coding agent in an afternoon. Here's how that went, and why the same workflow still runs the place.
- What it is: a from-scratch static site generator (Python + Jinja2) that replaced WordPress, plus a one-time importer for the old content
- What it costs: free. No plugins, no theme license, same cheap shared hosting as before
- What you need: your WordPress export (a WXR file), your uploads folder, and an AI coding agent β I used Claude Code
- What you end up with: flat HTML files, no database, no login page to get brute-forced
What you end up with
First, the receipts β the real terminal output from the day the rebuild landed:
$ python3 _build/build.py
Built 15 files (7 projects, 3 pages, 4 categories).
Fifteen HTML pages was the whole site at migration time: home, four category pages, seven project posts, three info pages. All pre-rendered, all plain files, zero server-side code running per request.
The WordPress export those came from was 572 KB and 123 items β posts, pages, image attachments, and a pile of abandoned drafts. Seven real posts survived the trip. The rest was junk the importer skipped on purpose.
And the machine that builds it fits in a table:
| Piece | Lines |
|---|---|
| build.py (the generator) | 183 |
| migrate.py (the one-time importer) | 166 |
| Templates (5 page layouts) | 139 |
| Partials (nav, footer, card) | 51 |
| site.yaml (all site config) | 36 |
| serve.sh (local preview) | 12 |
| .htaccess (server rules) | 57 |
| Total | 644 |
Add the CSS (322 lines) and the one JavaScript file (31 lines β a mobile nav toggle, that's it) and the entire site is under 1,000 lines. No node_modules, no build framework, no lockfile to argue with.
Part 1: getting off WordPress
I didn't hand-write any of this. I described what I wanted to Claude Code and it wrote the importer, then the generator, then the templates, while my job was reviewing output and saying "no, do it like this instead." File timestamps back this up: export in hand to a built, browsable site in under two hours. An afternoon, not a weekend.
The pipeline it landed on:
The export and the media mirror
WordPress exports everything through Tools β Export β All content. You get a WXR file β XML with your posts, pages, categories, and attachment records stuffed inside. That's the importer's only input.
The one thing the export does not include is your actual image files, just their URLs. So the uploads folder gets pulled separately β SFTP, a file manager, or a plain wget mirror β into assets/uploads/, keeping the same year/month structure WordPress used so the rewritten URLs resolve.
What migrate.py actually does
The importer is one-time but safely re-runnable. No XML library β just regex against the WXR text, which sounds fragile until you notice WordPress export XML is extremely regular, and grepping it is a lot less code than wiring up a full parser. For each post it pulls the title, slug, date, body, category, and featured image, then:
- Skips junk β empty posts and anything titled "(draft)β¦" never reach content/
- Maps categories to a short clean set of keys instead of WordPress's messy nicenames
- Resolves featured images via the attachment id, falling back to the first uploaded image in the body
- Rewrites media URLs from
wp-content/uploads/β¦to/assets/uploads/β¦ - Rewrites internal links β old permalinks become the new pretty URLs, using a slug map built from every post and page
- Strips Gutenberg block comments and deletes dynamic listing blocks outright β the build step generates category listings now, so the old baked-in ones would just sit there going stale
- Auto-generates excerpts β strip tags, truncate to 160 characters, use as card text and meta description
The interesting part: posts come out the other side as cleaned WordPress HTML sitting inside a Markdown file, not converted to Markdown syntax. The renderer passes raw HTML straight through, so nothing lossy happened to the old content β and new posts get written in plain Markdown. Both build fine from the same folder. The first time I opened a ".md" file that was 90% HTML tags I assumed something had broken. It hadn't.
What build.py does
This is the part that runs forever. It reads site.yaml for nav, categories, and links, reads everything in content/, and renders each file through a Jinja2 template into a real folder with an index.html inside β clean URLs with no server rewrite rules needed. Beyond the pages themselves it produces:
- A nav bar and footer built from data β add a category or a page and it shows up on its own
- Per-page title, meta description, canonical URL, Open Graph and Twitter tags
- sitemap.xml, robots.txt, and a web manifest, regenerated every run
- A cleanup pass that deletes stale output first, so a renamed post doesn't leave a dead page behind
- Zero runtime dependencies: self-hosted fonts, inline SVG icons, that one small JS file, nothing from a CDN
Adding a post is: drop one Markdown file in content/projects/, run the build. That's the entire workflow.
Preview, then deploy
Local preview is one script β build, then serve. Click through every page before anything goes near the real host.
./serve.sh
# builds, then serves the whole site at http://localhost:8099
Deploy is an rsync push guided by a manifest that lists exactly what ships (HTML, CSS, JS, images, fonts, .htaccess) and what's denied (the Python scripts, the Markdown source, the WordPress export, the templates). Server config adds compression, cache headers, security headers, and a hard deny on fetching any source file directly β so even if someone knows the generator exists, there's nothing to download.
Part 2: how this site gets maintained now
Here's what made the rebuild worth doing properly: the site doesn't get maintained by hand anymore either. The same agent workflow that built it keeps it current β including the update you're reading right now.
This revision started with an audit swarm. One crew fact-checked every published page and clicked every outbound link; another gathered screenshots and hard technical facts about how the site works. Everything fed into one written spec: build this, don't invent numbers, never publish a secret.
From there the work fanned out β a platform agent (category structure, a second language, search-engine plumbing), a designer, and a writing agent per article, this one included. A fast model drafts each page from its facts file. A stronger model edits for voice and accuracy. Then an adversarial pass tries to break it β rechecking numbers against the source facts, re-clicking links, hunting anything that reads hallucinated. Or, honestly, anything that reads like an AI wrote it. Which, in this case, one did.
Give your own agent this job
Sitting on an old WordPress site you don't want to keep patching forever? This is the recipe I gave mine. Describe each step to your agent and let it write the code.
- Export. WordPress admin β Tools β Export β All content. Park the WXR file where the agent can read it, e.g.
_build/source.xml. - Mirror the media. Copy
wp-content/uploads/from the old host intoassets/uploads/, keeping the year/month structure. - Ask for a migration script. Parse the WXR items, skip drafts and empties, map categories to a short clean list, resolve featured images (fall back to the first inline image), rewrite upload URLs and internal links, strip Gutenberg block comments, auto-generate excerpts, write frontmatter Markdown files.
- Ask for a generator. Read a site config plus the content folder, render through templates (base, home, category, post, page, shared nav/footer/card partials), output pretty-URL folders, generate sitemap.xml, robots.txt, and a web manifest.
- Rebuild the design from scratch. Don't port the old theme's CSS β one clean stylesheet, self-hosted open fonts, inline SVG icons, zero outside requests.
- Preview everything. A one-line script that builds then serves locally. Click every page.
- Write the server config. Compression, cache headers, security headers, deny rules for source files, a fallback for extensionless URLs.
- Deploy with a manifest. rsync the output, explicitly excluding source folders and scripts, then check the live URL returns 200.
- Keep the export. The WXR file stays in the project β re-running the migration script regenerates the content folder cleanly if you ever need a fresh import.
The whole runtime dependency list, in full:
pip install jinja2 pyyaml markdown
That's it.
Gotchas
- Forget to mirror the uploads folder and nothing warns you. The build succeeds while every thumbnail 404s. Found out the slow way.
- Output and source share a folder. The built HTML lands in the same project root as the Python and Markdown source β deploy separation is entirely the allow/deny list. Felt backwards at first; it's what keeps deploy a plain rsync.
- Renaming a category can strand a dead folder. The cleanup step has the category names typed into it directly. Rename one in the config, forget that list, and the old folder sits there published and orphaned.
- Dates sort as strings, not dates. Fine as long as every date is written
YYYY-MM-DD. Type one wrong and your newest post quietly sinks to the bottom.