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<T>(entries: { id: string; data: T }[], collection: string) {
const en = new Map<string, T>();
const es = new Map<string, T>();
for (const { id, data } of entries) {
if (id.endsWith('.es')) es.set(id.slice(0, -3), data);
else en.set(id, data);
}
for (const key of en.keys())
if (!es.has(key))
throw new Error(`${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<T>(
pairs: { key: string; en: T; es: T }[],
fields: readonly (keyof T)[],
collection: string,
) {
for (const pair of pairs)
for (const field of fields)
if (pair.en[field] !== pair.es[field])
throw new Error(
`${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 checkfalla si falta una clave. -
astro buildfalla 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.
Top comments (0)