I18N
_Status: being written in the current phase. See REWORK-STATE.md §3 for the authoritative progress list; this file is filled in as the corresponding stream lands._
R3 REOPENED — owner directive 2026-11-02 (ADR-073). The 2026-10-25
freeze is lifted: waves 8+ land the remaining locales, one per round, until
full coverage. The live set is
web/i18n/_active.json(ADR-071) and grows
with every landing; the i18n gate target remains **full coverage = the
active set**. The landing recipe below is operative again. (History: the
set stood frozen at 49/66 from 2026-10-25 to 2026-11-02 — see
docs/P22-PLAN.md §R3 execution records.)
Coverage vs activation (ADR-071) — read this before landing a locale
Two different things have been conflated once already, and it cost a user-visible defect (s48-01). They are now separate, separately gated concepts.
Coverage is a build-time property of the catalogs: _platform.<code>.json covers every key of _base.en.json. php scripts/build_i18n.php --check reports it as full coverage: N/66 locales, and docs/I18N-STATUS.md is generated from the same computation. Coverage alone makes a locale translatable, not reachable.
Activation is a runtime property: can a visitor actually switch to it? The live set is
(SUPPORTED_LOCALES ∪ web/i18n/_active.json) − DISABLED_LOCALES
computed in exactly one place, Iya\Core\ActiveLocales, and read by every consumer that used to read the .env list on its own:
| Consumer | What it gates |
|---|---|
Http\Middleware\LocaleResolver::supports() | ?lang=, the lang cookie, the session value, POST /lang/<code>, /xx/ path prefixes, Accept-Language negotiation |
Http\HostResolver | language subdomains (uz.abuseipdb.ru) |
Web\View::languages() | the language switcher AND the hreflang alternates |
Web\Controllers\AccountController::updatePreferences() | which locale a user may SAVE as a preference |
web/i18n/_active.json is generated by scripts/build_i18n.php (never by hand) and lists, sorted, every locale at 100% platform coverage. It is deterministic — no timestamp — so --check byte-compares it and a stale committed copy reddens the i18n gate. A partial --locale= build neither rewrites nor is judged against it.
SUPPORTED_LOCALES is therefore an override, not the list:
- add a code there to force-enable a locale before its catalog is complete
(early access, staged rollout);
- add a code to
DISABLED_LOCALESto force-disable a complete one without a
redeploy (translation quality incident, legal hold);
enis always live — it is the fallback catalog, and it survives even an
explicit disable;
.env.examplemust list every active locale (LocaleActivationTestasserts
parity in both directions), so a rebuilt environment activates the landed waves by construction.
ActiveLocales is fail-closed: a missing or corrupt _active.json yields an empty complete-set (i.e. the env list alone), never "every catalog on disk", and only bare lowercase [a-z]{2,3} codes are accepted from the artifact, so a hand-edited file cannot inject a route segment.
The switcher
partials/lang-switcher.php offers the live set as switchable POST /lang/<code> forms and renders every other catalog as a disabled entry (aria-disabled="true", a <span>, no form) under the existing alerts.kind_soon label. Offering a locale the resolver rejects is precisely what made s48-01 silent: the click looked like it worked, answered 303, and changed nothing. Note that a new key must not be added to _base.en.json for this or any other UI tweak — doing so resets full coverage for all 48 landed locales (RESILIENCE-PLAYBOOK §6.5 p.23); compose from existing keys.
Definition of delivered
A locale is delivered when **POST /lang/<code> sets the lang cookie AND the following GET / renders <html lang="<code>"> with its own catalog copy.** Nothing shorter counts. That is asserted three ways, because one of them once wasn't:
tests/unit/Core/LocaleActivationTest.php— the artifact equals an independent
recomputation from disk; every complete catalog is live even though the suite boots a 9-code SUPPORTED_LOCALES (the defect scenario, under test); the resolver agrees with ActiveLocales; .env.example parity both ways; the override works in both directions; corrupt/hostile artifacts fail closed.
tests/integration/HttpSmokeTest.php— the switcher offers exactly the
live set (no dead clicks, no dark locales), not-live catalogs are never form targets, every hreflang alternate is live, and each landed wave locale renders its own <html lang> and home.hero_title through ?lang=<code>.
var-deploy/s49_live_prod_check.py(agent tooling, staged) — the same contract
against the live prod over HTTPS, 34 checks, with a fresh cookie jar per locale, controls (de/en) and fail-closed probes (ca/hy/ta).
Landing a new locale — the activation part is now free
Follow ADR-070 (all-or-nothing per locale, one landing commit at 1784/1784). After php scripts/build_i18n.php the new code appears in _active.json, the gate line moves 48/66 → 49/66, the switcher starts offering it, uz-style subdomains resolve, and users can save it as a preference — with **no .env edit**. Still required: bump the single locale pin (tests/integration/WaitlistTest.php), run the targeted i18n tests, the control suite, and the live prod check after the deploy.
Версия 2.2.0 · AbuseIPDB.RU · webmaster@abuseipdb.ru · source: docs/I18N.md