Welcome back!
In the last entry I introduced Zyphora, my self-hosted CMS — the whole "publishing without the bloat" idea. Today I want to get a bit more concrete and walk you through a feature I just shipped, because it turned out to be one of those small things that's deceptively tricky to get right: updating a theme via zip upload.
The Problem
Zyphora already let you install a theme by uploading a zip. But once a theme was installed, that was kind of it. If you wanted to ship a new version, your only option was to delete the old theme and install the new one from scratch — which is exactly the kind of clunky, foot-gun workflow I built this CMS to avoid.
So I wanted a real Update action: upload a new zip, and the existing theme gets replaced in place. Sounds simple. It is not, if you care about not destroying someone's live site in the process.
Why "Just Overwrite the Folder" Is a Trap
The naive version is: delete the theme directory, extract the new zip into the same spot, done. The problem is everything that can go wrong in the middle of that:
The new zip turns out to be a zip bomb and fills the disk halfway through.
A template in the new theme has a syntax error and would explode on the next page render.
A file inside the directory is locked (hello, Windows) and the move fails.
In every one of those cases, the naive approach leaves you with a half-deleted, half-extracted theme and a broken site. Not great.
The Approach: Stage, Then Swap
So instead of overwriting in place, the update does a stage-then-swap:
Extract and lint the new zip into a hidden staging directory (
.staging-<slug>-<uuid>) right next to the live theme. Same volume means the eventual move is fast and stays roughly atomic.Move the current live theme aside into a hidden backup directory.
Move staging into the live slot.
Delete the backup.
The key bit is step 3's safety net: if moving staging into place fails, the backup gets moved back, so the previous install is restored and the site keeps working. And if even the rollback fails, the error message tells you exactly where the backup folder is, so you can recover by hand instead of being left guessing.
To make all this reusable, I split the old monolithic installFromZip into two clean pieces:
parseThemeZip()— validates the zip and thetheme.jsonmanifest without writing anything to disk. Throws human-readable errors so the admin UI can show them verbatim.extractZipToDir()— does the actual extraction, with the zip-bomb size guard, per-entry path-safety checks, and a lint pass on the templates. If anything fails, it removes the partial directory before re-throwing — so the destination is either fully populated and lint-clean, or it doesn't exist at all.
Both installFromZip and the new updateFromZip now build on those, which means the validation and safety logic lives in exactly one place.
A Few Guardrails
updateFromZip is deliberately strict about what it refuses:
The bundled default theme can't be updated this way — its source of truth is the codebase, not an upload.
Mismatched slugs get rejected. If the zip's manifest slug doesn't match the theme you're updating, that's almost certainly a mistake, so you get told to delete and use the install form instead.
Missing targets are rejected too — if the theme isn't installed yet, you should be installing, not updating.
The Sneaky Little Bug I Headed Off
Here's the one that would've bitten me later: the theme registry scans the themes directory to build its list. With staging and backup folders now living inside that same directory mid-update, a scan that happened at the wrong moment would have registered a phantom theme for the staging dir.
The fix is one line — scanThemes now skips any dot-prefixed directory — but it's the kind of thing that's invisible until it isn't. I'd rather close that door now than debug a ghost theme appearing in the admin panel three weeks from now.
The UI Side
None of this matters if it's annoying to use, so each non-bundled theme in the admin panel now has a collapsible "Update from zip…" section: pick a file, hit Update, confirm the "this replaces the current files" prompt, and you're done. On success it even tells you the version jump — Updated "X" from v1.2.0 to v1.3.0 — which is a small touch but exactly the kind of feedback I like getting from my own tools.
What's Next
This kind of work — boring-sounding plumbing with a lot of careful failure handling — is honestly some of my favorite stuff to build. It's invisible when it works, and that's the point.
Next up I want to dig into the theme system itself: how templates get linted, why I render the way I do, and where I think it's still too rigid. More on that soon.
Thanks for reading — see you in the next entry!
[ COMMENTS · 0 ON FILE ]
No comments on file yet — be the first to transmit.
Open a return channel