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.
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 mdasttype 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.
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.mjsimport 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.
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.mjsimport { 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.