Renderizar contenido: render, Content y getHeadings
Cómo se pasa del dato Markdown al HTML mostrable: render de una entrada de colección devuelve un componente Content y su lista de headings, mientras que una importación directa de un módulo Markdown expone getHeadings. Con esa lista de encabezados se construye una tabla de contenidos sin analizar el texto a mano.
render: de la entrada al componente Content
Cuando el Markdown vive en una colección, la entrada que devuelve getEntry o getCollection es puro dato: tiene id, data tipado y el body crudo, pero no un HTML listo. Compilar ese cuerpo es una operación aparte, render, que importas de astro:content, a la que pasas la entrada y que te devuelve un objeto con varias piezas.
---import { getEntry, render } from 'astro:content';const post = await getEntry('blog', 'mi-post');const { Content, headings } = await render(post);---<article> <h1>{post.data.title}</h1> <Content /></article>De ese objeto, Content es un componente de Astro como cualquier otro: lo colocas en la plantilla, hereda el contexto y respeta los componentes que hayas mapeado para el Markdown. Junto a él, render te entrega headings, la lista ya construida de los encabezados del documento, y remarkPluginFrontmatter, los datos que un plugin remark haya inyectado durante la compilación. Renderizar es, por tanto, el acto de convertir un dato en su forma visible, y solo ocurre cuando lo pides.
Ese “solo cuando lo pides” no es un matiz: es una propiedad de coste. Compilar el cuerpo de un Markdown es la parte cara del trabajo, y render la aísla en una llamada explícita. Una página índice que lista cien títulos y cien fechas no llama a render ni una vez, así que no paga por compilar cien cuerpos que nadie va a leer en esa vista; solo la página de detalle, que muestra un documento entero, invoca render sobre esa única entrada. Consultar data es leer un objeto ya cargado; renderizar es trabajo real. Separar ambos gestos es lo que mantiene los builds rápidos cuando el contenido crece.
El Content respeta, además, cualquier mapeo de componentes que le pases: puedes sustituir cómo se renderiza cada elemento del Markdown —un <a> por tu componente de enlace, un <img> por tu imagen optimizada— sin tocar el contenido. Esa capacidad convierte a Content en un punto de personalización, no en una caja negra que solo escupe HTML fijo. El mismo cuerpo se pinta distinto según los componentes que le des, sin reescribir el Markdown.
getHeadings: la vía de la importación directa
No todo Markdown pasa por una colección. Un módulo importado directamente —o descubierto con import.meta.glob— expone otra interfaz para el mismo material. En lugar de una función render, el módulo trae ya un Content, un frontmatter y, en vez de una propiedad headings, una función getHeadings.
---const posts = Object.values( import.meta.glob('../posts/*.md', { eager: true }),);const primero = posts[0];const encabezados = primero.getHeadings();---<Content />La diferencia es de forma, no de fondo. En una entrada de colección obtienes los encabezados como propiedad del resultado de render; en un módulo importado los pides como función, getHeadings(). En ambos casos el contenido de la lista es idéntico, porque procede del mismo análisis del documento durante la compilación. Elegir una vía u otra depende de si tu contenido está gobernado por una colección tipada o importado como módulo suelto.
Hay una asimetría histórica detrás de estas dos puertas. Las importaciones directas de Markdown existen desde las primeras versiones de Astro; las content collections llegaron después para poner tipado y validación sobre ese mismo material. Que ambas expongan los mismos encabezados no es casualidad, sino la señal de que las colecciones no reemplazaron el motor de Markdown, solo le añadieron una capa de contrato por encima. Debajo, el mismo compilador produce el mismo análisis, y por eso getHeadings y render().headings no pueden divergir.
La forma de un heading
Cada elemento de la lista de encabezados es un objeto de tres campos, y conocerlos es lo que permite construir cualquier navegación sobre ellos. depth es el nivel del título, un número de 1 a 6 que corresponde a h1 hasta h6. text es su texto visible. Y slug es el id que Astro le asignó en el HTML, el mismo que sirve de ancla.
// Forma de cada elemento de headingstype Heading = { depth: number; // 1..6, el nivel del encabezado slug: string; // el id ancla, p. ej. "mi-seccion" text: string; // el texto visible del titulo};Un ejemplo concreto, para un documento con varias secciones, tendría esta forma:
[ { depth: 1, slug: 'introduccion', text: 'Introducción' }, { depth: 2, slug: 'motivacion', text: 'Motivación' }, { depth: 2, slug: 'alcance', text: 'Alcance' }, { depth: 3, slug: 'limites', text: 'Límites' },]Que slug coincida con el id real del encabezado en el documento es la pieza que lo hace todo posible. Significa que un enlace a # seguido de ese slug apunta con certeza al título correcto, sin que tengas que recalcular la transformación del texto ni temer que la ancla de la tabla y la del documento diverjan. La lista y el HTML hablan del mismo id porque ambos salen del mismo github-slugger en tiempo de compilación.
Y todo ello se calcula durante el build, no en el navegador. La lista de encabezados se construye cuando Astro compila el Markdown y viaja ya resuelta a la plantilla; el cliente recibe una tabla de contenidos que es HTML estático, no el fruto de escanear el documento en tiempo de ejecución. Esa es la diferencia entre derivar el índice de un dato precalculado y reconstruirlo con JavaScript en cada visita: lo primero no cuesta nada al usuario, lo segundo se paga en cada carga.
El campo depth merece atención aparte, porque es la clave para reconstruir la jerarquía. La lista de encabezados es plana —un array en el orden en que aparecen—, pero cada elemento lleva su nivel, y con él puedes volver a anidar. Un h3 que sigue a un h2 es su hijo; otro h2 posterior abre una rama hermana. Recorrer la lista comparando el depth de cada elemento con el anterior es todo lo que se necesita para pasar de una secuencia plana a un árbol de secciones y subsecciones.
Una tabla de contenidos derivada
Con la lista en la mano, una tabla de contenidos es un simple recorrido. Filtras por los niveles que te interesan, mapeas cada heading a un enlace cuyo destino es # más su slug, y usas depth para sangrar visualmente la jerarquía. No hace falta analizar el HTML ni instrumentar el cuerpo: los datos ya vienen dados.
---import { getEntry, render } from 'astro:content';const post = await getEntry('docs', 'guia');const { Content, headings } = await render(post);const toc = headings.filter((h) => h.depth <= 3);---<nav aria-label="Contenido"> <ul> {toc.map((h) => ( <li data-depth={h.depth}> <a href={`#${h.slug}`}>{h.text}</a> </li> ))} </ul></nav><Content />La separación entre Content y headings es lo que permite que la misma entrada alimente dos vistas coordinadas: el cuerpo íntegro y su índice. Y como el slug de cada heading es el id real del documento, la tabla y el texto quedan enganchados por construcción: pulsar una entrada del índice lleva exactamente a su sección, sin código de sincronización.
La misma lista habilita más de lo que parece. Con un pequeño observador en el cliente puedes resaltar en el índice la sección visible a medida que el lector hace scroll —el patrón del scroll-spy—, porque cada entrada de la tabla ya conoce el id del bloque al que vigila. Puedes también generar migas de pan a partir del último h2 cruzado, o advertir en desarrollo si la jerarquía salta de un h2 a un h4 sin h3 intermedio, un defecto de accesibilidad que la lista deja ver de un vistazo. Todas son proyecciones distintas del mismo dato estructural que render calculó una vez.