wandres.dev
MARKDOWN A FONDO · remark, rehype, headings

Plugins rehype: transformar el HTML resultante

La segunda mitad del pipeline: una vez que el mdast se convierte en hast, el árbol del HTML, los plugins rehype intervienen sobre la salida. Añadir id a los encabezados con rehype-slug, enlazarlos con rehype-autolink-headings, y escribir un plugin propio que inyecta atributos recorriendo el hast con unist-util-visit; todo registrado en rehypePlugins.

⏱ 16 min

Si remark opera sobre el Markdown como árbol, rehype opera sobre el HTML como árbol. Entre uno y otro hay un puente —el paso de mdast a hast— tras el cual tu documento deja de pensarse en encabezados y párrafos y empieza a pensarse en etiquetas y atributos. Ese cambio de plano es justo lo que necesitas cuando la transformación que buscas es de HTML: poner un id en cada título, envolverlo en un enlace, añadir target a los enlaces externos o una clase a cada tabla. rehype es la fase donde el resultado todavía es maleable como estructura, un instante antes de congelarse en la cadena final que se sirve.

🎯 Al terminar esta lección sabrás
  • Situar rehype y el hast como la fase de HTML del pipeline, tras el puente remark-rehype.
  • Añadir anclas a los encabezados combinando rehype-slug y rehype-autolink-headings.
  • Escribir un plugin rehype propio que inyecte atributos con unist-util-visit.
  • Registrar plugins en rehypePlugins y razonar sobre el orden que exigen sus dependencias.

Del mdast al hast: el cambio de plano

El pipeline no salta del Markdown al texto HTML de golpe. Entre medias, un puente llamado remark-rehype convierte el mdast en otro árbol, el hast —el HTML Abstract Syntax Tree—. Donde el mdast tenía nodos de tipo heading o link, el hast tiene nodos de tipo element con un tagName como h2 o a y un objeto properties con sus atributos. Es el mismo documento, contado en el vocabulario del HTML.

Ese cambio de vocabulario define para qué sirve cada fase. En el mdast razonas sobre la semántica del contenido: esto es un encabezado de nivel dos, esto es un enlace. En el hast razonas sobre la forma del HTML: esta etiqueta es un h2, este a tiene tal href. Cuando tu transformación se expresa mejor en términos de etiquetas y atributos —añadir un class, un id, un rel— el hast, y por tanto rehype, es el lugar correcto.

// Forma simplificada de un nodo element del hast
type HastElement = {
  type: 'element';
  tagName: string;                       // 'h2', 'a', 'img'...
  properties: Record<string, unknown>;   // { id, className, href... }
  children: HastElement[];
};

El puente remark-rehype no es una traducción trivial de uno a uno. Algunos conceptos del Markdown no tienen equivalente directo en HTML y el puente decide cómo representarlos —las notas al pie, por ejemplo, se convierten en una lista al final con enlaces de ida y vuelta—. Por eso el paso de mdast a hast es también un punto de configuración, y no solo de transformación: lo que el puente elige por defecto puede ajustarse, algo que retomarás cuando veas remarkRehype. De momento basta con retener que cruzar de un árbol a otro implica decisiones, no una copia.

El caso de uso más común de rehype es hacer enlazables los encabezados. Requiere dos pasos y dos plugins. Primero, rehype-slug recorre el hast y asigna a cada encabezado un id derivado de su texto. Después, rehype-autolink-headings añade dentro de cada encabezado un <a> que apunta a ese mismo id, de modo que cada título se convierte en un enlace a sí mismo.

// astro.config.mjs
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';

export default {
  markdown: {
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],
    ],
  },
};

El orden aquí no es negociable: rehype-autolink-headings necesita que el id ya exista para poder apuntar a él, así que rehype-slug debe ir antes. Invertirlos produce enlaces que apuntan a anclas vacías. Es el ejemplo canónico de una dependencia entre plugins: uno prepara el terreno, el otro lo aprovecha.

La opción behavior decide cómo se coloca ese enlace, y cada valor sirve a una intención distinta. Con wrap, el enlace envuelve el texto del título, de modo que todo el encabezado es clicable. Con append o prepend, se inserta un pequeño ancla —a menudo un símbolo de eslabón— después o antes del texto, dejando el título intacto y añadiendo un asidero visible al pasar el ratón. La elección no es cosmética: wrap conviene cuando quieres que el título entero sea un enlace; append cuando prefieres un ancla discreta que no compita con la lectura.

⚠️
Astro ya pone id, pero no anclas

Astro asigna por su cuenta un id a cada encabezado durante la compilación, así que rehype-slug puede parecer redundante. La distinción fina: el id que necesitas para enlazar ya está, pero el <a> que convierte el título en enlace no. Si solo quieres las anclas, rehype-autolink-headings suele funcionar sobre los id que Astro ya generó; si dependes de una regla de slug particular o de un orden explícito, declarar rehype-slug te da control determinista sobre cuándo y cómo se asigna cada id.

Un plugin rehype propio: inyectar atributos

Cuando la transformación no la cubre un plugin existente, la escribes. El patrón es idéntico al de remark —una función que devuelve un transformer(tree)— pero el árbol es el hast y lo que buscas son etiquetas. Para recorrerlo sin escribir la recursión a mano se usa unist-util-visit, una utilidad que visita cada nodo que coincida con un criterio.

El ejemplo típico: marcar los enlaces externos para que abran en otra pestaña de forma segura. Visitas cada element de tipo a, compruebas si su href sale del sitio y, si es así, añades target y rel a sus properties.

// rehype-enlaces-externos.mjs
import { visit } from 'unist-util-visit';

