Skip to content

Internacionalización

Claude edited this page Oct 27, 2026 · 8 revisions

Internacionalización

English · Español

La interfaz está en 15 idiomas. El código está escrito en español: toda cadena visible para el usuario pasa por t("<cadena fuente en español>"), y cada uno de los demás idiomas es un diccionario cuya clave es esa misma cadena en español. No hay ninguna librería de i18n en ejecución; todo el mecanismo es la tabla de idiomas src/lib/idiomas.ts, src/components/language-provider.tsx, las funciones puras de src/i18n/traducir.ts, los diccionarios de src/i18n/<código>/ (los carga src/i18n/cargar.ts) y la comprobación scripts/idiomas.mjs, que se impone en CI.

Tres «idiomas» distintos (no los confundas)

Nombre Dónde vive Qué controla
Idioma de la interfaz LanguageProvider, clave de localStorage proxybuilder.lang (cualquier código de LANGS) Todo el texto de la UI, el formato de números y fechas y los mensajes de error de Rust tal como los ve el usuario.
Idioma de las cartas useUi().lang (src/stores/ui-store.ts), clave dentro de proxybuilder.preferencias En qué idioma se pide a Scryfall la impresión de una carta. Las opciones salen de IDIOMAS_SCRYFALL (en es fr de it pt ja ko ru zhs zht, con su nombre propio). Se elige en «Opciones» del panel de la lista («Idioma de las cartas»).
Idioma del diseño PrintSettings.designLang, DesignLang (Rust) En qué idioma pinta el texto el diseñador de cartas; véase Visión general del diseñador.

Esta página trata sobre todo del primero. Los tres comparten los mismos códigos a propósito (siguiente sección).

La tabla de idiomas: src/lib/idiomas.ts

Una sola tabla sirve para la interfaz y para los idiomas de diseño:

Export Propósito
LANGS Los 15 códigos, en el orden de la tabla: es (fuente), en, fr, de, it, pt (Brasil), ro, pl, nl, ru, uk, ja, ko, zhs, zht. De ahí sale type Lang (que language-provider.tsx vuelve a exportar).
IDIOMAS Un Idioma por código: codigo, nativo (el nombre en su propio idioma, nunca traducido, para que se reconozca en un selector), pais (país ISO de su bandera), locale (BCP-47) e impresas (si Scryfall tiene cartas impresas en él; falso en ro, pl, nl, uk).
esLang(x) Guarda de tipo: si x es uno de los códigos.
idioma(codigo) El Idioma de un código; uno desconocido devuelve el inglés.
localeDe(lang) El locale BCP-47 (es-ES, en-GB, pt-BR, zh-Hans-CN, zh-Hant-TW…).
IDIOMAS_SCRYFALL Los 11 idiomas con impresiones en Scryfall, en el orden de sus listas (el inglés primero), con el portugués llamado solo «Português», como en las cartas. Sustituye a tres listas que antes estaban escritas a mano (panel de la lista, filtros de variantes, biblioteca).

Por qué los códigos de Scryfall (pt, zhs, zht) y no BCP-47: así el idioma de la interfaz, el idioma de diseño de una carta (DesignLang en Rust usa los mismos códigos en el mismo orden) y el idioma de un deck son la misma clave en todas partes, sin tabla de equivalencias. El BCP-47 vive solo en locale, para Intl y <html lang>.

Política del idioma por defecto

Decisión: el idioma de fábrica de la interfaz es el inglés; cualquier otro se elige en Ajustes y se recuerda. Implementación: leerIdioma() (exportada desde language-provider.tsx) devuelve el código guardado cuando esLang lo acepta, y "en" en cualquier otro caso (ausente, desconocido, almacenamiento bloqueado). index.html incluye <html lang="en">. El README principal y las notas de las releases de GitHub también están en inglés (README.es.md es la copia en español). Deducido: el inglés es el idioma por defecto porque el proyecto apunta a un público más amplio que el de su código fuente en español, mientras que el español sigue siendo de primera clase porque es el idioma en el que se escriben las cadenas.

