Skip to content

Localization

Hails edited this page Jul 1, 2026 · 5 revisions

Localization

hailsDotGO is fully translatable. UI strings live in flat JSON locale files compiled into the binary, templates look strings up with a T function, and a community translation workflow (Translator-Workspace) layers approved edits on top at runtime. This page is the contributor reference for how the i18n system in internal/i18n/i18n.go works and how to add keys and languages.

Locale files

The embedded locales live in internal/i18n/locales/ and are compiled in via go:embed:

  • en.json, English, the source of truth
  • de.json, German
  • es.json, Spanish
  • fr.json, French
  • ja.json, Japanese

Keys are flat dot notation strings, namespaced by feature: nav.raids, raids.title, events.starts_in, admin.tab.settings, translate.filter.pending. There is no nesting; every file is a single JSON object of string to string.

en.json defines the complete key set. Other locales may be partial: any key a locale does not define falls back transparently.

Lookup order

TFunc(lang) returns a per request translation function. For each key it checks, in order:

  1. Pending edit overrides: only in translator preview mode, overlaying the translator's own unreviewed edits
  2. Disk overlay: approved community edits for the locale (see below)
  3. Embedded locale: the compiled in xx.json
  4. Embedded English: the compiled in en.json
  5. The key itself: a last resort that makes a missing key visible as some.key in the UI

So a missing translation shows English, and a key missing even from en.json shows the raw key name.

Templates and page scripts

Server rendered templates call the injected function directly:

<h1>{{T "raids.title"}}</h1>

The bundled TypeScript pages cannot call T, so each template's scripts block defines an inline object of pre-translated strings that the bundle reads as a global. base.html defines JSC, the shared js.common.* strings used by shared modules like the Pokemon picker and the counters renderer, and injects SITE_LANG, which the shared game data module uses to pick localized Pokemon names. Page-specific objects follow the same pattern: RD in raids.html, DP in dps.html, PV in pvp.html, SH in shinies.html, EV in events.html, RF and RF2 (plus the RAID_CTX context object) in trainers.html, and TL_STRINGS in translate.html.

The active language comes from the lang cookie (set via the /lang switcher form and persisted to the user row when logged in). Translators previewing a locale use the tl_preview query parameter and cookie instead; the cookie is only honored for iframe requests, so the preview never leaks into normal browsing.

Runtime locales and overlays

Two database backed mechanisms extend the embedded files without a rebuild:

  • The locales table controls which languages appear in the public switcher (enabled flag) and records community locales created from the Translator-Workspace. A runtime locale starts from English and is translated entirely through overlays.
  • Overlay files live in the server's working locales/ directory (not the embedded one). They are sparse: only keys explicitly approved through the translator workflow. On approval the overlay is backed up and rewritten atomically, then hot applied in memory. At startup, overlay keys that no longer exist in the embedded en.json are dropped as stale, so a new build's strings are never shadowed accidentally.

Approved overlays can be exported per locale or synced back to the repository as a pull request; once merged, the next build embeds them and the overlay becomes redundant. See Translator-Workspace for that flow.

Persistence across updates

Translation work survives an ordinary deploy without any extra step:

  • Pending edits are rows in the translation_edits table (status='pending'). The deploy script never touches the database, so unreviewed submissions are always still there after an update.
  • Approved overlays are sparse files in the server's working locales/ directory. The deploy script only replaces a fixed set of tracked paths (binary, templates, static assets) and never deletes anything in the install directory, so overlay files are left untouched and Init reloads them when the service restarts.

Two edge cases used to risk losing translated strings; both are now hardened:

  1. Renaming or removing an English key. Overlays and submissions are validated against the embedded en.json key set, so a removed or renamed key drops out of the live overlay on the next start. Rather than discard the translated text, Init now archives it to locales/backup/<lang>_stale_<timestamp>.json and rewrites the overlay so the same keys are not archived again on later restarts. The text is therefore recoverable. One caveat remains: a still-pending edit for a removed key can no longer be approved, so treat an English key rename as a breaking change and keep the old key or migrate alongside it.
  2. Overlays exist only on the server. The working locales/ directory is not in version control. The server now auto-mirrors approved overlays to the GitHub sync branch: shortly after startup, on a periodic backstop, and (debounced) whenever an edit is approved. This runs only when GITHUB_TOKEN and GITHUB_REPO are configured; the manual sync button remains available either way. Approved translations therefore reach the repository without anyone remembering to press a button, and survive a server rebuild.

Adding a new key

  1. Add the key and its English text to internal/i18n/locales/en.json. This must come first: submissions and overlays are validated against the embedded English key set.
  2. Reference it from a template with {{T "my.new.key"}}, or add it to the page's inline strings object if a script needs it.
  3. Optionally add translations to the other locale files in the same commit; otherwise the key simply shows English until translators catch up.

Adding a new language

There are two routes:

  • File route: create internal/i18n/locales/xx.json (a two letter code) with whatever keys are translated and rebuild. The init() loader picks up every embedded JSON file automatically, with no code change. The language stays out of the public switcher until an admin enables it in the locales panel.
  • Workspace route: a translator creates the locale from the Translator-Workspace with no rebuild at all. It registers as a runtime locale, starts hidden, gets translated through reviewed edits, and can later be promoted to an embedded file via the GitHub sync.

Encoding warning: no BOM

The locale JSON files are embedded with go:embed and parsed by Go's encoding/json, which rejects a leading byte order mark. The files must be UTF-8 without a BOM. In particular, PowerShell's Set-Content -Encoding utf8 (Windows PowerShell 5.1) writes a BOM and will break the build or crash the service at startup. Use an editor or tool that writes plain UTF-8, and when in doubt validate by building and running the Go parser rather than trusting ConvertFrom-Json, which tolerates the BOM that Go rejects.

Clone this wiki locally