Repository navigation
Internationalization
English · Español
The interface comes in 15 languages. The code is written in Spanish: every user-visible string is passed through t("<Spanish source>"), and each other language is a dictionary keyed by that same Spanish string. There is no runtime i18n library; the whole mechanism is the language table src/lib/idiomas.ts, src/components/language-provider.tsx, the pure functions in src/i18n/traducir.ts, the dictionaries in src/i18n/<code>/ (loaded by src/i18n/cargar.ts) and the CI-enforced check scripts/idiomas.mjs.
| Name | Where it lives | What it controls |
|---|---|---|
| Interface language |
LanguageProvider, localStorage key proxybuilder.lang (any code of LANGS) |
All UI text, number/date formatting 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. The options come from IDIOMAS_SCRYFALL (en es fr de it pt ja ko ru zhs zht, shown by their own names). 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 mostly about the first. All three share the same codes on purpose (next section).
One table serves the interface and the design languages:
| Export | Purpose |
|---|---|
LANGS |
The 15 codes, in table order: es (source), en, fr, de, it, pt (Brazil), ro, pl, nl, ru, uk, ja, ko, zhs, zht. type Lang is derived from it (and re-exported by language-provider.tsx). |
IDIOMAS |
One Idioma per code: codigo, nativo (the name in its own language, never translated, so it is recognisable in a picker), pais (ISO country of its flag), locale (BCP-47) and impresas (whether Scryfall has cards printed in it; false for ro, pl, nl, uk). |
esLang(x) |
Type guard: is x one of the codes. |
idioma(codigo) |
The Idioma of a code; an unknown code returns English. |
localeDe(lang) |
The BCP-47 locale (es-ES, en-GB, pt-BR, zh-Hans-CN, zh-Hant-TW…). |
IDIOMAS_SCRYFALL |
The 11 languages with Scryfall printings, in Scryfall's list order (English first), Portuguese named just "Português" as on the cards. Replaces three formerly hard-coded lists (decklist panel, variants filters, library). |
Why Scryfall codes (pt, zhs, zht) and not BCP-47: the UI language, the design language of a card (DesignLang in Rust uses the same codes in the same order) and the language of a deck are then the same key everywhere, with no mapping table. BCP-47 lives only in locale, for Intl and <html lang>.
Decision: the factory interface language is English; another one is chosen in Settings and remembered. Implementation: leerIdioma() (exported from language-provider.tsx) returns the stored code when esLang accepts it, and "en" otherwise (missing, unknown, storage blocked). index.html ships <html lang="en">. 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.
src/components/language-provider.tsx exports:
| Export | Purpose |
|---|---|
LanguageProvider |
Context provider; state lang. |
useLang() |
Returns { lang, setLang, t, mensaje }. Throws if used outside the provider. |
leerIdioma() |
The stored language, or "en". Also used by src/main.tsx. |
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, vars?) |
t() without the provider (reads proxybuilder.lang directly; uses that language's dictionary if it is already loaded, otherwise English). Used only by LimiteError, which must wrap the provider so a crash inside the provider still shows a translated recovery screen. |
type Lang |
Re-export of the type from src/lib/idiomas.ts. |
t(es, vars) is traducir(es, lang, vars, dic, EN) from src/i18n/traducir.ts, a pure module (no React) so it can be tested alone:
- Fallback chain: language → English → Spanish source. A key missing from, say, the Polish dictionary shows the English entry (someone using the app in Polish is more likely to understand English than Spanish); if English lacks it too, the Spanish source is shown. Never an empty string or a technical key. That safety net also hides missing translations while developing, which is exactly why the check script exists.
-
Plurals. A dictionary value is either a string or a
Pluralobject (src/i18n/tipos.ts:zero?,one?,two?,few?,many?,other;Traduccion = string | Plural,Diccionario = Record<string, Traduccion>). The form is chosen fromvars.nwithIntl.PluralRules(locale)(cached per locale); a missing form, or annthat is not a finite number, usesother. When the value came from the English fallback, English plural rules choose the form. -
Interpolation.
\{(\w+)\}placeholders are replaced fromvars; a placeholder with no value (ornull/undefined) is left as{name}rather than printed asundefined. Number values are formatted withIntl.NumberFormatof the interface language (always, even when the text fell back to English or Spanish). Hence the rule: counts passed tot()are numbers, never pre-formatted withtoLocaleString/toFixed. - Number, date, currency and relative-time formatting outside
t()useslocaleDe(lang):formatoMonedainsrc/features/stats/textos.ts, dates insrc/features/stats/tipos.tsx, dates/relative times/prices insrc/features/library/piezas.tsx, the symbols date insrc/features/disenar/recursos-dialog.tsx. - The
tfunction is recreated only whenlangchanges (useMemo([lang])), so it is safe in dependency lists; components re-render on language change because the context value changes.
setLang(l) stores the code in localStorage (ignoring storage errors: the language then lives only for this session), loads the dictionary first (cargarDiccionario) and only then switches the state. While it loads, the previous language stays on screen, so there is no flicker of half-translated text. A ref (pedido) remembers the last requested language: if two are requested in a row, the last one wins even if the earlier dictionary arrives later.
<html lang> is set to the full locale (localeDe(lang), e.g. ja-JP, zh-Hant-TW) and not just the code: WebKit uses it to pick the Japanese or Chinese glyph forms for the ideographs both scripts share.
Each language has src/i18n/<code>/ with one JSON file per area (the same 14 areas as English). Spanish is the source, so es/ is laid out the same way but maps every string to itself: pnpm idiomas checks that each requested string has an entry there too, and the app never loads it. Each file is a flat object { "Spanish source": "translation", ... }, formatted by Biome with tab indentation. The dictionaries are data, not code: no TypeScript to get wrong, and the check script reads them without transpiling. There is no index.ts any more: cargar.ts merges every area file of a language, so a new area only needs its own file. Keys are always quoted, as JSON requires. Two things the TypeScript build used to reject now fall to pnpm idiomas: a key repeated inside one file (JSON.parse would silently keep the last one) and a key present in two areas.
| File | Area |
|---|---|
comun.json |
Shared strings; starts with the ErrorCode messages from src/lib/errores.ts. |
ajustes.json |
Settings dialog (appearance, updates, tweakcn themes). |
shell.json |
Top bar, panel titles, save indicator, update button and toasts. |
lista.json |
Decklist panel, resolve phases, card marks. |
vista.json |
Preview pages, toolbar, card menu, "move to" dialog. |
exportar.json |
Export panel, presets, alignment. |
biblioteca.json |
Deck library. |
cartas.json |
Add card, variants, card editor dialogs. |
extras.json |
Statistics entry points and set browser. |
vincular.json |
Link custom cards to Scryfall; wheel zoom; UI scale. |
estadisticas.json |
Statistics dialog. |
disenar.json |
The designer view (largest file). |
tutorial.json |
The screen tutorials: help button, step counter and every step's title and text (Tutorials). |
mesa.json |
The game table: entrance, board, menus, dialogs, shortcuts, the sentences of the game log and Settings > «Mesa de juego» (Game Table). |
An optional src/i18n/<code>/identicas.json lists Spanish source keys whose translation is legitimately identical to English in that language (e.g. "Deck" in German); see the check script.
src/i18n/cargar.ts:
-
English is bundled in the main chunk (
EN): it is the fallback of every language, so it must always be there. - The others are lazy:
import.meta.glob(["./*/*.json", "!./en/*.json", "!./*/identicas.json"], { import: "default" })makes one lazy module per area file.cargarDiccionarioloads the files of the chosen language together and merges them, so startup does not carry 14 dictionaries. -
cargarDiccionario(lang)loads a language once and caches it. Spanish has no dictionary (it is the source); a language without a dictionary, or whose chunk fails to load, resolves toundefinedand its texts fall back to English. -
diccionarioCargado(lang)returns the cached dictionary, if any.
src/main.tsx awaits cargarDiccionario(leerIdioma()) before mounting (.finally(() => montar(raiz)), so a failed load still mounts), so the app never shows for an instant in another language; the window is shown in parallel (ensenarVentana()).
src/i18n/GLOSARIO.md (in Spanish) fixes the terminology per language so repeated terms read as on official cards and always the same:
- In languages with printed cards (
fr,de,it,pt,ru,ja,ko,zhs,zht) game terms are taken from real printed cards on Scryfall, with the reference card noted. -
ro,pl,nlandukhave no printed cards: terms follow community usage and are marked as having no official source. - It lists what is never translated (product names, file formats,
.pbdeck, keyboard shortcuts,{x}placeholders) and the form of address per language (e.g.vousin French,duin German, impersonal forms in Polish, です/ます in Japanese).
- In the component, write the visible text through the hook:
const { t } = useLang();thent("Texto en español"). For text outside React, in a module-level table, wrap it inparaTraducir("...")and callt(value)where it is rendered. - Add the pair to the dictionary of the matching area in every language (
src/i18n/<code>/<area>.json), followingsrc/i18n/GLOSARIO.md. Reuse an existing key when the Spanish text is identical and means the same thing. - For a count, use
{n}and pass a number; where the language has several plural categories, write aPluralobject with exactly that language's categories (Russianone/few/many/other, Japanese onlyother…). - Run
pnpm idiomas(also part ofpnpm check, the pre-commit hook for staged.ts/.tsx, and CI). - 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 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 key in every dictionary too; otherwise the old entries become unused (a warning) and the new string is reported as missing (an error).
What it does:
-
Requested strings (
leerPedidas). Walkssrc/(skippingnode_modules, dot-dirs,bindings,src/i18n/and*.test.ts(x)) and collects every double-quoted literal int("...")andparaTraducir("...")(first argument;\"and\nunescaped), with the file where each was first seen. -
Languages. Loads
src/lib/idiomas.ts(leerTabla), which is code, so it is still transpiled withtypescriptand imported. Then it reads each dictionary directory (leerDiccionarios; directories starting with_are skipped): every<area>.jsonis parsed withJSON.parse, and its raw text is scanned for keys repeated at the top level of the same file, whichJSON.parsewould otherwise drop without a trace. -
Errors (exit status 1):
- a language of the table with no directory
src/i18n/<code>/, or a directory that is no language of the table; - a requested string missing from the merged dictionary;
- a key present in two areas, or repeated inside one area file;
- an English area file missing in the language;
- placeholders
{x}different from those of the Spanish key (also checked in each plural form); - a plural object on a key without
{n}, a category the language does not have, or a missing category (the language's categories areIntl.PluralRules(locale).resolvedOptions().pluralCategories).
- a language of the table with no directory
-
Warnings (do not fail): keys no longer requested, and values identical to English (only when they contain letters and are not an allowed exception: the global list
IDENTICAS_PERMITIDASin the script, or the language'sidenticas.json). - Prints one line per language (
✓ <code>: N cadenasplus the warning count, or✖ <code>: …followed by each error).--detallealso lists each warning.
leerPedidas, leerTabla, leerDiccionarios and comprobar are exported for the tests in scripts/__tests__/idiomas.test.mjs.
Limits to keep in mind:
- 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 throughparaTraducir("...")at its definition. - Spanish text written in JSX without
t()is not detected at all. -
.test.ts(x)files are excluded. - A wrong but well-formed translation is not detectable; the identical-to-English warning only catches untranslated copies.
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)) {
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 has one Spanish sentence per ErrorCode (Network, RateLimited, NotFound, Io, Db, InvalidInput, Cancelled, Pdf, Image, Unsupported, Busy, Internal, Rejected, Server), each wrapped in paraTraducir. MOTIVOS adds sentences of their own for known reasons of a code: the game table's rejections (Rejected with params.motivo = NoExiste, SinPermiso, ZonaVacia, FueraDeTurno, Limite, NoSePuede) and the update that waits for a game (Busy with motivo: partida); an unknown reason uses the code's general sentence. 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.
The game table log follows the same rule: each engine event reaches the screen as data and src/features/mesa/textos-eventos.ts writes its sentence with t() (FRASES, one per Accion, plus the special changes), from the viewer's side so hidden cards are never named (Game Table).
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. Designer warnings (Aviso) carry a kind and parameters (some a DesignLang); the screen words them and names a language by its own name.
SelectorIdioma (src/components/selector-idioma.tsx) is a Select listing languages with their flag and native name (each item gets lang={locale}, so its name is drawn with the right glyphs). It is used for the interface language in SettingsDialog (src/features/settings/settings-dialog.tsx, calls setLang) and for the design language in the export panel and the Diseñar preview (see Designer UI). The change reaches all mounted components as soon as the dictionary has loaded; there is no reload.
Flags are drawn by <Bandera pais> from src/components/emoji.tsx with Noto Color Emoji images bundled in src/assets/emoji/, not with the system emoji font: Windows' Segoe UI Emoji has no flags and WebKit on macOS does not paint COLRv1 fonts. See Frontend Architecture.
-
pnpm idiomas/scripts/idiomas.mjsguards completeness, placeholders and plural forms. Its logic is tested inscripts/__tests__/idiomas.test.mjs: a complete dictionary passes; a missing key, a changed placeholder and a missing plural form fail; a plural without{n}or with a category the language lacks fails; unused and identical-to-English keys only warn; a missing area file fails, and so does a key repeated inside one file. -
src/i18n/__tests__/traducir.test.ts: fallback language → English → Spanish, the Russian plural chosen byn, a non-numbernusesother, numbers formatted per language, andcargarDiccionario(English present, Spanish has none, a language without dictionary does not throw). -
src/lib/__tests__/idiomas.test.ts: the table (15 codes without repeats inLANGSorder, canonical BCP-47 locales,IDIOMAS_SCRYFALLis the 11 printed ones,esLangand the English fallback ofidioma). -
src/lib/__tests__/errores.test.tscoversesAppErrorandmensajeDeError. - Not covered: the quality of translations (reviewed against
GLOSARIO.md).
-
Add a UI language: add it to
LANGSandIDIOMASinsrc/lib/idiomas.ts; createsrc/i18n/<code>/with one JSON file per area (the 14 English files, translated); add its terms and form of address tosrc/i18n/GLOSARIO.md; runnode scripts/emoji-noto.mjsto copy its flag; runpnpm idiomas. If it should also be a design language,DesignLangin Rust must gain the same code at the same position (see Designer Engine). Inferred: nothing else needs to change on the screen, because every consumer iterates the table. - Translating a new feature: see "Adding or changing a string" above.
- Frontend Architecture, Settings and Shell
- IPC and Error Handling, Conventions
- State Stores (card language preference)
- Designer Overview (design languages), Testing and CI, Tutorials, Game Table
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia