De tres renderers a uno: cómo extraer un núcleo de render compartido

Hay una deuda técnica que no aparece como bug: el mismo bloque de código funciona en varios sitios, pero empieza a evolucionar distinto en cada uno. En mi caso era el renderer de Markdown enriquecido de mi plataforma.

Important

La decisión no fue "refactorizar porque hay duplicación". Fue extraer una pieza que ya tenía contrato propio: Markdown enriquecido entra, HTML seguro y decorado sale.

Vista rápida

Pregunta Respuesta corta
¿Qué se repitió? El renderer de Markdown enriquecido
¿Dónde vivía? El editor desktop, un dashboard interno y un visor web
¿Por qué dolía? Cada copia aceptaba features distintas y bugs distintos
¿Qué extraje? Un paquete compartido con el contrato de render
¿Qué no moví? UI del editor, navegación, i18n y acciones de producto
flowchart LR editor["Editor desktop<br/>preview completo"] --> debt["Semántica duplicada"] dashboard["Dashboard interno<br/>preview parcial"] --> debt web["Visor web<br/>reader vendorizado"] --> debt debt --> core["Paquete de render compartido"] core --> consumers["Consumidores finos<br/>editor, web, dashboards, publish"]

El estado de partida

Cuando hice la auditoría, encontré tres copias funcionales del mismo renderer en distintos consumidores: el editor canónico, una extracción parcial pensada para vistas internas y un reader vendorizado pensado para web.

Copia Rol real Fortalezas Riesgo
Renderer del editor Preview canónico Footnotes, diagramas avanzados, decoradores de layout Mezclaba core con detalles del editor
Renderer del dashboard Extracción parcial Más modular y legible Le faltaban features del editor
Reader web vendorizado Visor público Funcionaba sin build complejo Iba por detrás y dependía de scripts de sync

Las tres usaban el mismo stack base: parser, sanitizador, resaltado de código, fórmulas y diagramas. El problema no era que hubiese tres archivos parecidos. El problema era que el lector veía tres versiones de lo que debería ser el mismo documento.

Warning

El olor a deuda no es repetición. Es repetición que deriva.

La señal visual

La señal más clara fue hacer una lista de bloques soportados. Si una tabla, una alerta o un diagrama renderiza distinto según el producto, el contrato ya está roto.

Bloque Markdown Editor Dashboard Visor web Resultado deseado
Diagramas Completo Básico Vendorizado Un solo render
Fórmulas Un solo render
Alertas tipo callout Parcial Un solo decorador
Columnas Un solo decorador
Footnotes No Depende Feature del core
Estilos de tabla Parcial Parcial Feature del core

La tabla cambió la conversación: ya no era un refactor abstracto, era una promesa de lectura que debía ser consistente.

Criterio para extraer

Uso tres preguntas antes de sacar código a un paquete:

Identidad

¿La pieza tiene entrada, salida y contrato propios?

En este caso sí: Markdown + dependencias de runtime → HTML seguro + decoradores.

Semántica

¿Los consumidores quieren el mismo comportamiento?

Sí. Nadie quería "otra forma" de renderizar tablas o diagramas.

Próximo consumidor

¿Hay más usos reales en cola?

Sí: varios consumidores adicionales, incluyendo exports server-side y portales.

Si una de esas respuestas hubiese sido no, habría esperado. Como las tres eran sí, la extracción ya no era estética: era mantenimiento preventivo.

Qué cambió al extraer

La extracción no consistió en diseñar un renderer nuevo. Consistió en mover el contrato existente al lugar correcto y limpiar las dependencias.

1. Dependencias inyectadas

Antes, las copias leían sus librerías de runtime desde el ámbito global del navegador. Eso encaja en el cliente, pero no en un export server-side.

Bloque de codigo - js
const preview = new PreviewRenderer({
  element,
  dependencies: {
    parser,
    sanitizer,
    highlighter,
    diagramEngine,
    mathEngine
  }
});

El core no decide de dónde vienen las librerías. Cada consumidor las proporciona.

2. Render puro separado de decoración

El render tiene dos fases:

flowchart TD md["Markdown fuente"] --> parse["Parse + sanitize"] parse --> html["HTML seguro"] html --> decorate["Decoradores DOM"] decorate --> output["Preview final"] decorate --> diagrams["Diagramas"] decorate --> math["Fórmulas"] decorate --> blocks["Columnas, alertas, tablas, footnotes"]

Separar esas fases permite probar el parser sin DOM completo y probar decoradores con fixtures pequeños.

3. Tema como datos

La configuración de los diagramas dejó de depender de sincronizar CSS con scripts. El core acepta tokens de tema y el producto decide la estética.

Antes Después
El motor de diagramas leía valores derivados de CSS local El motor de diagramas recibe tokens explícitos
Visor web vendorizado Visor web consume el core
Bugs arreglados tres veces Bugs arreglados una vez

La migración no destructiva

No migré todo a la vez. El orden importa porque cada consumidor tiene un riesgo distinto.

flowchart LR package["Crear paquete local"] --> internal["Migrar consumidor interno"] internal --> editor["Migrar editor"] editor --> web["Migrar visor web"] web --> publish["Usar en publicación"]
Paso Objetivo Verificación
Paquete local Tener API estable y tests Unit tests del core
Consumidor interno Probar consumidor menos crítico Preview de docs internos
Editor Reemplazar la copia canónica Demos visuales de Markdown enriquecido
Visor web Eliminar reader vendorizado Paridad página por página
Publicación Validar nuevo consumidor Sitio real generado desde Markdown
Tip

Una migración que rompe paridad visual no es una migración. Es un bug con envoltorio de arquitectura.

Checklist de paridad que usé
  • Headings con IDs estables.
  • Código con highlight.
  • Diagramas con el mismo tema.
  • Fórmulas inline y block.
  • Alertas tipo callout.
  • Columnas.
  • Tablas con estilos.
  • Footnotes generadas al final del documento.
  • Imágenes con rutas relativas resueltas desde el Markdown.

Qué valida que la decisión fue correcta

La extracción merece la pena cuando cambia la forma de operar:

Señal Qué significa
Un bug de render se arregla una sola vez El core tiene dueño real
Un consumidor nuevo entra sin tocar el core El contrato está bien cortado
El editor conserva su UI específica El paquete no absorbió producto
El visor web deja de vendorizar La deuda visible baja

La prueba más fuerte no es que el paquete exista. Es que un cuarto consumidor lo use sin pedir una excepción rara.

Lo que no haría

  • No extraería por el segundo fork si el contrato todavía no está claro.
  • No metería i18n, navegación, acciones de copiar o UI del editor dentro del core.
  • No mezclaría esta migración con features nuevas del renderer.
  • No usaría flags para convertir una librería en cinco productos escondidos.

La regla que me llevo

Una librería compartida no se diseña desde cero. Se descubre cuando el mismo contrato aparece en varios productos y empieza a doler en todos.


Este artículo es parte del trabajo de plataforma que cuento en Sobre mí. Si te interesa el tema, sigue el blog o escríbeme.