Skip to content

Internationalization

Pablo Martínez López edited this page Oct 20, 2026 · 10 revisions

Internationalization

The interface is bilingual (English and Spanish). The code is written in Spanish: every user-visible string is passed through t("<Spanish source>"), and the English translation lives in a dictionary keyed by that same Spanish string. There is no runtime library; the whole mechanism is src/components/language-provider.tsx, the dictionaries in src/i18n/en/, and the CI-enforced check scripts/idiomas.mjs.

Three different "languages" (do not confuse them)

Name Where it lives What it controls
Interface language LanguageProvider, localStorage key proxybuilder.lang ("es" or "en") All UI text and Rust error messages as shown to the user.
Card language useUi().lang (src/stores/ui-store.ts), key inside proxybuilder.preferencias Which language of a card's printing to fetch from Scryfall (en es fr de it pt ja ko ru zhs zht). Chosen in the decklist panel "Options" ("Card language").
Design language PrintSettings.designLang, DesignLang (Rust) Which language the card designer renders text in; see Designer Overview.

This page is only about the first.

Default language policy

Decision: the factory interface language is English; Spanish is chosen in Settings and remembered. Implementation: leerIdioma() in language-provider.tsx returns "es" only when localStorage["proxybuilder.lang"] === "es"; anything else (missing, corrupted, storage blocked) returns "en". index.html also ships <html lang="en">, and the provider keeps document.documentElement.lang in sync. The main README and the GitHub release notes are in English as well (README.es.md is the Spanish copy); Inferred: English is the default because the project targets a wider audience than its Spanish-speaking source code, while Spanish remains first-class because it is the language strings are authored in.

How t() works

src/components/language-provider.tsx exports:

Export Purpose
LanguageProvider Context provider; state lang, persisted on setLang.
useLang() Returns { lang, setLang, t, mensaje }. Throws if used outside the provider.
t(es, vars?) lang === "en" ? (EN[es] ?? es) : es, then {name} interpolation.
mensaje(e) Any error (usually an AppError) as a sentence in the current language.
paraTraducir(es) Identity function that marks a string "translate later, elsewhere". For text stored in data tables (e.g. MARCAS in src/lib/cartas.ts, FASES in src/features/decklist/acciones.ts, TEXTOS in src/lib/errores.ts) that is rendered later through t(variable). The check script sees paraTraducir("...") literals.
traduccionSuelta(es) t() without the provider (reads proxybuilder.lang directly). Used only by LimiteError, which must wrap the provider so a crash inside the provider still shows a translated recovery screen.
type Lang = "es" | "en"

Key behaviours:

  • Missing entry falls back to the Spanish source, never to an empty string or a technical key. That safety net also hides missing translations while developing in Spanish, which is exactly why the check script exists.
  • Interpolation regex is \{(\w+)\}; a placeholder with no matching value (or a null/undefined value) is left as {name} rather than printed as undefined. Values are string | number.
  • There is no plural mechanism. Strings like "{n} cartas" map to "{n} cards" irrespective of n; if a count of one must read correctly, write separate source strings. (Inferred: no pluralisation code exists; I found no Intl.PluralRules use.)
  • Number/date formatting is separate from t(): call sites choose a locale from lang (lang === "en" ? "en-GB" : "es-ES", e.g. src/features/stats/textos.ts, src/features/library/piezas.tsx).
  • The t function is recreated only when lang changes (useMemo([lang])), so it is safe in dependency lists; components re-render on language change because the context value changes.

Dictionaries

src/i18n/en/index.ts merges all areas into EN: Record<string, string> with object spread (later areas win silently on duplicate keys, hence the collision check below).

File Area (per its header)
comun.ts Shared strings; starts with the ErrorCode messages from src/lib/errores.ts.
ajustes.ts Settings dialog (appearance, updates, tweakcn themes).
shell.ts Top bar, panel titles, save indicator, update button and toasts.
lista.ts Decklist panel, resolve phases, card marks.
vista.ts Preview pages, toolbar, card menu, "move to" dialog.
exportar.ts Export panel, presets, alignment.
biblioteca.ts Deck library.
cartas.ts Add card, variants, card editor dialogs.
extras.ts Statistics entry points and set browser.
vincular.ts Link custom cards to Scryfall; wheel zoom; UI scale.
estadisticas.ts Statistics dialog.
disenar.ts The designer view (largest file).

