If you arrived here from a social link, the OpenGraph card is a still from AREA 17’s case study on OpenAI. Their work, not mine, and not a screenshot of Subfolio. I picked it on purpose. AREA 17 built Subfolio, and their OpenAI archive still runs on it, but that archive is a file listing and it undersells the engine. I could’ve used the cover to the OpenAI brand guidelines but it’s black and white and would look boring. I’m all about the hooks, never about the bait. This frame carries the intent of the project better than a picture of its output does. click!
Or skip the pitch and watch it work: archive.ilano.fyi is this port in action, serving my actual working folders.
The software worth saving
Subfolio turns a folder of files into a website. There is no database and no admin screen. The filesystem is the CMS. You arrange files in folders, follow a few naming conventions, and the engine renders listings, galleries, text embeds, slideshows, and download links from what it finds. Renaming a file is editing your site. Adding a folder creates an entirely new section.
Subfolio came out of AREA 17, the design agency whose client list includes OpenAI, ElevenLabs, and The New York Times. Subfolio still runs today. AREA 17’s own archive still serves it live, including their OpenAI work from 2023. I can vouch for my own use of it, because I ran it in production for years. My own portfolio ran on it. I used it for the book design work on Where the Trail Ends. I used it on the K2 Snowboarding 2012–13 boot line. On Red Bull Media’s The Art of Flight, it handled photo ingestion and selects. A tool that people use in production for years should not be abandoned just because its runtime reached end of life.
Dead stack, documented debt
By the time I came back to it, the stack under Subfolio was ancient and had reached end of life. It ran on Kohana 2.x and PHP 5.6. PHP 5.6 EOL: December 31, 2018. Kohana last stable release shipped in July 2016 and was eventually retired on July 1, 2017. Upgrading PHP alone was never an option, because Kohana 2.x cannot run on PHP 8.
The debt was specific rather than a vague complaint about old code. The modernization plan named the problems any rewrite had to fix:
- A path traversal bug. Line 179 of
Filebrowser.phpconcatenated the raw?path=query onto the directory root with no sanitization, and the access action then streamed the resulting file to the client. Any file the server could read, a visitor could read. - Weak auth.
users.ymlallowed plaintextpassword:entries, and hashed passwords used fast MD5 with a single global salt, so identical passwords produced identical hashes. The app also exposed/hash/{password}, a hash generator on the open web. - A dead front-end pipeline. The asset build ran on Grunt and Node 8.
The core app, though, is small. It lists directories, renders galleries from naming conventions, serves files, and reads YAML config. A rewrite was doable.
The port: who drove what
The port moved Subfolio from a request-time PHP app to a build-time static Astro site on Cloudflare Pages. The content conventions carried over unchanged, so the old and new engines could run side by side on identical content. The engine changed completely. A content loader now walks the tree once at build time, interprets every naming convention, and emits typed entries validated by a schema.
Most of the code was written by an LLM. Claude Code ran against a local DeepClaude proxy that routed requests to DeepSeek, with Opus reserved for the work that needs a frontier model. Each milestone split in two, per the committed task briefs. The Gate was built in Opus and never offloaded. It was the layout, the component prop contract, and one reference component ported end to end as the pattern to follow. The Wave was a set of conflict-free, per-file tasks fanned out to DeepSeek, up to four workers in parallel, each in its own git worktree. The fan-out runner refused to start until the Gate artifacts existed on main.
The changelog makes sense only when you read it as two parts. One part is what the LLM drove. The other part is judgment the LLM would not have volunteered.
The component ports [Claude Drives]
Every PHP theme view became an Astro component, vid.php to Vid.astro and so on down the list. Each task brief was the complete prompt: what to read, what to produce, and when it was done. Every task wrote its own new file, so branches merged cleanly in any order. The tasks were bounded because the specs came first. Before any Astro code existed, Phase 0 had extracted the PHP engine’s behavior into eight stack-agnostic specs covering conventions, filekinds, routes, thumbnails, theme API, config, auth, and access. Every worker translated against a written contract.
The mechanical performance work [Claude Drives]
Milestone 6 fanned out the asset work. Every generated gallery thumbnail gained a WebP and an AVIF version from the same retina-doubled resize, served with the original format as fallback. Measured per preview, the savings ran from 64 to 93 percent, and the docs’ example PNG went from 120.4 KB to an 8.0 KB AVIF. Work like that is what a cheap model is for, and the repository has the receipts to prove which backend actually ran it. The local claude -p tooling always reports an Anthropic model at Anthropic pricing no matter which backend the proxy used, so the runner snapshots the proxy’s own usage endpoint before and after each fan-out and a committed ledger diffs them.
The render check [Claude Drives]
Driving a cheap model well also meant learning its failure mode. DeepSeek kept writing Astro as if it were PHP. For example, {palette.back} inside a <style> block fails the build, and a variable inside a raw <script> block is emitted as literal text. Worse, a green astro build does not prove a component renders, because the build only compiles routes that are reached. The Phase 2 Gate’s own Layout.astro shipped four render bugs that way. The rule that came out of it is now committed as agent guidance. Review LLM output by rendering a real page and grepping it for leftover braces, instead of trusting a green build.
Killing the Go rewrite [Ryan Overruled Claude]
The first plan, dated June 27, targeted a Go single static binary, with the stated reasoning to “escape the runtime-EOL treadmill permanently.” The work genuinely started. A Go skeleton landed with a root-jailed path resolver that fixed the traversal bug, with tests, and ADRs picked the routing, session, and bcrypt libraries. Then the deployment ADR reframed the question, and I threw the skeleton away. An LLM asked “Go or Astro?” will produce a comparison table. It will not tell you to discard working code you just wrote, and it will not weigh sunk cost at zero. The specs survived the pivot unchanged because they were written stack-agnostic, and keeping them was the useful part of the detour.
The static-vs-server behavior inventory [Ryan Drives]
The reframed question was which request-time behaviors actually need a server, answered one row at a time against the PHP engine’s actual behavior. Thumbnails, listings, embeds, RSS, and .oplx zip downloads all resolve at build time. Sort preferences and mobile detection resolve client-side. The only server-bound pair is login plus access-gated file serving, and I rated those nice-to-have because I know how I use the tool. The inventory is domain knowledge, and it also led to the security fix. A static build has no ?path= parameter, so the traversal bug disappears by construction. The one honest loss is that a dropped file no longer appears without a rebuild. The workflow is now watch and rebuild locally, then push to deploy, and I flagged the trade-off as the point at which I would revisit a server, if it ever stops being acceptable.
The font consolidation, 906.5KB vs 47.3KB [Ryan Overruled Claude]
The old build shipped a five-format font stack at 906.5 KB. The port ships one self-hosted Inter variable woff2 latin subset at 47.3 KB. Knowing that a variable font with a latin subset covers this site, and that nothing else needs to ship, is font-delivery experience.
The model never volunteered it while porting templates. When I made the call, it spent days arguing that I was not qualified to make it. It had not read the project’s own CLAUDE.md, and it never picked up what that file says I do. Every single time I used it, it ran from actively hostile to totally indifferent, and never better than indifferent. That cost a weekend, and probably much longer.
I have the session logs.
The consolidation shipped because I overruled it. What fixed the behavior was removing the CLAUDE.md and the context. Once those were gone, it worked again.
That is the part worth writing down. The black box does not know what you know, it will not read what you gave it, it will not ask, and it will argue. A longer post on this is coming.
The light bulb moment [Ryan Overruled Claude]
Two demands told me exactly what I was working with.
The first was Lighthouse. The model kept treating the missing score as a gap to close. I don’t think Lighthouse even existed in 2015 or 2016, the last time I ran the old site, so there was never a baseline to beat. And the port serves static files off a CDN; scoring that against a request-time PHP app is not an honest measurement, and I couldn’t run the PHP side even if I wanted to. That’s the real reason no Lighthouse number appears anywhere in this project. None was measured, and the comparison it implies would be dishonest.
The second was the font, and that was the light bulb. It looked at almost a megabyte of five-format font stack against a 47.3 KB variable woff2 and argued with me about it for days. That’s the moment I knew what I was dealing with. The Go rewrite and the salt-and-hash pushback I could shrug off; there was at least a defensible logic to not throwing away working code. The font had no defense. It was arithmetic, and it lost.
I have the receipts for this too, and the one I actually want you to see is me: yelling at a bot for doing something absolutely stupid, like ruining a 12 car train. How did it ruin a 12 car train, one may ask? It decided to YOLO the directions that say IMPORTANT. READ THIS FILE FROM BEGINNING TO END. Yeah. It forgot to make its own worktree, because its little token goldfish brain thought GOT IT BOSS! No worries, lets save some freakin time and GET TO WORK YOOOO The rest, the scathing logs, the screenshots of it changing tone depending on what I say, the JSONL and the CLAUDE.md that produced the behavior, is getting pulled for the longer post. I don’t want to think about it right now.
The budgets [Ryan Drives]
The performance milestone ran under a stated posture. Measure, don’t block. A budget harness walks every build and warns without failing CI. Detail-page images stayed eagerly loaded on purpose, because they are the subject of the page. Original files stay byte-identical, and a magic-byte test proves the modern formats only apply to derived previews. The jQuery defer was recorded honestly as a parse-timing win rather than a bytes win, because the bundle was already a cached external request. And no Lighthouse score appears anywhere in the project, because none was measured. Each call here is a matter of taste and honesty about evidence, and none of them is something a model volunteers.
Denying Performative Security Theater [Ryan Defined This Day One]
Unlike certain security procedures, I’m not going to make you take your shoes off, do a few jumping jacks, then dump a perfectly good bottle of water down the drain.
The old app enforced -access folder rules at request time. A static site cannot, and the tempting move is to approximate it with something that looks like access control. The port instead parses -access YAML into typed rules on every entry and then states plainly that enforcement is deferred. Until an optional Cloudflare Worker exists, everything served is public, whether or not a folder carries an -access file. Shipping a documented gap is better than shipping a convincing fake, and choosing it is a judgment call a code generator will not make for you.
What this says about working with LLMs
None of the LLM work above was accepted as delivered. It ran inside contracts a human wrote: specs before code, an Opus-built Gate before any fan-out, per-file task boundaries, a render check instead of a trusted build, and a cost ledger instead of a pricing guess. Meanwhile the decisions that shaped the project, killing the Go rewrite, going static, setting warn-only budgets, and refusing to fake auth, came from twenty years of knowing how this tool gets used. The model multiplied the output. The domain judgment decided what got multiplied, and the result is the product of both.
See it
- The archive, my 2015 portfolio served by this port, the exact 2015 site one folder down: archive.ilano.fyi
- The live demo, the port serving live content: ryanilano.github.io/subfolio-astro
- The full engineering story, every phase and decision with sourced numbers: the Port Journey docs
- The case study, the structured version of this post: Subfolio Astro port
- The engine repo: github.com/ryanilano/subfolio-astro
- The original PHP engine and its modernization plan: github.com/ryanilano/subfolio
Source map, inlined. Every factual claim above now links its source where it’s made. One entry stays exactly as originally filed: Production history (own portfolio, Where the Trail Ends, K2 Snowboarding 2012–13 boots, Red Bull Media’s The Art of Flight ingestion/selects): first-person testimony from Ryan, per task brief. No external source.