Editor, transplanted.
Every product has a component that everything else leans on. For a documentation platform, it is the editor and the format it saves to. Every page your readers see, every export, every PDF, every search result, every AI suggestion, every GitHub sync flows out of how a page is authored and stored.
We decided to replace that component. Not patch it, replace it: the editor, the storage format, the link model, the history system. And we did it so that no customer ever saw a maintenance window, lost a byte of content, or had to lift a finger.
This is the engineering story of that migration. Internally we called the new editor Swish (it is a codename, not a product name, and you will see it throughout). The short version: we moved millions of documents from a decade-old text-plus-plugins format to a single structured document model, ran both systems side by side for the entire rollout, drove the whole thing through asynchronous workers so nothing interactive ever waited on the migration, and gated every project behind a feature flag we could flip forward or roll back per customer.
Here is what was actually involved.
1. What we were replacing, and why
To appreciate the move, you have to see where we started.
We built the original editor in 2017. At the time there was almost no open-source editor we could adopt wholesale. We needed live Markdown syntax as you typed, rich interactive components (callouts, tabs, code blocks, synced content), a formatting toolbar, a clean split between an edit mode and a reader mode, and all of it running inside Angular. Very few editors of that era were patchable to all of those requirements at once. So we took the best contenteditable foundation available, MediumEditor, and patched it, hard, until it did what we needed.
It worked. For years it gave our customers a better authoring experience than most of our competitors. But contenteditable is a browser primitive, not a document model, and building a serious editor on top of it means fighting the browser itself. Chrome behaved one way; Firefox, Safari and Edge each behaved differently, and every one of them needed its own special handling. We optimised for Chrome and kept patching around the rest. Patch over patch over patch. The result was genuinely capable, but it was never the delightful, dependable editing experience we always wanted to give people.
That foundation is also where a whole class of familiar frustrations came from, the ones we described when we announced the new editor: text that quietly changed after you saved it, underscores turning into italics, stray asterisks appearing after a save, empty lines silently removed, undo that did not quite work, and paste from Google Docs or Confluence or Notion arriving as a wall of plain text or broken markup. The underlying model also made some things simply hard to build, like placing blocks inside a list, or letting an AI assistant edit in the same structured format an author uses.
Underneath the editor, the problem went deeper than the browser. A page was not one thing, it was two coupled fields:
content, our in-house dialect of Markdown we call "darkdown," with inline token references sprinkled through the text:$plugin[id]for a block element,$link[type,id,title]for an internal link,$inline[...]for badges and icons,asset:<id>for an image.plugins, a separate JSON array holding the actual structured blocks (callouts, code blocks, tables, tabs, cards) that those$plugin[id]tokens pointed at.
And because a page can be a draft as well as a published version, that was really four fields: content, draftContent, plugins, draftPlugins.
This model held up for years, but it had structural problems that no amount of patching fixes:
- The text and the structure could drift apart. A
$plugin[7]token in the text and a plugin withid: 7in the JSON were joined only by convention. Two sources of truth for one document. - Links carried IDs, but there was no link graph. An internal link stored an ID, not a URL, and resolved to the current slug at render time. That part is genuinely great, and customers love it: rename or move a page and every link to it keeps working, with no rewrites and no broken links. The limitation was not the dynamic resolution, it was that nothing tracked what linked to what. Reverse-link lookups and broken-link detection meant full-table regex scans of every page's content, and an opaque ID token does not translate cleanly to a plain Markdown link in a Git repo or to an AI agent reasoning over real links. Docs-as-code and AI assistants need a real, queryable link graph; the old model never had one.
- Everything resolved late. Synced (reusable) blocks, audience-gated conditional blocks, and asset URLs were all expanded at read time, on every request.
- Recursive plugins were genuinely hard. A synced block could contain other plugins, including other synced blocks, and conditional blocks and table cells wrapped still more content, so expanding a page meant walking that nesting recursively. That machinery was delicate. It demanded extensive care and a fresh round of testing every single time we touched it, because one subtle mistake could silently mangle deeply nested content or send the expansion spinning in a loop.
We first started building a replacement back in 2022. The idea was never the blocker; the surface area was. Doing this properly means every one of the downstream systems below keeps working, with zero downtime and zero text loss, and there was never enough runway to carry all of it at once. What changed is how the work gets done. With AI agents carrying a large share of the implementation and the verification, a migration that would traditionally have taken a year or more, at lower quality, was brought close to completion in about four weeks.
2. The core decision: one canonical document
The new model collapses those four fields into a single structured JSON document, a tree of nodes and marks, the same shape the Swish editor manipulates directly. Every one of the 13 legacy plugin types became a node or mark in a schema of roughly 28 node and mark types: callouts, code blocks with language tabs, code-steps, tables, tabs, cards, synced blocks, conditionals, images, video, custom HTML, GitHub-embedded code, plus inline atoms like variables, badges, icons, glossary terms, and the marks for bold, italic, link and comment.
One document. No parallel plugin array. No token-and-target to keep in sync. That single model is also what unlocks the things the old one made hard: blocks nested inside lists, richer tables with merged cells, standalone code blocks, accordions, and an AI assistant that edits in exactly the format an author does.
A deliberate and important sub-decision: Markdoc is the interchange format, never the stored one. Markdoc (the open document format) is what we push to and pull from customer GitHub repos and what import and export speak. But the canonical truth on our side is always the JSON document. Converting out to Markdoc is for sync and export; the database never stores Markdoc. This one rule, canonical JSON in, Markdoc only at the edges, is what kept the blast radius of the migration containable.
3. Running two systems at once
Here is the constraint that shaped everything: at any given moment during the rollout, some projects were on the old system and some were on the new one, and a single project had to be able to move between them, forward or backward, without downtime or data loss.
You cannot do that with a big-bang cutover. You do it with a seam.
The storage seam
We added the new document to the database as gzip-compressed binary columns (body and draftBody) alongside the untouched legacy content and plugins columns, plus a single per-project flag: uses_swish.
Every read and write of that compressed body goes through exactly one seam in the code, a reader that decompresses and a writer that compresses. To the rest of the application, those bytes are opaque; a direct column read is a bug by definition. That single chokepoint is what let us swap the storage backend, add history deltas, and change the encoding without touching a hundred call sites.
The read path branches on one flag:
uses_swish = false: serve the legacycontentandplugins, rendered exactly as before. Byte for byte unchanged.uses_swish = true: decode the stored JSON body and serve that.
The one-way ratchet
The subtle decision here was not to dual-write. Once a project flips to the new editor, saves write only the new body. The legacy columns freeze at their pre-flip state.
That sounds risky, since you are no longer keeping the old format up to date. It is actually the opposite. The frozen legacy columns become a pristine, immutable recovery source. They are exactly what the page looked like the instant before cutover, and nothing can corrupt them because nothing writes to them anymore. Rollback within the retention window means flipping the flag back and serving that frozen state. No replay, no merge, no reconciliation, just a known-good snapshot that was never touched.
The cost of the ratchet is honest and bounded: a rollback does not replay edits made after the flip. We decided that is the right trade, because a simple, provably-correct recovery beats a complex, fragile one.
4. One converter, reached only off the hot path
Converting between the legacy format, the JSON document, and Markdoc is real work, and it has to happen in exactly one place or it drifts. So the converter is a single implementation, a framework-free TypeScript module, wrapped in a small, stateless internal converter service.
The rule we built the whole architecture around:
No interactive read or save ever depends on the converter being up.
Look at where the converter is, and is not, on the two paths a human actually waits for:
- Editor save persists the JSON document directly. No converter call.
- Reader read serves the stored JSON document directly. No converter call.
Every call to the converter lives inside an asynchronous worker job or a non-interactive request:
- Backfill, converting a project's entire back catalogue, runs on a dedicated migration queue, deliberately separate from the general async queue so a large migration can never starve normal background work.
- GitHub sync, the AI commit-diff, version-wide find-and-replace, and history diffing all ride async workers.
- Exports (Markdown, HTML, plain text, Word, Markdoc) and PDF generation are non-interactive requests the user already expects to take a moment.
Because every converter operation is pure and idempotent, every one is safely retryable. And because none of them sits on the read or save path, a converter outage degrades gracefully. A search index refreshes on the next edit, a backfill resumes where it stopped, an export returns a clear error and retries, but the editor and the reader keep working. That is the load-bearing property of the entire design.
5. The blast radius: everything that touches a page
This is the part that is easy to underestimate. Changing how a page is stored means touching every surface that ever derived anything from a page. A canonical body is only canonical if all of these produce identical results from it.
We migrated, one by one:
- The reader, the public documentation site, now renders the JSON document directly, with synced blocks expanded, assets resolved to URLs, and audience-gated conditional blocks stripped server-side per viewer (more on that below).
- All the reader-output serializers:
?format=markdown,html,text,word,markdoc. Each one was reimplemented as a read-only walk over the resolved document. We wrote a new Swish-to-Markdown serializer covering all of the node and mark types, with HTML derived from that and Word built on top of it. - Search: indexing now walks the JSON document to split it into sections and extract plain text. Search is the one place that deliberately keeps conditional blocks intact (tagged by audience, filtered at query time) so the index holds every audience's content. This runs as an in-process walk, with no round-trip to the converter, so bulk reindexing stays fast.
- The AI writing assistant: it reads pages, holds the live editor state, and emits edit suggestions. We re-taught it the new canonical dialect, fed it the document through the converter, and rebuilt its "apply" step to splice structured nodes into the document instead of patching text. An AI edit now lands as a minimal, clean change instead of a whole-document rewrite.
- The public API: the versioned API that customers integrate against now speaks the new dialect in and out for flipped projects, while staying byte-identical for projects still on the old system. Same endpoints, no breaking change.
- GitHub sync: the two-way sync now reads and writes the canonical Markdoc dialect, with a one-time, idempotent re-sync per repository as each project cuts over.
- RSS and changelog feeds: reworked to render changelog HTML directly from the document instead of a multi-hop round-trip.
- History and diffing: an entirely new snapshot system (keyframes plus forward deltas) that records native document history, reconstructs any past revision, and produces a render-able visual diff.
PDF: from a headless browser to a native walk
PDF export got the single biggest performance win, and it is worth calling out on its own.
The old pipeline drove a headless browser (Puppeteer) to load each page in the reader and print it. That is heavy: you are booting a browser, rendering a full web page, and capturing it, per document. Because the new body is a structured document we can walk directly, we removed Puppeteer entirely and now generate PDFs natively from the document tree.
Two results. PDF generation is roughly 10x faster. And, more importantly, the output is better: native rendering means real, interactive links in the PDF, resolved to their proper destinations, instead of flattened, dead text. A faster pipeline that also produces a genuinely better document.
Custom CSS: the parity nobody asks about but everyone notices
Here is a touchpoint most migrations would forget. Many customers ship their own CSS to brand their docs, and that CSS targets the DOM the reader produces. Change the markup and you silently break someone's carefully-tuned brand styling.
We split the problem cleanly. The app chrome (navigation, sidebar, table of contents, breadcrumbs, search) renders from the same DOM on both systems, so chrome CSS keeps working untouched. Only content markup (callouts, tables, code blocks) changes shape under the new editor. For those, we mapped every legacy selector to its new equivalent and drove brand styling through CSS custom properties, so porting a customer's callout palette is remapping a handful of tokens, not rewriting their stylesheet. Layout parity is a dedicated, final pre-flip check for every project with meaningful custom CSS: we measure the rendered DOM against the old reader rather than eyeballing screenshots.
6. The guardrails: how we proved it was safe
A migration is only as good as your confidence that nothing was lost. Ours came from one property, tested relentlessly, plus a set of structural guards that make whole classes of mistake impossible.
The fixed-point, zero-loss gate
The converter has to satisfy two things at once. Take a document M, convert it into the JSON model and back, and call the result C(M):
- Losslessness: no node, no attribute, no character of prose is ever silently dropped. Content loss must be exactly zero.
- Fixed point:
C(C(M)) == C(M). Once a document has passed through the converter, converting it again changes nothing. This is what guarantees that GitHub syncs settle to clean, stable diffs instead of churning on every save.
These are not spot-checks. We ran them continuously, as an ever-growing corpus.
The find, fix, revalidate loop
We assembled a large and increasingly nasty corpus, eventually more than 60,000 documents. Some were hand-picked to stress a specific construct: deeply nested tables, merged cells, code-steps, inline images, exotic Markdown escaping, malformed historical input, every edge of the format. Others were pulled at random, to catch the things we would never think to look for. Then we pointed the gate at all of it, over and over.
Early runs were humbling. One early slice of 1,600 documents surfaced a cluster of failures, around 3% of them showing content loss or non-stable output. So we traced each failing document parse by parse, root-caused the converter defect, fixed it with a regression fixture, and re-ran the entire corpus. Then we grew the corpus and did it again.
That loop ran for weeks. It caught things a smaller test suite never would: inline Markdown images being dropped entirely, raw pipe-tables inside wrapper tags, multi-block image captions, close-shaped template tags hiding inside code fences and eating the rest of a page, a whole family of Markdown-escaping and emphasis-delimiter edge cases. Each one found, fixed, fixtured, and re-validated against everything that came before. We also ran 8,000-document fuzz passes to shake out the long tail of serializer instabilities. By the end, the full corpus of more than 60,000 documents read zero parse errors and effectively zero real content loss, with no regressions on anything previously green.
The point is not that the converter was buggy. Every converter is, at first. The point is that we built a machine that finds the bugs at scale and proves they are fixed, and we did not flip a single project until that machine came back clean.
The structural fences
Beyond the content gate, we built guards that make certain mistakes structurally impossible:
- Single-implementation fence. The converter is one module. To keep it usable by both the browser editor and the server, it must stay framework-free and DOM-free, so the build fails if anything in that module imports Angular or a browser API. There is no way to accidentally fork the converter into a second, drifting copy.
- Byte-parity tests. A few things, like the exact algorithm that turns a heading into a URL anchor, have to be reimplemented in both PHP (for the fast search and indexing path) and TypeScript (for the reader). We pin the two together with a byte-parity test against each other and the legacy output, so reader anchors and search fragments can never silently diverge.
- The privacy gate. Audience-gated content is stripped server-side, per viewer, before a page ever leaves the building, never hidden with CSS on the client. We held a hard line that the strip had to be real and server-side before any public reader could serve the new path, so gated content could never leak. The converter is also forbidden from ever evaluating an author-supplied condition as code; audience membership is a declarative ID resolved on the server, which closes off an entire class of injection risk.
- The ratchet as a guard. The no-dual-write ratchet from section 3 is not just a rollback mechanism, it is a correctness guard. Because the legacy columns cannot be mutated after a flip, the recovery source is provably the pre-flip original, every time.
7. The rollout: invisible until the flip
With the seam, the workers, and the guards in place, the actual rollout is a calm, per-project sequence, run by engineering, never something a customer has to trigger:
- Backfill the project's entire history into the new format, on the dedicated migration queue. The reader is still on the old system, so this is completely invisible. It is idempotent and resumable, and edits during the run are detected and reconverted automatically.
- Validate: run the full gate (losslessness, fixed-point, structural checks) across every page. A pass is the go or no-go. A fail means fix the converter and re-run. Nothing flips on a failed validation.
- Flip
uses_swish = true. The reader now serves the new document and the new editor loads. This is the only customer-visible moment, and if we did our job, it is visible only as a better editor, not as a change to their content. - Retention window: the frozen legacy columns stand by as the recovery source.
- Materialize history off the critical path, and eventually a single global cleanup once every project is stable.
Rollback risk shrinks as you go. Before cleanup, a conversion problem is a recoverable incident, since the untouched legacy columns are right there. Cleanup, which finally drops the legacy columns, is the one hard cliff, and it does not happen until native history has been materialized and verified everywhere. Insurance first, irreversibility last.
By the numbers
- 13 legacy plugin types collapsed into a 28-type node-and-mark schema.
- Four content fields per page reduced to one canonical document (plus its draft).
- Two systems running side by side for the entire rollout, switched by one per-project flag.
- A new internal converter service, the 7th independently-deployed service in our pipeline.
- PDF generation roughly 10x faster after dropping the headless browser, now with real interactive links.
- Every downstream surface migrated: reader, five export formats, PDF, Word, search, AI assistant, public API, GitHub sync, RSS, history, and custom-CSS parity.
- Over 1,000 automated tests, plus a corpus of more than 60,000 documents, curated and random, validated to zero content loss.
- First attempted in 2022, brought close to completion in about four weeks with AI agents doing much of the build and verification.
- Zero downtime. Zero customer action required.
Most of this is invisible by design. If you are a DeveloperHub customer, the migration looks like a nicer editor showing up one day, with every page, link, export, and integration exactly where you left it. Underneath, it is a new document model, a dual-running storage seam, a fleet of asynchronous workers, a single-implementation converter behind a wall of guards, and a corpus that got hammered until it came back clean.
That gap, between how simple it looks and how much is holding it up, is the work.