From three renderers to one: extracting a shared rendering core
Some technical debt does not show up as a bug. The same block of code works in several places, but starts evolving differently in each one. In my case, it was the enriched Markdown renderer of my platform.
ImportantThe decision was not "refactor because there is duplication". It was extracting a piece that already had its own contract: enriched Markdown goes in, safe decorated HTML comes out.
Quick view
| Question | Short answer |
|---|---|
| What was repeated? | The enriched Markdown renderer |
| Where did it live? | Desktop editor, internal dashboard and web reader |
| Why did it hurt? | Each copy accepted different features and different bugs |
| What did I extract? | A shared package with the render contract |
| What did I not move? | Editor UI, navigation, i18n and product actions |
Starting point
During the audit, I found three functional copies of the same renderer in different consumers: the canonical editor renderer, a partial extraction for internal views and a vendored reader for the web.
| Copy | Real role | Strengths | Risk |
|---|---|---|---|
| Editor renderer | Canonical preview | Footnotes, advanced diagrams, layout decorators | Mixed core with editor details |
| Dashboard renderer | Partial extraction | More modular and readable | Missing editor features |
| Vendored web reader | Public reader | Worked without a complex build | Lagged behind and depended on sync scripts |
All three used the same base stack: parser, sanitizer, code highlighting, formulas and diagrams. The problem was not that three files looked similar. The problem was that the reader saw three versions of what should be the same document.
WarningThe smell is not repetition. It is repetition that drifts.
The visual signal
The clearest signal was listing supported blocks. If a table, callout or diagram renders differently depending on the product, the contract is already broken.
| Markdown block | Editor | Dashboard | Web reader | Desired result |
|---|---|---|---|---|
| Diagrams | Full | Basic | Vendored | One render |
| Formulas | Yes | Yes | Yes | One render |
| Callouts | Yes | Partial | Yes | One decorator |
| Columns | Yes | Yes | Yes | One decorator |
| Footnotes | Yes | No | Depends | Core feature |
| Table styles | Yes | Partial | Partial | Core feature |
That table changed the conversation. This was no longer an abstract refactor. It was a reading promise that had to stay consistent.
Criteria for extraction
I use three questions before moving code into a package:
Identity
Does the piece have its own input, output and contract?
Here, yes: Markdown + runtime dependencies -> safe HTML + decorators.
Semantics
Do consumers want the same behavior?
Yes. Nobody wanted a different way to render tables or diagrams.
Next consumer
Are there more real uses coming?
Yes: more consumers, including server-side exports and portals.
If any answer had been no, I would have waited. Because all three were yes, the extraction was not aesthetic. It was preventive maintenance.
What changed
The extraction was not about designing a new renderer. It was about moving the existing contract to the right place and cleaning its dependencies.
1. Injected dependencies
Before, copies read their runtime libraries from the browser global scope. That works in the client, but not in server-side exports.
const preview = new PreviewRenderer({
element,
dependencies: {
parser,
sanitizer,
highlighter,
diagramEngine,
mathEngine
}
});The core does not decide where libraries come from. Each consumer provides them.
2. Pure render separated from decoration
Rendering has two phases:
Separating the phases makes it possible to test parsing without a full DOM and test decorators with small fixtures.
3. Theme as data
Diagram configuration stopped depending on CSS synchronization scripts. The core accepts theme tokens and the product decides the visual style.
| Before | After |
|---|---|
| The diagram engine read values derived from local CSS | The diagram engine receives explicit tokens |
| Vendored web reader | Web reader consumes the core |
| Bugs fixed three times | Bugs fixed once |
Non-destructive migration
I did not migrate everything at once. The order matters because every consumer has different risk.
| Step | Goal | Verification |
|---|---|---|
| Local package | Stable API and tests | Core unit tests |
| Internal consumer | Test the least critical consumer | Internal docs preview |
| Editor | Replace the canonical copy | Visual demos of enriched Markdown |
| Web reader | Remove vendored reader | Page-by-page parity |
| Publishing | Validate a new consumer | Real site generated from Markdown |
TipA migration that breaks visual parity is not a migration. It is a bug wrapped in architecture language.
Parity checklist I used
- Stable heading IDs.
- Code highlighting.
- Diagrams with the same theme.
- Inline and block formulas.
- Callouts.
- Columns.
- Styled tables.
- Footnotes generated at the end of the document.
- Images with relative paths resolved from the Markdown file.
What proves the decision was right
The extraction is worth it when it changes operations:
| Signal | Meaning |
|---|---|
| A render bug is fixed once | The core has a real owner |
| A new consumer enters without touching the core | The contract is well cut |
| The editor keeps its specific UI | The package did not absorb product |
| The web reader stops vendoring | Visible debt drops |
The strongest proof is not that the package exists. It is that a fourth consumer can use it without asking for a strange exception.
What I would not do
- I would not extract after the second fork if the contract is still unclear.
- I would not put i18n, navigation, copy actions or editor UI into the core.
- I would not mix this migration with new renderer features.
- I would not use flags to turn one library into five hidden products.
The rule I keep
A shared library is not designed from scratch. It is discovered when the same contract appears in several products and starts hurting all of them.
This article is part of my platform work. If you are interested in this topic, follow the blog or contact me.