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.

Important

The 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
flowchart BT editor["Desktop editor<br/>full preview"] --> debt["Duplicated semantics"] dashboard["Internal dashboard<br/>partial preview"] --> debt web["Web reader<br/>vendored reader"] --> debt debt --> core["Shared render package"] core --> consumers["Thin consumers<br/>editor, web, dashboards, publish"]

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.

Warning

The 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.

Bloque de codigo - js
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:

flowchart TD md["Source Markdown"] --> parse["Parse + sanitize"] parse --> html["Safe HTML"] html --> decorate["DOM decorators"] decorate --> output["Final preview"] decorate --> diagrams["Diagrams"] decorate --> math["Formulas"] decorate --> blocks["Columns, callouts, tables, footnotes"]

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.

flowchart LR package["Create local package"] --> internal["Migrate internal consumer"] internal --> editor["Migrate editor"] editor --> web["Migrate web reader"] web --> publish["Use in publishing"]
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
Tip

A 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.