Este sitio tiene dos collections de blog. No una collection con un campo en y otro es en cada entrada: dos llamadas defineCollection independientes, log y logEs, cada una escaneando su propio árbol de directorios, cada una con su propia instancia de esquema Zod, cada una renderizada por su propio archivo de ruta. Si buscas en src/content.config.ts encontrarás esto:
const log = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/log" }),
schema: logSchema,
});
const logEs = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/log-es" }),
schema: logSchema,
});
export const collections = { log, logEs };
La misma función de esquema, logSchema, invocada dos veces para producir dos collections tipadas de forma independiente. Nada vincula una entrada de log con su equivalente en logEs salvo una convención de sistema de archivos: resulta que viven en el mismo slug, en directorios hermanos (src/content/log/<slug>/index.mdx y src/content/log-es/<slug>/index.mdx). La capa de contenido de Astro no tiene ni idea de que existe una relación.
Esa es una forma deliberada, no un descuido, y merece la pena dejar por escrito el motivo: la alternativa obvia (una collection, un objeto translations, una única fuente de verdad por entrada) es genuinamente más elegante sobre el papel y genuinamente equivocada para cómo se escribe realmente este blog en concreto.
La forma y por qué no es obviamente la correcta
Un diseño de collection única se parecería a un esquema donde cada entrada lleva un campo content indexado por idioma (title.en, title.es, body.en, body.es) o, más habitualmente en el mundo Astro, un esquema con un mapa translations y un único loader canónico. En cualquier caso, la collection pasa a ser la unidad de completitud de traducción, y se vuelve estructuralmente imposible publicar una entrada en inglés sin al menos un hueco a nivel de esquema para su equivalente en español.
Eso suena a virtud. Para los textos de la interfaz (etiquetas de navegación, texto de botones, chrome de página) sí lo es, y este código base efectivamente hace eso: cada namespace de i18n vive en src/i18n/dictionaries/<namespace>.ts, y src/i18n/index.ts comprueba el tipo del export en español contra el de inglés con satisfies Widen<typeof en>. Si falta una clave en cualquiera de los dos lados, pnpm check hace fallar la compilación. No hay ninguna vía de escape del tipo export const es = en;. Ese es el contrato correcto para un conjunto fijo y pequeño de cadenas que siempre debe existir en ambos idiomas porque cada página depende de todas ellas.
Una entrada de blog no es eso. Una entrada de blog es un texto extenso, actualizado de vez en cuando, a veces abandonado, y las dos versiones lingüísticas de esa entrada no son el mismo artefacto traducido: son dos artefactos que resulta que cubren el mismo terreno. La versión en inglés de una entrada puede recibir un párrafo de seguimiento seis meses después que nunca llega a la versión en español, porque nadie ha tenido tiempo de hacerlo, y eso está bien, no es un fallo en el modelo de contenido, es simplemente cómo se prioriza en la práctica el trabajo de traducción cuando hay una sola persona haciéndolo en su tiempo libre.
Dos loaders, dos archivos de ruta, ningún pegamento entre collections
La collection log se renderiza mediante src/pages/log/[slug].astro; la collection logEs mediante src/pages/es/log/[slug].astro. Ambos archivos son casi idénticos (el mismo getStaticPaths, la misma llamada a render(), el mismo layout), con la única diferencia sustantiva en el nombre de collection que se pasa a getCollection:
// src/pages/log/[slug].astro
export async function getStaticPaths() {
const posts = await getCollection("log", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
// src/pages/es/log/[slug].astro
export async function getStaticPaths() {
const posts = await getCollection("logEs", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
Cada ruta solo conoce su propia collection. En ningún punto de la compilación la ruta en inglés se pregunta “¿existe una versión en español de este slug, y debería enlazarla?”. Si ese enlace llega a construirse algún día (un toggle de “leer esto en español” en la página de una entrada), será una búsqueda explícita contra logEs por slug, hecha deliberadamente, no algo que el modelo de contenido regale gratis. Ahora mismo no existe en absoluto, y nada en el esquema ni en los loaders obliga a que exista.
Esta es la misma duplicación por idioma que aparece en todo el resto del enrutado del sitio, no algo exclusivo del blog: ver Cada página es su propio archivo para entender por qué about.astro y es/about.astro son archivos separados, casi idénticos, por la misma razón subyacente por la que log y logEs son collections separadas, casi idénticas.
Hay una razón más pequeña pero muy concreta por la que las dos collections viven en árboles de directorios separados en lugar de como hermanos index.mdx / index.es.mdx en una sola carpeta: el loader glob de Astro colapsa un nombre de archivo index desnudo en el slug de su carpeta padre. Dos variantes de index.* en el mismo directorio colisionarían con ese colapso, o necesitarían una regla de derivación de slug distinta e inconsistente para la que lleva sufijo. Mantener log-es como un árbol completamente separado, reflejando la disposición de carpeta-por-slug de log, significa que exactamente la misma configuración de loader y exactamente la misma lógica de slug se aplican a ambos: lo único que cambia entre las dos llamadas a defineCollection es la ruta base.
La división vuelve a aparecer en “entradas relacionadas”, no solo en el enrutado
La independencia no se limita a los dos archivos de ruta: se propaga a cada componente que tiene que decidir “cuáles son los candidatos aquí”. RelatedPosts.astro, el componente que ambas páginas de entrada renderizan al final, es un buen ejemplo porque toma la misma decisión que los archivos de ruta, en miniatura:
const collectionName = lang === "es" ? "logEs" : "log";
const all = await getCollection(
collectionName as "log",
({ data }) => !data.draft,
);
const related = all
.filter((p) => p.id !== post.id && p.data.category === post.data.category)
.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf())
.slice(0, 3);
La lista de “más en esta categoría” de una entrada en inglés se construye exclusivamente a partir de getCollection("log"): nunca puede mostrar una entrada en español, ni siquiera una sobre el mismo tema exacto, ni siquiera una que sería, discutiblemente, la lectura relacionada más relevante de todas si las dos collections compartieran namespace. Eso no es un bug en RelatedPosts.astro; es la misma independencia que establece el modelo de contenido, apareciendo correctamente una capa más arriba. El componente no está filtrando entradas de otro idioma como caso especial; nunca tiene la opción de verlas, para empezar, porque lang elige exactamente una collection sobre la que consultar y esa es toda la reserva de candidatos.
Merece la pena nombrar esto porque es el tipo de consecuencia fácil de pasar por alto cuando se razona sobre la división en dos collections solo a nivel de esquema. La división no es solo “dos loaders en lugar de uno”: es una decisión que se propaga a cada consulta posterior que tiene que elegir un conjunto de entradas con el que trabajar: las listas de entradas relacionadas, los directorios por categoría, la paginación de la página índice del log, los constructores de breadcrumb JSON-LD y de esquema BlogPosting que reciben un parámetro lang y nunca ven la otra collection en absoluto. Cada uno de esos puntos de llamada vuelve a derivar de forma independiente “con qué collection estoy trabajando” a partir del idioma actual, y cada uno de ellos, como consecuencia, trata las entradas del otro idioma como si no existieran. Eso es coherente con el modelo, no una fuga en él —pero es un coste real y acumulativo de elegir dos collections en vez de una, y merece la pena saber que se extiende más allá del esquema de contenido antes de decidir si el trade merece la pena para un sitio dado.
Lo que esto cede
El coste honesto: no hay ninguna señal en tiempo de compilación de que a una entrada en inglés le falte su traducción al español, o viceversa. Si alguien borra src/content/log-es/some-slug/index.mdx por accidente, pnpm check no se quejará. La página índice de /log en español simplemente listará silenciosamente una entrada menos, y nada se pondrá en rojo. Una comprobación de paridad de traducción, si este sitio quisiera alguna vez una, tendría que ser un script separado (recorrer ambos árboles, comparar los conjuntos de slugs, fallar el CI ante un desajuste), porque el esquema de content collections genuinamente no puede expresar “esta entrada en inglés requiere una entrada en español correspondiente” sin colapsar de vuelta a una única collection o añadir un paso de validación entre collections a medida, para el que las content collections no están diseñadas.
Esa es una brecha real. Para los diccionarios de interfaz sería inaceptable, que es exactamente por qué esa parte del código base impone la paridad a nivel de tipos en su lugar. Para las entradas de formato largo es una brecha con la que este sitio elige convivir, porque la alternativa (bloquear la publicación de una entrada en inglés hasta que exista su traducción al español, o viceversa) ralentizaría lo que realmente importa aquí, que es publicar.
Lo que recupera: una entrada solo en inglés cuesta cero andamiaje en español
Aquí está el caso concreto, y no es hipotético: es exactamente este lote de trabajo. Veinte nuevas entradas del Engineering Log van a src/content/log/ como parte de esta tanda, todas en inglés, y las instrucciones para escribirlas son explícitas: no tocar src/content/log-es/ en absoluto, no hace falta nada ahí. Esa instrucción solo tiene sentido porque las dos collections son independientes.
Si esto fuera una sola collection con un hueco es exigido por el esquema, añadir veinte entradas en inglés significaría que el esquema o bien las rechaza directamente (falta un campo obligatorio) o las acepta con veinte entradas en español vacías o de relleno sentadas en el árbol de contenido, lo cual es peor que no tener versión en español, porque ahora hay un stub que parece contenido pero no lo es. Ninguna de las dos opciones es “simplemente escribir la entrada en inglés”.
Con dos collections independientes, el lado en inglés del blog puede crecer al ritmo que marque la escritura en inglés, y el lado en español crece por separado, más tarde, quien sea que haga esa pasada de traducción, contra el subconjunto de slugs que elija cubrir: posiblemente los veinte, posiblemente tres, posiblemente ninguno durante un tiempo. Ambos son estados válidos del árbol de contenido. No hace falta migrar nada, no hace falta ningún relleno, y pnpm check se mantiene en verde durante todo el proceso, porque el verde nunca dependió de que las dos collections estuvieran de acuerdo entre sí, para empezar.
El trade solo tiene sentido en la dirección en la que realmente se usa aquí: esto es un sitio personal con un único autor principal, un único idioma principal de redacción, y la traducción como una pasada genuinamente separada y de frecuencia menor. Un equipo que produjera ambos idiomas al mismo ritmo desde el primer día, con un traductor en plantilla y una política de publicar los dos o ninguno, estaría mejor servido por el modelo de collection única y su garantía de paridad en tiempo de compilación. Contenido distinto, ritmo distinto, respuesta correcta distinta: la división en dos collections no es un patrón universalmente correcto, es el patrón correcto para cómo se escribe realmente este blog en concreto.