Cómo funciona t()

src/components/language-provider.tsx exporta:

Export Propósito
LanguageProvider Proveedor de contexto; estado lang.
useLang() Devuelve { lang, setLang, t, mensaje }. Lanza un error si se usa fuera del proveedor.
leerIdioma() El idioma guardado, o "en". También la usa src/main.tsx.
paraTraducir(es) Función identidad que marca una cadena como «traducir más tarde, en otro sitio». Para texto guardado en tablas de datos (p. ej. MARCAS en src/lib/cartas.ts, FASES en src/features/decklist/acciones.ts, TEXTOS en src/lib/errores.ts) que luego se pinta mediante t(variable). El script de comprobación ve los literales de paraTraducir("...").
traduccionSuelta(es, vars?) t() sin el proveedor (lee proxybuilder.lang directamente; usa el diccionario de ese idioma si ya está cargado y, si no, el inglés). Lo usa solo LimiteError, que debe envolver al proveedor para que un fallo dentro del proveedor siga mostrando una pantalla de recuperación traducida.
type Lang Reexportación del tipo de src/lib/idiomas.ts.

t(es, vars) es traducir(es, lang, vars, dic, EN) de src/i18n/traducir.ts, un módulo puro (sin React) para poder probarlo solo:

  • Cadena de respaldo: idioma → inglés → cadena fuente en español. Una clave que le falta, por ejemplo, al diccionario polaco muestra la entrada inglesa (quien usa la app en polaco entiende mejor el inglés que el español); si al inglés también le falta, se muestra la fuente en español. Nunca una cadena vacía ni una clave técnica. Esa red de seguridad también oculta las traducciones que faltan mientras se programa, que es justo por lo que existe el script de comprobación.
  • Plurales. Un valor del diccionario es una cadena o un objeto Plural (src/i18n/tipos.ts: zero?, one?, two?, few?, many?, other; Traduccion = string | Plural, Diccionario = Record<string, Traduccion>). La forma se elige por vars.n con Intl.PluralRules(locale) (en caché por locale); si falta la forma, o si n no es un número finito, se usa other. Cuando el valor sale del respaldo inglés, la forma la eligen las reglas de plural del inglés.
  • Interpolación. Los marcadores \{(\w+)\} se sustituyen desde vars; un marcador sin valor (o con null/undefined) se deja como {name} en lugar de imprimirse como undefined. Los valores numéricos se escriben con el Intl.NumberFormat del idioma de la interfaz (siempre, aunque el texto haya caído al inglés o al español). De ahí la regla: los recuentos que se pasan a t() son números, nunca formateados antes con toLocaleString/toFixed.
  • El formato de números, fechas, moneda y tiempo relativo fuera de t() usa localeDe(lang): formatoMoneda en src/features/stats/textos.ts, las fechas de src/features/stats/tipos.tsx, las fechas, tiempos relativos y precios de src/features/library/piezas.tsx y la fecha de los símbolos en src/features/disenar/recursos-dialog.tsx.
  • La función t solo se recrea cuando cambia lang (useMemo([lang])), por lo que es segura en listas de dependencias; los componentes se vuelven a pintar al cambiar el idioma porque cambia el valor del contexto.

Cambiar de idioma

setLang(l) guarda el código en localStorage (si el almacenamiento falla, se ignora: el idioma vive solo en esta sesión), carga primero el diccionario (cargarDiccionario) y solo después cambia el estado. Mientras carga se queda en pantalla el idioma anterior, sin parpadeo de textos a medio traducir. Una ref (pedido) recuerda el último idioma pedido: si se piden dos seguidos, gana el último aunque el diccionario del primero llegue después.

