wandres.dev
CONTENT LAYER API · loaders personalizados

El loader como objeto: load, store y contexto

El loader con name, load y schema que recibe el contexto y escribe directamente en el store. Manejar set, get, has, delete y clear; validar con parseData, calcular la huella con generateDigest y hablar con el logger para convertir una carga en un loader reutilizable en Astro 7.

⏱ 16 min

El loader inline devuelve un array y se desentiende; el loader como objeto toma las riendas. En vez de entregar datos para que Astro los guarde, recibe el store y escribe en él directamente, entrada por entrada, decidiendo qué añade, qué actualiza y qué borra. Ese acceso al almacén —junto a un puñado de herramientas de contexto: parseData, generateDigest, un logger— es lo que convierte una simple carga en un loader de verdad: reutilizable, eficiente y consciente de lo que ya existía.

🎯 Al terminar esta lección sabrás
  • Distinguir el loader como objeto —con name, load y schema— del loader inline.
  • Manejar el store para escribir, leer, comprobar y borrar entradas dentro de load.
  • Validar cada entrada con parseData antes de guardarla y calcular su huella con generateDigest.
  • Emitir mensajes útiles con el logger del contexto del loader.

De la función al objeto

Un loader como objeto es una estructura con tres piezas: un name que lo identifica en los logs y en la caché, una función load donde ocurre todo el trabajo, y un schema opcional que puede depender de la propia carga. La diferencia crucial con el loader inline es que load no devuelve nada: en lugar de entregar un array, recibe un contexto con el store y escribe en él directamente. El loader pasa de “producir datos” a “gestionar un almacén”.

import type { Loader } from 'astro/loaders';

export function apiLoader(url: string): Loader {
  return {
    name: 'api-loader',
    load: async ({ store, parseData, generateDigest, logger }) => {
      logger.info(`Cargando desde ${url}`);
      const res = await fetch(url);
      const items = await res.json();

      store.clear();
      for (const item of items) {
        const data = await parseData({ id: item.id, data: item });
        const digest = generateDigest(data);
        store.set({ id: item.id, data, digest });
      }
    },
  };
}

Este patrón —una función que recibe opciones y devuelve un objeto Loader— es el idiomático, porque convierte al loader en una pieza configurable y reutilizable. Lo declaras una vez y lo usas en varias colecciones con distintas url, igual que glob y file son fábricas que devuelven loaders según sus opciones. El loader como objeto no es solo más potente que el inline: es la forma de empaquetar una integración y compartirla.

const noticias = defineCollection({
  loader: apiLoader('https://api.ejemplo.com/noticias'),
  schema: z.object({ id: z.string(), titular: z.string() }),
});

El schema, además, puede vivir dentro del loader en lugar de en defineCollection. Un loader que conoce la forma de su fuente —porque habla con una API concreta— puede exponer su propio esquema, incluso uno asíncrono que consulte la API para derivarlo. Cuando el loader trae su esquema, quien lo usa no tiene que escribir uno: la integración llega completa, con su forma incluida.

El store por dentro

Dentro de load, el store es tu interfaz con el almacén de la colección, y ofrece justo los métodos de un mapa persistente. store.set añade o reemplaza una entrada; recibe un objeto con al menos un id y un data, y admite body, rendered y digest cuando aplican. store.get recupera una entrada por su id. store.has comprueba si existe. store.delete la elimina. Y store.clear vacía la colección entera de un golpe.

store.set({ id: 'post-1', data });     // anade o reemplaza
const entrada = store.get('post-1');   // recupera por id
if (store.has('post-2')) { /* ... */ } // comprueba existencia
store.delete('post-3');                // elimina una
store.clear();                          // vacia la coleccion entera

Para recorrer lo que hay, el store ofrece store.keys, store.values y store.entries, que devuelven los id, los datos y los pares completos. Con ellos, un loader puede razonar sobre el estado previo: comparar las claves que ya tenía con las que la fuente ofrece ahora, y borrar las que desaparecieron. Esa capacidad de mirar atrás —de saber qué había antes— es justo lo que el loader inline no tenía y lo que abre la puerta a las actualizaciones incrementales.

El gesto de store.clear seguido de un bucle de store.set es el patrón de “reemplazo total”: olvida todo y vuelve a poblar. Es correcto y simple, y para muchas fuentes basta. Pero fíjate en que ahora eliges hacerlo, en lugar de que te lo impongan: podrías, en su lugar, actualizar solo las entradas cambiadas y conservar el resto. El loader como objeto te da esa elección; qué hagas con ella es la diferencia entre un loader que rehace todo y uno que solo toca lo justo.

ℹ️
load no devuelve, escribe

El error más común al pasar del inline al objeto es intentar return de un array desde load. No es así: load devuelve void —una promesa que resuelve a nada— y su efecto se produce escribiendo en el store. Si tu load termina sin haber llamado a store.set, la colección queda vacía por mucho que hayas descargado datos. El trabajo del loader objeto no es devolver entradas, sino depositarlas.

parseData, generateDigest y el logger

El contexto de load trae, junto al store, tres herramientas que elevan un bucle de carga a un loader robusto. parseData es la validación: recibe un id y un data crudo, lo comprueba contra el esquema de la colección y devuelve el dato ya tipado, o lanza si no cumple. Llamarlo antes de store.set es lo que garantiza que al almacén solo llegue contenido válido; saltárselo es meter datos sin verificar y perder la red de seguridad que justifica todo el sistema.

const data = await parseData({ id: item.id, data: item });
// data esta validado contra el schema; si no cumpliera, parseData habria lanzado
store.set({ id: item.id, data, digest: generateDigest(data) });

