Saltar al contenido

Los loaders integrados: glob() y file()

Los dos loaders que Astro trae de fábrica: glob para muchos ficheros y file para un fichero con una lista dentro. Patrones de rutas con pattern y base, el parseo según la extensión —Markdown frente a datos— y cómo se deriva el id de cada entrada en Astro 7.

glob y file: dos disposiciones del contenido

glob es el loader de “muchos ficheros”. Recorre un directorio, selecciona los ficheros que casan con un patrón y produce una entrada por cada fichero. Es el loader natural de un blog, una documentación o un porfolio, donde cada pieza de contenido merece su propio Markdown. Se configura con dos opciones centrales, pattern y base, y una tercera opcional, generateId.

TypeScript
import { glob } from 'astro/loaders';const blog = defineCollection({  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),  schema: z.object({ title: z.string(), pubDate: z.coerce.date() }),});

file, en cambio, es el loader de “un solo fichero”. Toma un único documento de datos —un JSON o un YAML que contiene una lista o un mapa— y produce una entrada por cada elemento de esa lista. Es el formato idóneo para datos que se editan juntos y no merecen un fichero cada uno: autores, países, una tabla de precios, la configuración de unos redirects.

TypeScript
import { file } from 'astro/loaders';const autores = defineCollection({  loader: file('src/data/autores.json'),  schema: z.object({ id: z.string(), nombre: z.string() }),});

La elección entre ambos no es de gusto sino de disposición. Si cada unidad de contenido es larga, se edita por separado y quizá lleva cuerpo Markdown, glob le da a cada una su fichero. Si son muchos registros cortos que se revisan de un vistazo y cambian a la vez, file los mantiene juntos en un documento. Un mismo proyecto suele usar los dos: glob para los posts, file para la lista de autores a los que esos posts referencian.

Patrones de rutas y parseo por extensión

El pattern de glob es un patrón glob resuelto contra base. **/*.md toma todos los Markdown a cualquier profundidad; *.md se limita al primer nivel; **/*.{md,mdx} admite dos extensiones; y un array combina reglas. La exclusión con ! es la herramienta para descartar borradores o parciales: ['**/*.md', '!**/_*.md'] incluye todo el Markdown salvo el que empieza por guion bajo.

TypeScript
loader: glob({  pattern: ['**/*.md', '!**/_borradores/**'],  base: './src/content/docs',})

Lo que ocurre después de seleccionar un fichero depende de su extensión, y aquí está el matiz más importante de glob. Ante un .md o un .mdx, el loader separa el frontmatter del cuerpo: el frontmatter se convierte en el data de la entrada —el que valida tu esquema— y el cuerpo queda como body sin renderizar, listo para que render() lo transforme en HTML. Ante un .json o un .yaml, no hay cuerpo: todo el fichero es data, y la entrada no tendrá contenido renderizable.

Esa distinción tiene una consecuencia práctica que sorprende a quien la descubre tarde: solo las entradas con cuerpo —las que vienen de Markdown— pueden renderizarse con render(). Una entrada nacida de un JSON es datos puros; pedirle contenido renderizado no tiene sentido, porque nunca hubo un cuerpo que renderizar. El parseo por extensión decide, en definitiva, si una entrada es “un documento con texto” o “un registro de datos”.

file parsea distinto porque su entrada es otra. Espera que el fichero contenga un array de objetos —y saca una entrada por objeto— o un mapa de objetos —y saca una entrada por par clave-valor—. Para formatos que no sabe leer de fábrica, como CSV o TOML, admite una opción parser: una función que recibe el texto crudo y devuelve el array o el mapa ya parseado. Así, el loader integrado se estira a fuentes que no son JSON ni YAML sin necesidad de escribir un loader entero.

TypeScript
loader: file('src/data/tabla.csv', {  parser: (texto) => parseCsvAObjetos(texto),})

Combinar varios loaders en un proyecto

Rara vez un sitio usa un solo loader. Un blog real declara varias colecciones a la vez —posts con glob, autores con file, quizá una tabla de etiquetas con otro file— y todas conviven en el mismo content.config.ts. Cada una elige el loader que su disposición pide, y el objeto collections las reúne bajo sus nombres, que son la identidad con la que luego las consultarás.

TypeScript
import { defineCollection, z } from 'astro:content';import { glob, file } from 'astro/loaders';const blog = defineCollection({  loader: glob({ pattern: '**/*.mdx', base: './src/content/blog' }),  schema: z.object({ title: z.string(), autor: z.string() }),});const autores = defineCollection({  loader: file('src/data/autores.json'),  schema: z.object({ id: z.string(), nombre: z.string() }),});export const collections = { blog, autores };

Que convivan no significa que se mezclen. Cada colección tiene su propio store y su propio espacio de id, de modo que un id repetido en dos colecciones distintas no colisiona: getEntry('blog', 'ana') y getEntry('autores', 'ana') apuntan a entradas diferentes. Esa separación por colección es lo que permite que el campo autor de un post —una cadena— haga de referencia al id de la colección autores, tejiendo relaciones entre conjuntos de datos sin que sus identidades se pisen.

El id de cada entrada

Cada entrada del store necesita un id único dentro de su colección, y cada loader lo deriva a su manera. glob lo saca de la ruta: toma el camino del fichero relativo a base, le quita la extensión y lo normaliza. Así blog/mi-post.md produce el id mi-post, y blog/2026/enero.md produce 2026/enero. Ese id es la llave estable con la que getEntry('blog', 'mi-post') localiza la entrada, y casi siempre coincide con el slug de su URL.

Cuando la ruta no es el id que quieres, glob acepta una función generateId que lo forja a tu gusto —a partir de un campo del frontmatter, por ejemplo—. Recibe la ruta y los datos ya parseados, y devuelve la cadena que quieras usar como identidad. Es la vía para que el id salga de un campo slug explícito en lugar del nombre del fichero.

TypeScript
loader: glob({  pattern: '**/*.md',  base: './src/content/blog',  generateId: ({ entry, data }) => data.slug ?? entry,})

file no deriva el id de una ruta, porque todas sus entradas comparten fichero. Cuando el documento es un array, cada objeto debe traer su propio campo id, y ese valor es la identidad de la entrada. Cuando es un mapa, la clave de cada par hace de id y los objetos no necesitan repetirlo. En ambos casos, el id está dentro de los datos, no en el sistema de ficheros.

Un detalle que conviene no olvidar: los id deben ser únicos dentro de la colección. Con glob esa unicidad viene casi regalada, porque las rutas ya son únicas en un sistema de ficheros; el riesgo aparece si tu generateId produce el mismo valor para dos ficheros distintos, en cuyo caso una entrada pisa a la otra sin ningún aviso. Con file, la responsabilidad es tuya: dos objetos con el mismo campo id, o dos claves iguales en el mapa, provocan la misma colisión silenciosa. Mantener un generateId inyectivo y unos identificadores sin repeticiones es lo que garantiza que ninguna entrada devore a otra.

La estabilidad del id importa más de lo que parece, porque es la referencia con la que unas colecciones apuntan a otras. Si un post referencia a su autor por id, cambiar ese id —renombrando el fichero, alterando el campo— rompe la referencia. Por eso conviene tratar el id como un identificador de verdad: estable, deliberado, no un accidente del nombre que le pusiste al fichero un martes por la tarde.