<html lang> recibe el locale completo (localeDe(lang), p. ej. ja-JP, zh-Hant-TW) y no solo el código: WebKit lo usa para elegir las formas japonesas o chinas de los ideogramas que comparten ambas escrituras.

Diccionarios y carga perezosa

Cada idioma tiene src/i18n/<código>/ con un JSON por área (las mismas 15 áreas que el inglés). El español es la fuente, así que es/ tiene la misma organización pero cada cadena vale lo mismo que su clave: pnpm idiomas comprueba que cada cadena pedida tenga entrada también ahí, y la app nunca lo carga (cargar.ts deja es/ fuera de su glob). Cada fichero es un objeto plano { "Cadena fuente en español": "traducción", ... }, con la indentación de tabuladores de Biome. Son datos, no código: no hay TypeScript que escribir mal, y el script de comprobación los lee sin transpilar. Ya no hay index.ts: cargar.ts funde todos los ficheros de área de un idioma, así que una área nueva solo necesita su fichero. Las claves van siempre entrecomilladas, como exige JSON. Lo que antes rechazaba el compilador de TypeScript lo rechaza ahora pnpm idiomas: una clave repetida dentro de un mismo fichero (JSON.parse se quedaría con la última en silencio) y una clave en dos áreas.

Fichero Área
comun.json Cadenas compartidas; empieza con los mensajes de ErrorCode de src/lib/errores.ts.
ajustes.json Diálogo de Ajustes (apariencia, actualizaciones, temas de tweakcn).
shell.json Barra superior, títulos de paneles, indicador de guardado, botón y toasts de actualización.
lista.json Panel de la lista, fases de resolución, marcas de carta.
vista.json Páginas de la vista previa, barra de herramientas, menú de carta, diálogo «mover a».
exportar.json Panel de exportación, presets, alineación.
biblioteca.json Biblioteca de mazos.
cartas.json Diálogos de añadir carta, variantes y editor de carta.
extras.json Puntos de entrada de las estadísticas y explorador de sets.
vincular.json Vincular cartas propias con Scryfall; zoom con la rueda; escala de la UI.
estadisticas.json Diálogo de estadísticas.
disenar.json La vista del diseñador (el fichero más grande).
tutorial.json Los tutoriales de las pantallas: el botón de ayuda, el contador de pasos y el título y el texto de cada paso (Tutoriales).
mesa.json La mesa de juego: la entrada, el tablero, los menús, los diálogos, los atajos, las frases del registro de la partida y Ajustes > «Mesa de juego» (Mesa de juego).
salas.json Jugar a través del servidor de salas: el diálogo «Con amigos…», las salas, la sala de espera, los playgroups y las invitaciones (Juego en línea).

Un src/i18n/<código>/identicas.json opcional lista las claves en español cuya traducción es, con razón, idéntica a la inglesa en ese idioma (p. ej. «Deck» en alemán); véase el script de comprobación.

src/i18n/cargar.ts:

  • El inglés va en el paquete principal (EN): es el respaldo de todos los idiomas, así que tiene que estar siempre.
  • Los demás son perezosos: import.meta.glob(["./*/*.json", "!./en/*.json", "!./es/*.json", "!./*/identicas.json"], { import: "default" }) crea un módulo perezoso por fichero de área. cargarDiccionario carga juntos los ficheros del idioma elegido y los funde, así que el arranque no lleva los otros 13 diccionarios.
  • cargarDiccionario(lang) carga un idioma una sola vez y lo guarda. El español no tiene diccionario (es la fuente); un idioma sin diccionario, o cuyo trozo no se puede cargar, se resuelve a undefined y sus textos caen al inglés.
  • diccionarioCargado(lang) devuelve el diccionario guardado, si lo hay.

src/main.tsx espera a cargarDiccionario(leerIdioma()) antes de montar (.finally(() => montar(raiz)), así que monta aunque falle la carga), para que la app nunca salga un instante en otro idioma; la ventana se enseña en paralelo (ensenarVentana()).

