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

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.

⏱ 15 min

Un Markdown compilado tiene dos caras que Astro entrega por separado: el HTML de su cuerpo, listo para pintarse, y la estructura de sus encabezados, lista para navegarse. La primera llega como un componente <Content /> que colocas en la plantilla; la segunda, como un array de objetos que describen cada título con su nivel, su texto y su ancla. Que vengan separadas no es un capricho: te deja renderizar el cuerpo donde toca y, a la vez, construir una tabla de contenidos con datos exactos, sin volver a leer el HTML ni adivinar dónde empieza cada sección.

🎯 Al terminar esta lección sabrás
  • Renderizar el cuerpo de una entrada de colección con render y <Content />.
  • Obtener los encabezados de un Markdown importado directamente con getHeadings.
  • Interpretar la forma de cada heading: depth, slug y text.
  • Construir una tabla de contenidos derivada de esa lista, sin analizar el HTML.

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.

📝
Dos puertas, una misma habitacion

render(entry).headings y modulo.getHeadings() devuelven exactamente la misma estructura. La primera es la puerta de las colecciones; la segunda, la de las importaciones directas. Saber que ambas conducen a la misma lista te evita creer que son mecanismos distintos: son dos accesos al mismo dato que Astro calculó una sola vez al compilar el Markdown.

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 headings
type 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' },
]
📝
getHeadings recoge todos los niveles

La lista incluye los seis niveles de encabezado, de h1 a h6, en el orden en que aparecen. Si tu tabla de contenidos solo quiere h2 y h3, el filtrado es cosa tuya: la API no decide por ti qué es relevante. Esa neutralidad es deliberada —te entrega el dato completo— y deja en la vista la política de qué mostrar, que es exactamente donde debe vivir.

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 />
💡
De lista plana a arbol anidado

La lista de headings es plana, pero construir un índice anidado a partir de ella es un recorrido corto: llevas una pila con las secciones abiertas y, por cada heading, comparas su depth con el de la cima. Si es mayor, anidas; si es menor o igual, cierras ramas hasta hallar su sitio. Ese pequeño algoritmo —el mismo que arma cualquier árbol desde una secuencia con niveles— convierte los seis niveles de encabezado en una jerarquía visual sin que el Markdown tenga que declararla.

flowchart LR
ENTRY[entrada markdown] --> RENDER[render de la entrada]
RENDER --> CONTENT[Content a html]
RENDER --> HEADINGS[lista de headings]
HEADINGS --> FILTER[filtrar por depth]
FILTER --> TOC[tabla de contenidos]
TOC --> LINK[enlaces a slug ancla]
style ENTRY fill:#89b4fa,color:#11111b
style TOC fill:#a6e3a1,color:#11111b

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.

Los encabezados son una API, no una decoracion

El giro mental de esta lección es dejar de ver los títulos de un documento como tipografía y empezar a verlos como una interfaz de programación sobre su estructura. Cuando Astro te entrega headings —o su gemela getHeadings— no te está dando un adorno tipográfico, sino un modelo navegable del esqueleto del texto: qué secciones hay, en qué nivel anidan y con qué ancla se alcanza cada una. Esa lista es la representación de datos de algo que normalmente vive enterrado en la prosa, y exponerla cambia lo que puedes construir. Una tabla de contenidos deja de ser un fragmento que mantienes a mano y se convierte en una proyección automática de la estructura real; si añades una sección al Markdown, el índice la refleja sin que toques la navegación. Pero no se detiene ahí: sobre esa misma lista puedes resaltar la sección visible al hacer scroll, generar migas de pan, calcular la profundidad máxima del documento o validar que la jerarquía no salte de un h2 a un h4. El principio que subyace es viejo y poderoso: cuando conviertes una estructura implícita en datos explícitos, habilitas herramientas que antes eran imposibles. Los encabezados dejan de ser algo que se lee y pasan a ser algo que se consulta, y esa es la diferencia entre maquetar una tabla de contenidos y derivarla.

⚔️ Un indice que se mantiene solo
  1. Renderiza una entrada de colección con render y coloca su <Content />, extrayendo a la vez su lista de headings.
  2. Construye una tabla de contenidos que incluya solo los niveles h2 y h3, mapeando cada heading a un enlace hacia # más su slug.
  3. Usa el campo depth para sangrar visualmente los h3 respecto a los h2, sin recurrir a ninguna otra fuente de datos.
  4. Añade una nueva sección al Markdown, recarga y comprueba que aparece en el índice sin haber tocado la plantilla; explica por qué eso ocurre.