Each file is export const NAME: Record<string, string> = { "Spanish source": "English", ... } with tab indentation (Biome). A key can be a bare identifier if it is a single word (Ajustes: "Settings"), otherwise a quoted string.

Adding or changing a string

  1. In the component, write the visible text through the hook: const { t } = useLang(); then t("Texto en español"). For text outside React, in a module-level table, wrap it in paraTraducir("...") and call t(value) where it is rendered.
  2. Add the pair to the dictionary of the matching area in src/i18n/en/<area>.ts. Reuse an existing key when the Spanish text is identical and means the same thing.
  3. Run pnpm idiomas (also part of pnpm check, the pre-commit hook for staged .ts/.tsx, and CI).
  4. Use {name} placeholders for dynamic parts (t("Descargando la {version}…", { version })); never concatenate translated fragments.

Disambiguation rule: if the same Spanish word needs two different English translations in different places, make the Spanish source of the less generic case longer (e.g. add context words). Never introduce a second dictionary or a context argument.

If you change an existing Spanish source string, change its dictionary key too; otherwise the English entry becomes orphaned and the new string is reported as untranslated. The script does not report orphaned dictionary entries (unused keys), only missing ones.

scripts/idiomas.mjs (pnpm idiomas)

What it does:

  1. Reads every src/i18n/en/*.ts except index.ts and extracts keys with a regex (lines starting with a quoted string or a bare identifier followed by :). A key found in two files is a collision (error): index.ts would otherwise let the last one win silently.
  2. Walks src/ (skipping node_modules, dot-dirs, bindings, the dictionary dir, and *.test.ts(x)) and collects every double-quoted literal in t("...") and paraTraducir("...") (first argument, escapes for \" and \n handled).
  3. Exits with status 1, listing "N string(s) not translated to English" with the file where each was first seen; prints idiomas: N cadenas, todas traducidas. on success.

Limits to keep in mind (all follow from the regexes):

  • Only double-quoted literals directly inside t( / paraTraducir( are seen. t(variable), single-quoted strings, template literals, or concatenations are invisible to the check, so route such text through paraTraducir("...") at its definition.
  • Spanish text written in JSX without t() is not detected at all.
  • .test.ts(x) files are excluded.

Errors: useLang().mensaje(e)

Rust never writes user-facing prose. Commands return Result<T, AppError> where AppError = { code: ErrorCode, params: { [key]: string } } (src/bindings/AppError.ts, ErrorCode.ts; see IPC and Error Handling). On the screen:

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

TEXTOS has one Spanish sentence per ErrorCode (Network, RateLimited, NotFound, Io, Db, InvalidInput, Cancelled, Pdf, Image, Unsupported, Busy, Internal), each wrapped in paraTraducir. esAppError accepts any object whose code is a key of TEXTOS, so adding an ErrorCode in Rust forces a matching entry (the Record<ErrorCode, string> type fails tsc otherwise). Components call toast.error(mensaje(e)).

Special cases are handled by the component that knows the context, using params: for example TemasTweakcn (src/features/settings/temas-tweakcn.tsx) checks e.params.motivo === "tema_enlace" to show "That is not a tweakcn theme link or id." instead of the generic invalid-input message. SaveIndicator shows mensaje(error) in a tooltip when autosave fails.

Rust-provided data that is displayed (Scryfall names, oracle text, release notes) is shown as-is: release notes in the update panel are English Markdown from the GitHub release.

Language switch UI

SettingsDialog (src/features/settings/settings-dialog.tsx) renders a ToggleGroup with "Español" and "English" (their own names, not translated) and calls setLang. The change is immediate for all mounted components; there is no reload.

Tests and guards

  • pnpm idiomas / scripts/idiomas.mjs is the only guard for completeness (no unit tests of the script).
  • src/lib/__tests__/errores.test.ts covers esAppError and mensajeDeError.
  • Not covered: pluralisation, placeholders mismatched between Spanish and English entries (the script does not compare {placeholders} across languages; check by eye).

Where to start when extending

  • Add a third UI language: widen Lang, add src/i18n/<code>/ dictionaries with the same keys, make traducir() pick the dictionary, extend leerIdioma() and the toggle in SettingsDialog, and generalise scripts/idiomas.mjs (it currently hard-codes src/i18n/en). Inferred: that is the full list; nothing else reads lang, except the locale pickers noted above.
  • Translating a new feature: see "Adding or changing a string" above.

See also

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