Glosario y tratamiento

src/i18n/GLOSARIO.md fija la terminología de cada idioma para que los términos que se repiten salgan como en las cartas oficiales y siempre igual:

  • En los idiomas con cartas impresas (fr, de, it, pt, ru, ja, ko, zhs, zht) los términos de juego salen de cartas reales en Scryfall, con la carta de referencia anotada.
  • ro, pl, nl y uk no tienen cartas impresas: los términos siguen el uso de su comunidad y van marcados como sin fuente oficial.
  • Lista lo que nunca se traduce (nombres de productos, formatos de fichero, .pbdeck, atajos de teclado, marcadores {x}) y el tratamiento de cada idioma (p. ej. vous en francés, du en alemán, formas impersonales en polaco, です/ます en japonés).
  • Una última tabla, «Términos de la mesa de juego», fija las acciones de juego de mesa.json en cada idioma: los términos de las reglas oficiales donde hay cartas impresas y lo habitual en la comunidad (a menudo el inglés) en ro, pl, nl y uk; con *, los no confirmados en una carta oficial.

Añadir o cambiar una cadena

  1. En el componente, escribe el texto visible a través del hook: const { t } = useLang(); y luego t("Texto en español"). Para texto fuera de React, en una tabla a nivel de módulo, envuélvelo en paraTraducir("...") y llama a t(value) donde se pinte.
  2. Añade el par al diccionario del área correspondiente en todos los idiomas (src/i18n/<código>/<área>.json), siguiendo src/i18n/GLOSARIO.md; en es/ el valor es la propia clave ("Texto en español": "Texto en español"). Reutiliza una clave existente cuando el texto en español sea idéntico y signifique lo mismo.
  3. Para un recuento, usa {n} y pasa un número; en los idiomas con varias categorías de plural, escribe un objeto Plural con exactamente las categorías de ese idioma (ruso one/few/many/other, japonés solo other…).
  4. Ejecuta pnpm idiomas (también forma parte de pnpm check, del hook de pre-commit para los .ts/.tsx preparados y de CI).
  5. Usa marcadores {name} para las partes dinámicas (t("Descargando la {version}…", { version })); nunca concatenes fragmentos traducidos.

Regla de desambiguación: si la misma palabra en español necesita dos traducciones distintas en sitios diferentes, haz más larga la cadena fuente del caso menos genérico (p. ej. añadiendo palabras de contexto). Nunca introduzcas un segundo diccionario ni un argumento de contexto.

Si cambias una cadena fuente en español existente, cambia también su clave en todos los diccionarios; si no, las entradas viejas quedan sin uso (un aviso) y la cadena nueva se reporta como que falta (un error).

scripts/idiomas.mjs (pnpm idiomas)

