Plugins remark: transformar el AST de Markdown
El pipeline de unified por dentro: remark parsea el Markdown a un árbol mdast que se puede inspeccionar y modificar antes de convertirlo en HTML. Anatomía de un plugin remark como transformer, un ejemplo propio de reading-time que inyecta datos en el frontmatter, y cómo registrarlo en remarkPlugins.
Entre el texto que escribes y el HTML que se sirve hay un momento invisible en el que tu Markdown no es ni una cosa ni la otra: es un árbol. remark lo parsea a esa estructura intermedia —el mdast— y, antes de que nada se convierta en HTML, te abre la puerta a recorrerla y modificarla. Un plugin remark es exactamente eso: una función que interviene en ese árbol para añadir, quitar o transformar nodos. Comprender que el Markdown pasa por una fase de datos manipulables, y no directamente de texto a HTML, es lo que convierte el formato en una plataforma extensible en lugar de una sintaxis cerrada.
- Situar remark y el mdast dentro del pipeline de
unifiedque usa Astro. - Reconocer la anatomía de un plugin remark como función que devuelve un
transformer. - Escribir un plugin propio de reading-time que inyecte datos en el frontmatter.
- Registrar el plugin en
remarkPluginsy consumir su resultado desde la plantilla.
El pipeline de unified y el mdast
Astro no compila Markdown con una función monolítica: usa unified, un procesador que descompone el trabajo en tres fases. Primero parsea el texto a un árbol sintáctico; luego transforma ese árbol con una cadena de plugins; por último lo serializa al formato de salida. remark es la mitad de la historia que trata con Markdown, y el árbol que produce se llama mdast, el Markdown Abstract Syntax Tree.
El mdast es una representación estructurada del documento. Cada elemento —un párrafo, un encabezado, un enlace, un bloque de código— es un nodo con un type, propiedades propias y, si contiene otros elementos, un array children. El documento entero es la raíz de ese árbol. Trabajar sobre él en lugar de sobre el texto crudo es la diferencia entre editar con una expresión regular frágil y operar sobre una estructura que conoce su propia gramática.
// Forma simplificada de un nodo mdast
type MdastNode = {
type: string; // 'heading', 'paragraph', 'link', 'code'...
children?: MdastNode[];
value?: string; // texto, en nodos de hoja
depth?: number; // nivel, en encabezados
};
Que exista esta fase intermedia es lo que hace extensible al Markdown. La sintaxis es fija, pero el árbol es abierto: puedes inspeccionarlo, añadir nodos, reescribir otros o extraer información de él antes de que se convierta en HTML. remark es la ventana a ese momento.
La ventaja de operar sobre el árbol y no sobre el texto es de fiabilidad, no de gusto. Una expresión regular que buscara encabezados en el texto crudo confundiría una almohadilla dentro de un bloque de código con un ## real, o tropezaría con un # escapado. El mdast no se equivoca en eso: un nodo heading es un encabezado porque el parser ya resolvió la gramática, y un code es código aunque contenga almohadillas. Trabajar sobre la estructura significa trabajar sobre el significado ya desambiguado, no sobre caracteres que hay que volver a interpretar.
Anatomía de un plugin remark
Un plugin remark es una función que devuelve otra función, el transformer. La externa se registra en la configuración y puede recibir opciones; la interna es la que se ejecuta por cada documento, y recibe dos argumentos: el tree, la raíz del mdast, y el file, un objeto que representa el fichero en curso y transporta datos entre fases.
export function miPlugin(opciones) {
return function transformer(tree, file) {
// recorrer y modificar tree
// o escribir datos en file.data
};
}
Ese patrón de dos capas no es adorno. La capa externa existe para que puedas configurar el plugin una vez —pasarle opciones al registrarlo— y la interna, para que la transformación reciba, en cada documento, su propio árbol y su propio fichero. El tree es el mdast que puedes mutar; el file es el canal por el que un plugin deja información que otras fases —o la propia plantilla— recogerán después.
Para recorrer el árbol rara vez escribes la recursión a mano. El ecosistema ofrece unist-util-visit, una utilidad que visita cada nodo de un tipo dado y ejecuta tu función sobre él; es el gesto idiomático tanto en remark como en rehype. Tu plugin se reduce entonces a declarar qué nodos te interesan y qué hacer con cada uno, mientras la utilidad desciende por los children sin que tú lleves la contabilidad del recorrido. Recorrer a mano un árbol de profundidad arbitraria es justo la clase de código propenso a fallos que una utilidad probada te ahorra.
Es tentador ver file como un mero identificador del documento, pero su papel es más rico: su propiedad data es un espacio compartido que sobrevive a la transformación. Astro reserva ahí file.data.astro.frontmatter, el mismo frontmatter que llega a tu plantilla. Escribir en él desde un plugin remark es la vía canónica para que un cálculo hecho sobre el árbol —un tiempo de lectura, un recuento— aparezca luego como un campo más de la cabecera del documento.
Un plugin propio: reading-time
El ejemplo clásico y útil es calcular el tiempo de lectura. La idea: recorrer el mdast para extraer todo su texto, estimar los minutos que costaría leerlo y depositar ese dato en el frontmatter, de modo que cualquier plantilla pueda pintarlo sin recalcular nada.
Para extraer el texto sin reimplementar el recorrido usamos mdast-util-to-string, que aplana el árbol a su contenido textual; y para la estimación, la biblioteca reading-time. El resultado se guarda en file.data.astro.frontmatter.
// remark-reading-time.mjs
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';
export function remarkReadingTime() {
return function (tree, file) {
const textoDelArbol = toString(tree);
const tiempo = getReadingTime(textoDelArbol);
// se inyecta en el frontmatter del documento
file.data.astro.frontmatter.minutosLectura = tiempo.text;
};
}
El plugin no toca el HTML ni el cuerpo: solo lee el árbol y escribe un dato derivado. Esa es la clase de trabajo para la que remark brilla —producir metadatos a partir del contenido— porque opera cuando el documento todavía es una estructura consultable, no una sopa de etiquetas. El cálculo ocurre una vez, en el build, y su coste desaparece del navegador.
Repara en que el plugin no reinventa nada: se apoya en dos piezas del propio ecosistema de unified, que instalas como dependencias. mdast-util-to-string sabe aplanar cualquier subárbol a su texto, así que no escribes tú el recorrido recursivo; y reading-time encapsula la heurística de palabras por minuto. Esa componibilidad es la norma del ecosistema, no la excepción: un plugin suele ser pegamento fino entre utilidades ya probadas, no un algoritmo escrito desde cero. Aprender a buscar la utilidad adecuada antes de programarla es la mitad del oficio.
La forma más rápida de conocer el mdast de un documento es volcarlo. Un plugin mínimo cuyo transformer haga console.log(JSON.stringify(tree, null, 2)) te muestra la estructura exacta —tipos, anidamiento, propiedades— que tus transformaciones van a recorrer. Inspeccionar el árbol real antes de escribir la lógica evita suposiciones sobre cómo se parseó tal o cual construcción del Markdown, y convierte el diseño del plugin en una lectura, no en una adivinanza.
Registrar y consumir el plugin
Un plugin no hace nada hasta que lo enchufas al pipeline. Se registra en el array markdown.remarkPlugins de astro.config. El orden importa: los plugins se aplican en secuencia sobre el mismo árbol, cada uno viendo lo que dejó el anterior.
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { remarkReadingTime } from './remark-reading-time.mjs';
export default defineConfig({
markdown: {
remarkPlugins: [remarkReadingTime],
},
});
Consumir el resultado depende de la vía. En una entrada de colección, el dato inyectado llega por remarkPluginFrontmatter, la tercera pieza que devuelve render. En una página Markdown con layout, aparece directamente dentro de frontmatter. En ambos casos es un campo más, indistinguible de los que escribiste a mano en la cabecera.
---
import { getEntry, render } from 'astro:content';
const post = await getEntry('blog', 'mi-post');
const { Content, remarkPluginFrontmatter } = await render(post);
---
<p>{remarkPluginFrontmatter.minutosLectura}</p>
<Content />
Merece subrayarse por qué el dato viaja en un canal aparte, remarkPluginFrontmatter, y no se mezcla con el data de la entrada. El data es lo que tú escribiste y tu esquema validó; lo que un plugin inyecta se calcula durante la compilación, después de esa validación, así que no puede fingir haber pasado por el esquema. Mantener ambas fuentes separadas —lo declarado frente a lo derivado— es honesto: la plantilla sabe en todo momento si un valor vino del autor o de una transformación automática.
En una página Markdown con layout, en cambio, el mismo dato no viaja por remarkPluginFrontmatter, sino que aparece directamente dentro de frontmatter:
---
const { frontmatter } = Astro.props;
---
<span>{frontmatter.minutosLectura}</span>
El nombre del campo —minutosLectura— lo eliges tú en el plugin, y es el mismo que lees en la plantilla. No hay un registro central que declare qué campos existen: el contrato es implícito, entre lo que el plugin escribe y lo que la vista espera. Documentarlo, aunque sea en un comentario, evita que renombrar el campo en el plugin deje la plantilla leyendo undefined en silencio.
flowchart LR TEXT[markdown crudo] --> PARSE[remark parsea a mdast] PARSE --> PLUG[remarkPlugins recorren el arbol] PLUG --> DATA[escribe en file data frontmatter] PLUG --> HTML[continua hacia html] DATA --> TPL[plantilla lee el dato] style TEXT fill:#89b4fa,color:#11111b style TPL fill:#a6e3a1,color:#11111b
La lección que trasciende a remark es que un formato de marcado, cuando se compila a través de un árbol, deja de ser texto y se comporta como un programa que puedes reescribir antes de ejecutarlo. El mdast es la prueba: tu documento existe, por un instante, como una estructura de datos con gramática propia, y ese instante es una palanca de un poder enorme. Sobre el árbol puedes hacer cosas que sobre el texto serían imposibles o suicidas: recolectar todos los encabezados sin confundirlos con almohadillas dentro de un bloque de código, transformar cada enlace externo, insertar un aviso al principio de cada documento, calcular métricas del contenido, o inventar tu propia sintaxis y traducirla a nodos estándar. Nada de esto requiere tocar el compilador de Astro; requiere entender que Astro te cede un punto de intervención en mitad del proceso. Y el patrón que aprendes aquí —una función que recibe un árbol y un fichero, lo recorre y deja datos o cambios a su paso— es el mismo que gobierna transformaciones de código, linters, empaquetadores y compiladores enteros. Cuando ves el reading-time no como un truco sino como un caso particular de “programa que analiza un árbol y deriva información de él”, has cruzado la frontera que separa usar un formato de extenderlo. El Markdown se vuelve entonces tan expresivo como lo que seas capaz de programar sobre su árbol.
- Escribe un plugin remark que use
mdast-util-to-stringpara contar las palabras del documento y guarde el total enfile.data.astro.frontmatter. - Regístralo en
remarkPluginsdentro deastro.configy razona en qué posición del array debe ir si depende de otro plugin. - Renderiza una entrada con
rendery pinta el recuento leyéndolo desderemarkPluginFrontmatter, sin recalcularlo en la plantilla. - Amplía el plugin para que, además del recuento, marque en el frontmatter si el documento es “largo” según un umbral, y explica por qué esa decisión pertenece al plugin y no a la vista.