Que tu build de Astro falle si falta una traducción
English version: Make missing translations fail your Astro build Hago sitios para clientes que necesitan inglés y español. El error que se me escapaba una y otra vez era siempre el mismo: alguien agrega un texto en inglés, nadie lo traduce, y semanas después lo descubre un visitante que habla español. Casi siempre es el segundo idioma el que se rompe en silencio. El enrutamiento i18n de Astro te da / y /es/, hreflang y URLs por idioma. Lo que no te dice es que a tu página en español le falta un testimonio, o que la tabla de precios en español todavía dice $29 después de que subiste el precio a $39. Así que convertí esos desajustes en un error de compilación. Son tres comprobaciones, con Astro y TypeScript puros, sin librerías. 1. Textos tipados: si falta una clave, no compila Pon todo el texto fijo del sitio —menú, hero, botones, metadatos— en dos archivos de TypeScript, y deriva el tipo del archivo en inglés: // src/content/copy.en.ts export const COPY_EN = { hero: { title: ‘Launch in English and Spanish on day one.’, cta: ‘Get early access’, }, features: [ { title: ‘Two languages, one deploy’, body: ’…’ }, // … ], }; export type SiteCopy = typeof COPY_EN; // src/content/copy.es.ts import type { SiteCopy } from ’./copy.en’; export const COPY_ES = { hero: { title: ‘Lanza en español e inglés desde el primer día.’, cta: ‘Quiero acceso anticipado’, }, features: [ { title: ‘Dos idiomas, un despliegue’, body: ’…’ }, // … ], } satisfies SiteCopy; La clave es satisfies. Si borras hero.cta del archivo en español, astro check falla, y tu editor lo marca antes de que guardes. Si agregas una clave que el inglés no tiene, también falla, así que los dos archivos no pueden separarse en silencio en ninguna dirección. 2. Misma cantidad en ambos idiomas: lo que satisfies no ve satisfies revisa la forma, no la longitud. Seis funciones en inglés y cinco en español cumplen igual con { title: string; body: string }[]. Y ese es el desajuste más común de todos: alguien agrega un testimonio en un solo idioma. Un recorrido recursivo pequeño lo detecta: // src/lib/copy-parity.ts export function assertCopyParity(en: unknown, es: unknown, path = ”): void { if (Array.isArray(en) && Array.isArray(es)) { if (en.length !== es.length) { throw new Error( copy: "${path}" has ${en.length} entries in English but ${es.length} in Spanish — + arrays must have the same length in both locales., ); } en.forEach((item, i) => assertCopyParity(item, es[i], ${path}[${i}])); return; } if (en && typeof en === ‘object’ && es && typeof es === ‘object’) { for (const key of Object.keys(en)) { assertCopyParity( (en as Record<string, unknown>)[key], (es as Record<string, unknown>)[key], path ? ${path}.${key} : key, ); } } } Llámala al cargar el módulo, en el único archivo que todas las páginas importan para obtener los textos, no dentro de una página: // src/content/copy.ts import { assertCopyParity } from ’../lib/copy-parity’; import { COPY_EN, type SiteCopy } from ’./copy.en’; import { COPY_ES } from ’./copy.es’; assertCopyParity(COPY_EN, COPY_ES); // se ejecuta en cada build export function copyFor(locale: ‘en’ | ‘es’): SiteCopy { return locale === ‘es’ ? COPY_ES : COPY_EN; } Como se ejecuta al cargar el módulo, salta en cada astro build, sin importar qué página importe copyFor primero. Y el error es concreto: Error: copy: “features” has 6 entries in English but 5 in Spanish — arrays must have the same length in both locales. 3. Contenido en pares: la misma entrada, dos archivos El contenido más largo —planes de precios, preguntas frecuentes, áreas de práctica— vive en colecciones de contenido Markdown, en pares de archivos: src/content/plans/ starter.md starter.es.md pro.md pro.es.md scale.md scale.es.md Aquí pueden fallar dos cosas. Puede faltar el archivo par, o un campo que debe ser idéntico en ambos idiomas —el precio, el orden— puede cambiar en un solo archivo. Lo segundo es traicionero, porque en una revisión de código nunca ves los dos idiomas en la misma pantalla. Empareja las entradas por nombre de archivo y compara los campos que no deben diferir: // src/lib/pairs.ts export function pairByLocale${collection}: "${key}" has no Spanish counterpart — expected src/content/${collection}/${key}.es.md to exist.); for (const key of es.keys()) if (!en.has(key)) throw new Error(${collection}: "${key}.es" has no English counterpart — expected src/content/${collection}/${key}.md to exist.); return […en].map(([key, data]) => ({ key, en: data, es: es.get(key)! })); } export function assertPairedFields${collection}: "${pair.key}" field "${String(field)}" differs across locales — + EN=${JSON.stringify(pair.en[field])}, ES=${JSON.stringify(pair.es[field])}., ); } // src/lib/content.ts import { getCollection } from ‘astro:content’; import { assertPairedFields, pairByLocale } from ’./pairs’; // Todo lo que no es texto traducido. const PAIRED_PLAN_FIELDS = [‘priceMonthly’, ‘priceYearly’, ‘featured’, ‘order’] as const; export async function getPlanPairs() { const pairs = pairByLocale(await getCollection(‘plans’), ‘plans’); assertPairedFields(pairs, PAIRED_PLAN_FIELDS, ‘plans’); return pairs.sort((a, b) => a.en.order - b.en.order); } Ahora, si subes el precio del plan Pro en pro.md y olvidas pro.es.md, obtienes: Error: plans: “pro” field “priceMonthly” differs across locales — EN=39, ES=29. La trampa que rompe el emparejamiento sin avisar Esta me costó una tarde. El loader glob() de Astro genera los ids de las entradas convirtiendo la ruta del archivo en slug, y al hacerlo elimina el punto: pro.es.md termina con el id proes. El sufijo .es desaparece, y el código de emparejamiento cree que proes es otra entrada en inglés sin su par en español. Genera el id a partir del nombre del archivo tú mismo: // src/content.config.ts import { defineCollection } from ‘astro:content’; import { glob } from ‘astro/loaders’; const fromFilename = ({ entry }: { entry: string }) => entry.replace(/.md$/, ”); const plans = defineCollection({ loader: glob({ pattern: ’**/*.md’, base: ’./src/content/plans’, generateId: fromFilename }), // schema: … }); Lo mismo pasa si tu frontmatter tiene un campo slug: el generateId por defecto lo usa, así que un par EN/ES que comparte slug se colapsa en una sola entrada. Qué ganas con esto astro check falla si falta una clave. astro build falla si falta un elemento en una lista, si falta el archivo par, o si un valor cambió en un solo idioma. Cada error dice exactamente qué ruta o archivo corregir. Nada de esto llega al navegador: todo corre al compilar. Son menos de 100 líneas en total, y sirve para cualquier cantidad de idiomas si generalizas el par en/es a un mapa. Empaqueté este enfoque en dos plantillas bilingües con Astro 7 y Tailwind 4: Despega, una landing para SaaS, y Despacho, para despachos y servicios profesionales. Son de pago ($79 / $59, pago único, proyectos ilimitados) en templates.bravelytech.com, pero todo lo de arriba lo puedes usar en cualquier proyecto. ¿Cómo mantienes sincronizados los idiomas en tus sitios multilingües? Me interesa saber cómo lo resuelven otros.