export function rehypeEnlacesExternos() {
  return function (tree) {
    visit(tree, 'element', (node) => {
      if (node.tagName !== 'a') return;
      const href = node.properties?.href ?? '';
      if (href.startsWith('http')) {
        node.properties.target = '_blank';
        node.properties.rel = 'noopener noreferrer';
      }
    });
  };
}

unist-util-visit es el caballo de batalla de estas transformaciones. Le pasas el árbol, el tipo de nodo que te interesa y una función que se ejecuta por cada coincidencia; dentro puedes leer y mutar properties con total libertad. Añadir una clase a cada img, un atributo de carga diferida, un data-* a cada tabla: todo es la misma coreografía de visitar y modificar.

El segundo argumento de visit admite más que un nombre de tipo. Puedes pasarle una función de prueba que decida, nodo a nodo, si te interesa —por ejemplo, solo los a cuyo href empieza por http— y así estrechar el recorrido sin comprobar la condición dentro. También importa saber que mutar properties en el sitio es seguro: el árbol es tuyo durante esta fase y los cambios se propagan al HTML final tal cual los dejes. No devuelves un árbol nuevo; modificas el que recibes, y esa mutación es el efecto del plugin.

import { visit } from 'unist-util-visit';

// El test como función: filtra en el propio recorrido
export function rehypeClaseExternos() {
  const esExterno = (n) =>
    n.tagName === 'a' && String(n.properties?.href).startsWith('http');
  return (tree) => {
    visit(tree, esExterno, (node) => {
      node.properties.className = ['externo'];
    });
  };
}
📝
Sobre el HTML crudo en el hast

Cuando el Markdown incrusta HTML literal, ese fragmento llega al hast como un nodo especial de tipo raw, no como elementos ya parseados. Muchos plugins rehype no lo recorren igual que al resto del árbol, así que una transformación que espera nodos element puede pasarlo de largo. Si necesitas operar también sobre el HTML embebido, hay que convertirlo antes en nodos reales; conviene saberlo para no extrañarse de que cierto contenido escape a tu plugin.

Registrar en rehypePlugins y el orden

Los plugins rehype se enchufan en markdown.rehypePlugins, hermano de remarkPlugins pero para la fase de HTML. Un plugin sin opciones se registra por su nombre; uno con opciones, como una tupla de dos elementos donde el segundo es el objeto de configuración.

// astro.config.mjs
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { rehypeEnlacesExternos } from './rehype-enlaces-externos.mjs';

export default {
  markdown: {
    rehypePlugins: [
      rehypeSlug,
      [rehypeAutolinkHeadings, { behavior: 'append' }],
      rehypeEnlacesExternos,
    ],
  },
};

Fíjate en la simetría con remark: los mismos tres huecos —el nombre suelto, la tupla con opciones, el plugin propio— y la misma semántica de orden. Quien haya configurado remarkPlugins ya sabe configurar rehypePlugins; solo cambia el árbol sobre el que actúan. Esa uniformidad no es casual, sino la señal de que ambos son la misma maquinaria de unified aplicada a dos planos distintos del mismo documento, con idéntico contrato de registro.

flowchart LR
MDAST[mdast semantica] --> BRIDGE[remark rehype puente]
BRIDGE --> HAST[hast etiquetas y atributos]
HAST --> SLUG[rehype slug pone id]
SLUG --> AUTO[autolink usa el id]
AUTO --> ATTR[plugin propio anade atributos]
ATTR --> HTML[html final serializado]
style MDAST fill:#89b4fa,color:#11111b
style HTML fill:#a6e3a1,color:#11111b

Como en remark, los plugins se aplican en el orden del array, cada uno sobre el hast que dejó el anterior. Esa secuencialidad es la que convierte el orden en una decisión de diseño: primero se ponen los id, luego se enlazan, después se retocan atributos. Pensar el array como una tubería —no como un conjunto— es lo que evita el error de encadenar plugins que dependen de un trabajo aún no hecho.

Dos arboles, una misma disciplina

Lo profundo de rehype no es rehype, sino descubrir que todo el procesamiento de tu contenido es una sucesión de árboles y transformaciones, y que dominarla se reduce a una sola pregunta repetida: en qué plano estoy y qué árbol tengo delante. El Markdown entra como texto, se vuelve mdast para razonar sobre contenido, cruza el puente a hast para razonar sobre HTML, y sale como texto de nuevo. En cada plano, la disciplina es idéntica —una función que recibe un árbol, lo recorre con un visitante y deja cambios a su paso— y solo cambia el vocabulario de los nodos. Interiorizar esta simetría tiene un efecto liberador: dejas de memorizar plugins como recetas sueltas y empiezas a ubicar cada necesidad en su plano. Quieres derivar un dato de la estructura del contenido, es remark sobre el mdast; quieres retocar la salida HTML, es rehype sobre el hast; quieres un id, sabes que existe porque alguien lo puso en el hast, y sabes en qué orden. La misma arquitectura que gobierna este pipeline gobierna compiladores, transpiladores y linters de medio mundo: parsear a un árbol, transformarlo en fases, serializar. Cuando ves el procesamiento de Markdown como una instancia de ese patrón universal, la pregunta ya nunca es “qué plugin necesito”, sino “en qué árbol vive lo que quiero cambiar” — y esa pregunta siempre tiene respuesta.

⚔️ Retoca la salida HTML
  1. Configura rehype-slug y rehype-autolink-headings en rehypePlugins y verifica en el HTML que cada encabezado queda enlazado a su propio id.
  2. Invierte deliberadamente el orden de ambos plugins, observa qué se rompe y explica por qué la dependencia impone la secuencia.
  3. Escribe un plugin rehype propio que, con unist-util-visit, añada una clase a cada elemento img del documento.
  4. Extiende ese plugin para marcar los enlaces externos con target y rel, y razona por qué esta tarea vive en el hast y no en el mdast.