generateDigest calcula una huella —un hash— de un dato: la misma entrada produce siempre el mismo digest, y una entrada distinta produce otro. Su utilidad es detectar cambios sin comparar campo a campo. Si guardas el digest de cada entrada en el store, en la siguiente carga puedes calcular el digest nuevo y compararlo con el viejo: si coinciden, la entrada no cambió y puedes ahorrarte reprocesarla. store.set incluso usa el digest para saltarse la escritura si nada cambió. Es la pieza técnica sobre la que se construye el caché incremental de la próxima lección.

El logger, por fin, es la voz del loader. En vez de un console.log anónimo, emite mensajes con el nombre del loader por delante, integrados en la salida de Astro, con niveles —logger.info, logger.warn, logger.error, logger.debug—. Un loader que habla con una fuente remota debería contar lo que hace: cuántas entradas trajo, si la fuente respondió con un cambio o sin él, cuánto tardó. Ese rastro es lo que convierte un fallo de integración de un misterio en un diagnóstico.

🏦

store

El almacen de la coleccion. Escribes con store.set, lees con store.get, recorres con store.entries.

parseData

Valida un dato crudo contra el esquema y lo devuelve tipado, o lanza. La aduana antes de store.set.

🧬

generateDigest

Calcula la huella de una entrada para detectar cambios sin comparar campo a campo.

📣

logger

La voz del loader: mensajes con su nombre y nivel, integrados en la salida de Astro.

flowchart TD
LOAD[load recibe el contexto] --> CTX[store parseData generateDigest logger]
CTX --> FETCH[trae datos de la fuente]
FETCH --> LOOP[por cada item]
LOOP --> PARSE[parseData valida contra el schema]
PARSE --> DIG[generateDigest calcula la huella]
DIG --> SET[store set deposita la entrada]
SET --> LOOP
style LOAD fill:#89b4fa,color:#11111b
style SET fill:#a6e3a1,color:#11111b

Entradas con cuerpo, no solo datos

Hasta aquí el loader guardaba entradas de puros datos, pero puede también aportar contenido renderizable, como hace glob con el Markdown. Para eso, store.set acepta dos campos más: body, el texto original sin procesar, y rendered, un objeto con el HTML ya generado y sus metadatos. Una entrada con rendered es la que luego responde a render() en la plantilla; una sin él es un registro de datos y nada más.

Generar ese HTML a mano sería tedioso, así que el contexto de load ofrece una ayuda: renderMarkdown, una función que toma una cadena de Markdown y devuelve el rendered listo para guardar. Con ella, un loader que trae artículos de un CMS escritos en Markdown puede entregarlos tan renderizables como si fueran ficheros locales, sin salir del sistema de collections.

load: async ({ store, parseData, renderMarkdown }) => {
  const posts = await traerPostsDelCms();
  for (const post of posts) {
    const data = await parseData({ id: post.slug, data: post.meta });
    const rendered = await renderMarkdown(post.cuerpo);
    store.set({ id: post.slug, data, body: post.cuerpo, rendered });
  }
}

La distinción entre entrada de datos y entrada con cuerpo, que en glob venía dada por la extensión del fichero, aquí la decides tú al construir la entrada. Es el mismo eje de siempre —si esto es un registro o un documento— pero ahora bajo tu control total: un loader puede producir colecciones de datos, de documentos o de ambas cosas, según lo que su fuente ofrezca y lo que tú decidas depositar.

El loader objeto invierte el control sobre el almacén

El paso del loader inline al loader objeto es, en miniatura, uno de los grandes patrones de la ingeniería de software: la inversión de control. En el modelo inline, tú produces datos y el framework decide qué hacer con ellos —tú entregas un array, Astro lo guarda a su manera—. En el modelo objeto, el framework te entrega sus herramientas —el store, la validación, la huella, el registro— y eres tú quien orquesta el trabajo dentro del ciclo de vida que él define. Has dejado de ser un proveedor de datos para convertirte en el director de la carga, con acceso a la maquinaria que antes estaba oculta. Este trueque —más responsabilidad a cambio de más poder— es el mismo que distingue usar una librería de escribir un plugin, consumir una API de implementar una interfaz, rellenar un formulario de programar el manejador. Y como todo trueque de ese tipo, solo merece la pena cuando el problema lo pide: si te basta con devolver un array, quedarte en el inline es sabiduría, no pereza. Pero cuando necesitas mirar lo que ya había, actualizar con cirugía en vez de con demolición, recordar entre ejecuciones o hablar con claridad en los logs, el loader objeto te da las llaves del almacén. Lo profundo no es la lista de métodos del store, sino el cambio de postura: pasas de confiar en que el framework haga lo correcto con tus datos a asumir tú la responsabilidad de dejar el almacén exactamente como debe quedar. Dominar esa postura —saber cuándo reclamar el control y qué hacer con él— es lo que separa a quien usa loaders de quien los escribe para que otros los usen.

⚔️ Escribe un loader como objeto
  1. Convierte un loader inline en un loader objeto: una fábrica que recibe una url y devuelve un name y un load, escribiendo en el store en vez de devolver un array.
  2. Dentro de load, valida cada entrada con parseData antes de store.set y comprueba que un dato inválido detiene el build igual que con el esquema de defineCollection.
  3. Añade un generateDigest por entrada y guárdalo en el store; imprime con el logger cuántas entradas cargaste.
  4. Recorre el store con store.keys y borra con store.delete una entrada que ya no venga de la fuente, razonando por qué el inline no podía hacer esto.