Achievement Watcher Next

🌍 Localization

How AW Next is translated, what is deliberately left in English, and what a contributor has to run.

← Documentation · Contributing · Project home

The short version

   
Application interface 28 bundled languages, full key parity, no English fallback at runtime
Website: the guides English only today, with the language declared so browser translation works
Website: home page and the two galleries 9 languages, as an overlay over the English markup
Dates, times, durations, numbers Not translated: Intl produces them from the selected language
Logs, file names, protocol values, source ids Not translated, on purpose
Check everything node tools/locale-lint.js, or just cd app && npm test

What gets translated

Anything a user reads on screen. There are three routes into the locale files and no fourth:

  1. Markup with a stable id or list position. app/view/app.html carries English text as the reference copy; app/locale/loader.js overwrites it for the selected language. Adding a row here means adding its binding in the loader in the same change.
  2. t('slug', 'English', 'Français') for strings built in JavaScript - dialogs, menus, busy labels. The slug resolves to dialogs.<slug> in the locale files, which are the source of truth; the two literals are a safety net for a catastrophic locale failure, not a translation.
  3. localeText('dotted.path') for a label that already exists elsewhere in the locale tree. It returns an empty string when the locale has not loaded, because a blank label is a visible bug and a silently English one is not.

The standalone Watchdog process cannot read the renderer locale files, so its handful of strings are mirrored in watchdog/locale.json.

What does not get translated

Translating these would make the product worse, not better:

Dates, numbers and durations come from Intl

app/util/intlFormat.js is the only place that formats a value rather than a sentence. It maps the app’s language id to a BCP-47 tag and hands the work to the platform:

Value API
A date or a timestamp Intl.DateTimeFormat
“3 days ago”, “yesterday” Intl.RelativeTimeFormat
Played time Intl.DurationFormat
Counts and percentages Intl.NumberFormat
“Steam, GOG and Epic” Intl.ListFormat

The rule this follows: the locale files carry the sentence, Intl carries the value. The Game Health footer is the shape to copy - dialogs.gh-verified-when is "Achievements checked {when}" in every language, and {when} is whatever Intl.RelativeTimeFormat produces. Three keys per language for today/yesterday/N-days-ago were three chances to get a plural rule wrong, in twenty-eight languages, for information ICU already has.

This is also a correctness matter, not only tidiness: playtime used to be built with a language tag that the duration library did not recognise for Simplified Chinese or Brazilian Portuguese, and its fallback option turned that into silent English for those users.

The BCP-47 table is spelled out in intlFormat.js because the in-game overlay window is sandboxed and cannot read app/locale/steam.json. test/core/intlFormat.test.js reconciles the two, so adding a language is still one edit in practice.

Every Achievement Watcher address the app can open lives in app/util/links.js - home, download, documentation, the individual documentation pages, presets, issues, security, the upstream credits. Nothing else may spell one out.

Markup names the destination rather than the address:

<a data-aw-link="troubleshooting" target="_blank">
</a>

applyExternalLinks() in app/app.js fills the href in at startup. test/core/links.test.js checks that every documentation slug resolves to a page this site actually publishes, and tools/locale-lint.js fails the build if an address is written by hand anywhere else. The repository has been renamed once already; the point is that the next rename is one edit.

The website

The site is two things, and they are localized differently.

The guides

Published in English only, and that is a decision rather than a gap.

The pages are the same Markdown files that GitHub renders when browsing the repository. There is no layout, no include and no Liquid template beyond a favicon, a theme toggle and the site bar, which is what keeps the two renderings identical. Forking that into twenty-eight directories would mean twenty-eight copies of every guide drifting apart at different speeds, and a reader landing on a translation that is two releases behind is worse served than one reading current English.

What is done instead:

If a language ever gets a maintained translation of these guides, the shape to add is a sibling directory with its own hreflang beside the one already declared, not a rewrite.

The home page and the two galleries

These three are hand written pages rather than Markdown, and they are translated, one language at a time, without forking anything. Today, and all of them complete: French, German, Spanish, Italian, Portuguese (Brazil), Polish, Russian, Japanese and Simplified Chinese - every one of them a language the application itself is already translated into, which is what keeps a site translation reviewable by somebody who also reads the app.

English lives in the markup: every visible string is written in docs/index.html, docs/gallery/index.html and docs/gallery/themes/index.html and carries data-i18n, so the pages are complete and indexable before a script runs. That covers the navigation, both galleries, the submission forms and everything their scripts say back to a sender, including validation refusals. A translation is an overlay - a flat JSON file of key to string in docs/assets/i18n/ - applied over that markup after load. A missing key falls back to the English already on the page, so a partial translation degrades one string at a time.

   
Start a language node tools/site/extract-strings.js > docs/assets/i18n/fr.json
Make it appear add { "code": "fr", "name": "Francais" } to docs/assets/i18n/languages.json
Check it node tools/site/extract-strings.js --check

extract-strings.js reads the pages themselves plus the fallbacks the page scripts pass to awI18n.t(), so the key list is generated rather than maintained. --check fails on a key that no longer exists in the markup and reports the ones not translated yet. test/site/pages.test.js enforces the rule the overlay depends on: one key means one English string, wherever it appears.

Adding or changing a string

  1. Add the key to app/locale/lang/english.json - it is the structural reference.
  2. Add a real translation to the other 27 files. A missing key ships as blank UI; there is no English fallback at runtime.
  3. If it is a t() slug, it belongs under dialogs. Keep the placeholder set identical to English.
  4. If it is markup with a position in a list, update the nth-child selector in app/locale/loader.js in the same change. Obsolete rows are hidden, never deleted or reordered.
  5. If it is a watchdog.* key, mirror it into watchdog/locale.json.
  6. Run the checks.
node tools/locale-lint.js
Push-Location app
npm test
Pop-Location

What the linter checks

tools/locale-lint.js is plain Node with no dependency, and every rule also runs inside the test suite (test/core/localeLint.test.js), so a regression fails npm test rather than waiting for someone to run a tool.

Rule What it catches
missing-key / extra-key A locale that has drifted from the English key set
empty-value A key present but never translated
placeholder-mismatch {count} dropped, renamed or invented in a translation
markup-mismatch <b> lost or unbalanced in a translation
copied-from-english An English sentence pasted into another language unchanged
missing-dialog-slug A t('slug') the locale files do not define
hardcoded-ui-string Interface prose in JavaScript that no translation helper wraps
uncentralized-link An Achievement Watcher address written outside app/util/links.js
dead-docs-link An in-app link to a documentation page that does not exist
unknown-link-key A data-aw-link naming something the registry does not hold

Two of these are judgement calls rather than facts, and are tuned to stay quiet on real content:

The pseudo-locale

node tools/locale-lint.js --pseudo

writes scratch/pseudo.json: every English value with its vowels accented and padded by 30%, placeholders and markup untouched.

"Achievements checked {when}"  ->  "⟩ÄçhĂŻĂ«vĂ«mëñtĆĄ çhëçkĂ«d {when}·······⟧"

Copy it over app/locale/lang/english.json in a scratch checkout and run the app. Two things become visible that no automated rule can see: anything still in plain English is a string the locale layer never reached, and anything clipped or wrapped badly is a label that will break in German or Russian. Restore the file afterwards - it is written outside app/locale so it can never be picked up as a twenty-ninth bundled language by accident.

Translation credits

The per-language credits and the update procedure for an existing translation are in app/locale/README.md. Corrections from fluent speakers are welcome.

← Documentation · Contributing · Project home