Qué hace:

  1. Cadenas pedidas (leerPedidas). Recorre src/ (saltándose node_modules, los directorios que empiezan por punto, bindings, src/i18n/ y *.test.ts(x)) y recoge cada literal entre comillas dobles en t("...") y paraTraducir("...") (primer argumento; con los escapes \" y \n resueltos), con el fichero donde se vio cada una por primera vez.
  2. Idiomas. Carga src/lib/idiomas.ts (leerTabla), que es código y por eso se sigue transpilando con typescript e importando. Luego lee cada directorio de diccionario (leerDiccionarios; se saltan los que empiezan por _): cada <área>.json se parsea con JSON.parse, y su texto en bruto se revisa por si una clave se repite en el mismo fichero, cosa que JSON.parse esconde sin avisar.
  3. Errores (estado de salida 1):
    • un idioma de la tabla sin directorio src/i18n/<código>/, o un directorio que no es de ningún idioma de la tabla;
    • una cadena pedida que no está en el diccionario fundido;
    • una clave que está en dos áreas o repetida dentro de un mismo fichero;
    • un fichero de área del inglés que le falta al idioma;
    • marcadores {x} distintos de los de la clave en español (también en cada forma de un plural);
    • un objeto de plural en una clave sin {n}, una categoría que el idioma no tiene o una categoría que falta (las del idioma son Intl.PluralRules(locale).resolvedOptions().pluralCategories);
    • un valor que no es ni una cadena ni un objeto de plural con other;
    • en el español, que se comprueba aparte con comprobarFuente: un valor que no es su propia clave, además de las cadenas que faltan, los ficheros de área que faltan y las claves repetidas de arriba (sin comprobar marcadores, plurales ni idénticas al inglés, porque cada valor es su clave).
  4. Avisos (no fallan): claves que ya no se piden, y valores idénticos al inglés (solo si tienen letras y no son una excepción permitida: la lista global IDENTICAS_PERMITIDAS del script o el identicas.json del idioma).
  5. Imprime una línea por idioma (✓ <código>: N cadenas más el número de avisos, o ✖ <código>: … seguido de cada error). --detalle lista además cada aviso.

leerPedidas, leerTabla, leerDiccionarios, comprobar y comprobarFuente se exportan para los tests de scripts/__tests__/idiomas.test.mjs.

Límites que conviene tener presentes:

  • Solo se ven los literales entre comillas dobles directamente dentro de t( / paraTraducir(. t(variable), las cadenas con comillas simples, las plantillas literales o las concatenaciones son invisibles para la comprobación, así que lleva ese texto por paraTraducir("...") en su definición.
  • El texto en español escrito en JSX sin t() no se detecta en absoluto.
  • Los ficheros .test.ts(x) están excluidos.
  • Una traducción equivocada pero bien formada no se puede detectar; el aviso de idénticas al inglés solo pilla las copias sin traducir.

Errores: useLang().mensaje(e)

Rust nunca escribe prosa para el usuario. Los comandos devuelven Result<T, AppError>, donde AppError = { code: ErrorCode, params: { [key]: string } } (src/bindings/AppError.ts, ErrorCode.ts; véase IPC y manejo de errores). En la pantalla:

// src/lib/errores.ts
export function mensajeDeError(e: unknown, t): string {
	if (esAppError(e)) {
		const clave = e.params.motivo ?? e.params.code;
		const propio = clave ? MOTIVOS[e.code]?.[clave] : undefined;
		return t(propio ?? TEXTOS[e.code], e.params);
	}
	if (e instanceof Error) return e.message;
	return String(e);
}

TEXTOS tiene una frase en español por cada ErrorCode (Network, RateLimited, NotFound, Io, Db, InvalidInput, Cancelled, Pdf, Image, Unsupported, Busy, Internal, Rejected, Server), cada una envuelta en paraTraducir. MOTIVOS añade frases propias para los motivos conocidos de un código (params.motivo, o params.code en el servidor de salas): los rechazos de la mesa de juego (Rejected: los del motor, NoExiste, SinPermiso, ZonaVacia, FueraDeTurno, Limite, NoSePuede, y los de la red y las repeticiones, como codigo, version, llena, espectador, partida_abierta), la actualización rechazada con una partida abierta (Busy con motivo: partida), los datos no válidos (InvalidInput: falta_lista, lista, lista_sin_nombre, codigo, deck, identidad), la conexión perdida (Network: sin_servidor, sin_respuesta, desconectado), NotFound con sin_sala, Unsupported con version y los códigos del servidor de salas (Server); un motivo desconocido usa la frase general del código. esAppError acepta cualquier objeto cuyo code sea una clave de TEXTOS, así que añadir un ErrorCode en Rust obliga a una entrada equivalente (el tipo Record<ErrorCode, string> hace fallar tsc si no). Los componentes llaman a toast.error(mensaje(e)).

Los casos especiales los gestiona el componente que conoce el contexto, usando params: por ejemplo TemasTweakcn (src/features/settings/temas-tweakcn.tsx) comprueba e.params.motivo === "tema_enlace" para mostrar «Eso no es un enlace ni un id de un tema de tweakcn.» en lugar del mensaje genérico de entrada inválida. SaveIndicator muestra mensaje(error) en un tooltip cuando falla el autoguardado.

El registro de la mesa de juego sigue la misma regla: cada evento del motor llega a la pantalla como dato y src/features/mesa/textos-eventos.ts escribe su frase con t() (FRASES, una por Accion, y los cambios especiales), según quien mira, así las cartas ocultas nunca se nombran (Mesa de juego).

Los datos que vienen de Rust y se muestran (nombres de Scryfall, texto de reglas, notas de la release) se muestran tal cual: las notas de la release en el panel de actualización son Markdown en inglés de la release de GitHub. Los avisos del diseñador (Aviso) llevan un kind y parámetros (algunos, un DesignLang); la pantalla los redacta y nombra cada idioma por su nombre propio.

Selector de idioma y banderas

SelectorIdioma (src/components/selector-idioma.tsx) es un Select con los idiomas, su bandera y su nombre propio (cada opción lleva lang={locale}, para que su nombre salga con las letras adecuadas). Se usa para el idioma de la interfaz en SettingsDialog (src/features/settings/settings-dialog.tsx, llama a setLang) y para el idioma de diseño en el panel de exportación y en la vista previa de Diseñar (véase Interfaz del diseñador). El cambio llega a todos los componentes montados en cuanto carga el diccionario; no hay recarga.

Las banderas las pinta <Bandera pais> de src/components/emoji.tsx con imágenes de Noto Color Emoji incluidas en src/assets/emoji/, no la letra de emoji del sistema: Segoe UI Emoji de Windows no tiene banderas y WebKit en macOS no pinta letras COLRv1. Véase Arquitectura del frontend.

Tests y protecciones

  • pnpm idiomas / scripts/idiomas.mjs protege la completitud, los marcadores y las formas de plural. Su lógica se prueba en scripts/__tests__/idiomas.test.mjs: un diccionario completo pasa; una clave que falta, un marcador cambiado y una forma de plural que falta fallan; un plural sin {n} o con una categoría que el idioma no tiene falla; las claves sin uso y las idénticas al inglés solo avisan; un fichero de área que falta falla, y una clave repetida dentro de un mismo fichero también; en el español (comprobarFuente), fallan un valor distinto de su clave, una cadena que falta y un fichero de área que falta.
  • src/i18n/__tests__/traducir.test.ts: respaldo idioma → inglés → español, el plural ruso elegido por n, un n que no es número usa other, números con el formato de cada idioma y cargarDiccionario (el inglés ya está, el español no tiene y un idioma sin diccionario no rompe).
  • src/lib/__tests__/idiomas.test.ts: la tabla (15 códigos sin repetir en el orden de LANGS, locales BCP-47 canónicos, IDIOMAS_SCRYFALL son los 11 con cartas impresas, esLang y el respaldo inglés de idioma).
  • src/lib/__tests__/errores.test.ts cubre esAppError y mensajeDeError.
  • No cubierto: la calidad de las traducciones (se revisa contra GLOSARIO.md).

Por dónde empezar para ampliarlo

  • Añadir un idioma de interfaz: añádelo a LANGS y a IDIOMAS en src/lib/idiomas.ts; crea src/i18n/<código>/ con un JSON por área (los 15 del inglés, traducidos); añade sus términos y su tratamiento a src/i18n/GLOSARIO.md; ejecuta node scripts/emoji-noto.mjs para copiar su bandera; ejecuta pnpm idiomas. Si además tiene que ser idioma de diseño, DesignLang en Rust necesita el mismo código en la misma posición (véase Motor del diseñador). Deducido: en la pantalla no hace falta cambiar nada más, porque todo lo que usa idiomas recorre la tabla.
  • Traducir una feature nueva: véase «Añadir o cambiar una cadena» más arriba.

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally