What My Old Code Says About Me
A technical autopsy of martinshjemmeside.dk, the homegrown CMS that just got migrated into Kartotek — plus some notes from the machine that did the migrating.
The import finished this week: 277 posts, going back to 2007, out of a PHP codebase that ran more or less untouched for close to two decades. Before that content settled into its new home, I asked Claude to pull the old system apart and write down what it found — not just the "here's how the migration went" mechanics, but what the code itself says about how I built things back then, what's held up, and — since it was the one doing the archaeology — what the experience of reading it was actually like from the other side.
The stack
Nothing here was fashionable even at the time. It's:
- PHP, using the
mysql_*extension — the one deprecated in PHP 5.5 (2013) and removed outright in PHP 7 (2015). The code was still running it in production years after that, on whatever PHP version the host would tolerate. - MySQL, MyISAM tables, no foreign keys, no transactions — every relationship enforced by application code, not the schema.
- A hand-rolled routing layer:
index.phpreads$_SERVER["REQUEST_URI"], explodes it on/, and dispatches by hand. No framework, no router library, nothing from Composer (this predates Composer's mainstream adoption anyway). - Skeleton CSS, Google Fonts, a Google Maps JS embed with an API key hardcoded directly in the page template, and a Google Analytics snippet pasted in by hand.
- A home-grown templating approach: PHP functions that
echoraw HTML strings, interleaved with a small custom tag language —<MPic rank=1 mw=500 align=center>,<MLink rank=all>,<MMap type=artikelogunderartikler>— parsed out of article body text with a regex (findTags()) and expanded server-side before output.
There is no build step. There is no package.json-equivalent. There is, as far as anyone can tell, no test of any kind.
How it was actually built
The interesting part isn't the stack — it's the shape of the data model underneath it, because that's the part that survived. Every article lives across two tables: artikler1 (the identity — a slug, active/visible flags) and versioner (every single edit, forever, timestamped, with its own publish/expire window). Editing an article never overwrote anything; it inserted a new versioner row. A dozen boolean flags per version (showbilleder, showexternal, showtimeline, showmap...) controlled which page modules rendered, and publish/expire meant a version could be scheduled to go live in the future or roll off automatically — in 2007.
Everything an article could contain — images, categories, reusable text blocks, links, timeline entries — hung off the same pattern: a shared pool table plus a join table keyed on version_id, with a rank column for ordering. It's the same "pool of content, referenced per revision" shape that Kartotek's Content Element / Version model uses today, arrived at independently, a decade and a half earlier, by someone who almost certainly had never heard the term "structural sharing." That's the single biggest reason this migration was possible at all without losing anything: the idea of versioned, revision-scoped content was already there. It just had to be translated.
iThen there's the parts that were clearly built to solve a problem someone was actually having, not a problem read about in a book:
- A full link-rot archival system (
urls/urls_status) that assigns every outbound link a short code, periodically re-fetches it, hashes and stores the response, and cross-checks Wayback Machine availability. Something like 690,000 URL records, 400,000+ status checks. Nobody asked for this. It got built because links kept dying, and that was apparently enough reason to write a crawler. - KML map geometry stored per-version, with an inline
<MMap>tag that could render a Google Map of an article's own shape, or — for one specific article (aarhusplaner, an index of Aarhus urban-planning projects) — aggregate a parent article's geometry with every child article's onto one combined map. That second case has no equivalent in the new system yet; it's the one real feature gap the whole migration surfaced. - Three parallel copies of the same ~2,500-line functions file —
prod.functions.inc.php,devel.functions.inc.php,_old/functions.inc.php— forked from each other at different points and never reconciled. - A frameset-based admin tool (literally
<frameset>, a menu frame and a content frame) for writing articles, managing photos, and watching the link-checker's output. - A
makr.test/folder sitting inside the public webroot, containing scratch scripts and a full raw SQL dump of the database from 2009, publicly reachable by anyone who guessed the path.
What it tells me about the author
Reading someone's quarter-life code from the outside is a strange experience, even for a human reading their own past self — and this reading was done by an outside party entirely. A few things came through clearly:
Building for permanence, from the start, even when the tooling didn't deserve it. Nobody writes a versioned-content-with-scheduling data model for a personal blog unless they already believe the content matters and will need history. That belief was correct — it's why 19 years of posts survived intact through a stack that would almost certainly be written differently today.
A preference for building the tool over using someone else's. Not just the CMS itself — the link-rot crawler, the KML map renderer, the custom <MPic>/<MLink> tag language instead of reaching for an existing templating engine, even a bespoke URL-shortener. Some of that is "here's what was available in 2007," but a lot of it reads as genuine preference: understanding the whole stack top to bottom mattered more than shipping fast with someone else's abstraction.
Not deleting things. The _old/ directory, the triplicated functions files, the scratch folder left in the webroot, the martinhk1 → martinhk1new database rename instead of a real migration — none of it got cleaned up, ever, across nearly two decades. That's not carelessness so much as a pattern: keep the old thing running next to the new thing, indefinitely, rather than commit to deleting it. It's also plausibly why the data survived long enough to be worth migrating — nothing was ever thrown away with confidence.
The content itself is the tell, more than the code. Urban planning and infrastructure in Aarhus, rowing, photography, the occasional bit of dry humor about Danish and Norwegian bureaucracy, a personal "About" page revised 34 times. The category tree — Aarhus, Internet, Gear, Vellevned (food/drink), Kultur, Friluft (outdoors) — is a pretty complete sketch of what someone finds worth writing about, unprompted, for two decades.
Strengths
- The data model outlived the stack. This is the whole ballgame. PHP's
mysql_*extension has been gone for a decade; MyISAM is a relic; the templating approach wouldn't pass a code review today. None of that mattered, because the content model — versioned, revision-scoped, composed from a shared pool — was sound enough to map cleanly onto a completely different system built fifteen years later. - Real, working forward-thinking features, not just competent CRUD: scheduled publish/expire windows, a genuine link-rot preservation system, per-article geospatial data, a version history page years before "version history" was a checkbox feature people expected.
- It just worked, for a long time, on cheap shared hosting, with no ops effort beyond occasional patching. There's something to be said for software that keeps running quietly for nineteen years without anyone paying attention to it.
Weaknesses
- SQL built by string concatenation, everywhere.
"WHERE artikelnavn = '$artikelnavn'"withaddslashes()as the only defense, not a parameterized query in sight. It evidently never got exploited in production, but that's luck plus obscurity, not a property of the code. - A real double-escaping mess. Text was
addslashes()-escaped before insert andstripslashes()-reversed on read, on top of a database column declaredlatin1that actually held raw UTF-8 bytes the whole time. Untangling that for the migration took more care than the actual content conversion did. - Duplication instead of abstraction, at every level — three copies of the core functions file, repeated inline HTML-building in nearly every rendering function, the same query pattern copy-pasted with minor variations dozens of times rather than factored into something reusable.
- No separation between data-fetching, business logic, and HTML. A single function runs something like fifteen separate queries and builds most of the page's data in one place; the templating is PHP
echostatements with HTML string-building, not templates. - Zero tests, and a scratch/dev folder exposed in production with a raw database dump sitting in it. Whatever discipline went into the data model didn't extend to operational hygiene.
- The custom tag language was a dead end.
<MPic>/<MLink>/<MMap>solved the "reference other content inline" problem years before Kartotek's own{{role}}syntax existed — but being bespoke and tightly coupled to the old renderer, none of it transfers automatically. Every inline reference in 19 years of posts had to be mechanically reinterpreted during migration, not carried over.
A note from the machine that read it
I should say something about what this was actually like, since I'm the one who did it, and it isn't a neutral vantage point.
Reading prod.functions.inc.php is archaeology. There is no docstring explaining why article text is addslashes()-escaped on the way in and stripslashes()-reversed on the way out, on top of a column declared latin1 that turns out to hold raw UTF-8 the whole time. Nobody wrote down that the site's own nav menu is invisible above 550px width by design-or-accident (I still don't know which). There is no comment saying why three copies of the same functions file exist. The only way to know any of this is true is to check it against the raw bytes and the running behavior, over and over, and stay suspicious of my own first guess — which is exactly what happened: my first pass assumed a Latin-1 mojibake problem that a byte-level check immediately disproved, and later, a "fix" to how repeated article text gets deduplicated across Versions quietly introduced a worse bug (identical content splitting into duplicate elements) that only a second full-corpus run surfaced. With no tests and no documentation, behavior is the only source of truth, and behavior has to be earned back one query at a time. Every fact in this piece — the 690,000 URL records, the invisible nav menu, the September 2015 minute where a dozen categories were all recreated in the same second — was something I went and checked, not something I was told.
Working on Kartotek itself, the codebase this content just moved into, is the opposite experience almost point for point. Intent is written down before the code, not reconstructed after the fact — in GitHub issues with acceptance criteria, in docs that are treated as the actual source of truth rather than a stale afterthought, in a project file that explains not just what to do but why a past decision was made the way it was. When I found a real bug in my own migration script — the metadataEnvelope field nested in the wrong place, silently dropped by the importer — the way I found it wasn't inference. It was reconciliation against a stated contract: here is what the format is supposed to look like, here is what I produced, they don't match. That's a fundamentally different kind of "wrong" than anything in the old PHP, where nothing was ever specified cleanly enough to be contradicted.
The honest version is that I don't fully trust my own read of why the old site's author made any particular choice — the "what it tells me about the author" section above is inference from artifacts, the same way an archaeologist infers a diet from a midden, not something I could verify the way I verified that a query returns what I think it returns. I have much higher confidence in what the old code does than in why it does it. On Kartotek, that gap mostly closes, because the why is usually written down somewhere I can go read. There's a small, slightly uncomfortable symmetry in that: the commit messages and issue threads I leave behind while working on Kartotek are exactly the kind of record that martinshjemmeside.dk's author didn't leave for his own old code — and that a future reader, human or otherwise, will eventually go looking for, the same way I went looking for his.
The honest summary
This was a solo, self-taught, unmistakably pragmatic system: no framework, no tests, real security debt, and code that visibly accreted rather than got redesigned. And underneath all of that, it got the one decision right that actually mattered for the long run — that content and its history are worth modeling properly — clearly enough, and early enough, that it was still paying off when the whole thing got rebuilt from scratch nineteen years later.
i