Repository navigation
feat(i18n): allow partial locale catalogues, with a coverage floor - #225
Conversation
A locale file had to be complete before it could merge: TestAllLocalesHaveTheSameKeys
asserted exact set equality in both directions. The runtime never needed that - Locale.T
already falls back to English per key (i18n.go:161) - so the only thing stopping an
in-progress language from landing was the test. That mattered: nobody translates 169
strings in one sitting, and a volunteer who did 60 of them had nothing mergeable to show,
which is the practical reason a community language never gets off the ground.
The gate is now: no unknown keys, but missing keys are fine.
- TestLocalesHaveNoUnknownKeys (was TestAllLocalesHaveTheSameKeys)
An unknown key is still an error because it is always a typo or a rename and would render
for nobody. A missing key is the fallback working as designed.
The floor is the other half of the bargain. A mostly-English "German" booking page is worse
for a booker than English, so a locale is offered to visitors - language switcher,
Accept-Language matching, public API payload - only at or above MinCoverage (80% of the
English keys). Below it the locale still loads and ?lang=<code> still resolves, so whoever
is translating it can check their work in place, and startup logs which locales are
withheld and why. The floor carries headroom on purpose: the English catalogue grows, and
growth can push a complete locale under the line, which should read as "chase the
translators", not "lower the floor".
TestSupportedLocales had to change too. It asserted len(SupportedLocales()) == len(locales),
which only holds while every shipped locale is complete and would have failed the first
time a translation landed in stages.
No shipped locale changes: all 11 are complete, so offered == loaded == 11, and a test
asserts nothing below the floor is ever in `supported`.
There was a problem hiding this comment.
Important
The floor admits partial locales, but the client-side string tables those locales feed are not English-merged: a locale at ≥80% will still render raw keys (e.g. confirm_booking) on the JS-driven booking/embed surfaces. One gap in the floor's operator-fallback coverage and two narrowed guards are noted below.
Reviewed changes
- Coverage floor —
MinCoverage = 0.8,Coverage(*Locale)andofferedToVisitorsadded, andinit()now filterssupported/supportedCodesso only at-or-above-floor locales reach the switcher, matcher and public API payload; withheld locales are logged. - Partial catalogues allowed — the old bidirectional key-set gate is replaced by
TestLocalesHaveNoUnknownKeys; a missing key is now expected to fall back to English at lookup time. - Tests —
TestSupportedLocalescompares against the offered count, and two new tests pin the floor (TestOfferedLocalesMeetCoverageFloor,TestCoverageFloorHidesPartialLocale). - Docs —
CHANGELOG.mdanddocs/ARCHITECTURE.mddescribe the partial-catalogue contract and the floor.
⚠️ Partial locales render raw keys on the client-side surfaces
Locale.T falls back to English per key, which is the premise of this change, but the JS-driven tables embedded in book.html, manage.html and embed.js are built from Locale.JSON() and contain only the locale's own keys. Once a locale is admitted at 80%, its missing strings render as literal keys like confirm_booking on the booking/embed surfaces, where the server-side T would have shown English.
Technical details
# Client-side i18n tables need an English fallback
## Affected sites
- `internal/i18n/i18n.go:233` — `Locale.JSON()` marshals `l.strings` only; no English merge.
- `internal/handler/templates/book.html:281` and `book.html:856` — `t(key) { return I18N[key] || key; }` (the comment says it matches `Locale.T`, but `Locale.T` falls back to English first).
- `internal/handler/templates/manage.html:271` — same lookup.
- `internal/handler/embed.js:42` — `t(i18n, key)` returns the key; the widget receives this table from the public payload.
- `internal/handler/book.go:404`, `book.go:536`; `internal/handler/manage_handler.go:193` — all embed/return `loc.JSON()`.
## Required outcome
For any locale that can be served — including a below-floor one reached via `?lang=` — the client-side table must never leave a key unmatched when English has it. The single-point fix is to make `Locale.JSON()` emit English merged with the locale's overrides (matching `Locale.T`); alternatively, teach all three `t()` implementations to consult an English table.ℹ️ Operator fallback can still serve a below-floor locale
ResolveWithFallback looks up fallbackCode with Get(), and PatchBranding validates it with Get() too, so the floor does not apply to server_settings.fallback_locale. A locale that is complete when the operator selects it can later slip under the floor (the headroom scenario this PR anticipates) and still be served to every visitor with no Accept-Language match — the same mostly-English page the floor aims to prevent.
Technical details
# Floor does not cover the operator fallback
## Affected sites
- `internal/i18n/i18n.go:180` — `ResolveWithFallback` uses `Get(fallbackCode)` directly, bypassing `offeredToVisitors`.
- `internal/branding/branding.go:56` — loaded branding accepts any `Get`-able fallback.
- `internal/handler/branding_settings.go:141` — write validation also uses `Get`, so a hidden code can be set through the API.
## Open question for the human
Should the fallback be exempt because the operator chose it explicitly, or should a below-floor fallback degrade to English (or the last offered language) like the other three surfaces? If the latter, `ResolveWithFallback` and `PatchBranding` need to consult `offeredToVisitors`, not `Get`.ℹ️ Two i18n guards narrowed their scope without notice
TestDateTablesMatchCLDR iterates supportedCodes, so below-floor locales' date tables are no longer cross-checked against CLDR (previously every loaded locale). Separately, the printf guard's skip comment still points at the removed TestAllLocalesHaveTheSameKeys.
Technical details
# Guard scope and stale test reference
## Affected sites
- `internal/i18n/cldr_test.go:10` — comment still says "checks every locale's" while the loop now covers only `supportedCodes`.
- `internal/i18n/i18n_test.go:412` — comment names `TestAllLocalesHaveTheSameKeys`, which this PR deleted.
## Suggested approach (optional)
Keep the CLDR check over all of `locales` but compare `loc.T(key)` instead of `loc.strings[key]`, so a partial locale's provided date keys are still validated and its missing ones resolve to English (which the same table supplies). That preserves the drift guard's reach without forcing partial locales to carry date keys before promotion. Update the `i18n_test.go:412` comment to name the fallback behaviour instead.deepseek-v4.1-flash | 𝕏
The gate change removes the barrier; this is what makes it usable without a platform. - `cmd/i18n-coverage` (also `make i18n-coverage`) prints, per catalogue and per language, how many keys are present, the coverage percentage and whether the language is offered or withheld. It reads both catalogues and skips the admin one until it exists, so it works today and keeps working after #217. Informational only: the tests in internal/i18n remain the gate. CI runs it so a translation PR shows its progress in the log. - CONTRIBUTING now says a language does not have to be complete, what the 80% floor means, and what passing tests does not prove (wording, agreement, spliced sentences). It also points at the glossary. - docs/i18n-glossary.md fixes one word per concept per language, filled from the shipped catalogues rather than invented, and marks the places where the booker and admin French currently disagree so they are decisions rather than drift.
There was a problem hiding this comment.
ℹ️ No critical issues — one documentation correction inline.
Reviewed changes
- Coverage report — added
cmd/i18n-coverage, amake i18n-coveragetarget, and an informational CI step that prints per-language coverage for the booker and admin catalogues. - Contributor guide — rewrote the CONTRIBUTING translations section around partial catalogues, the 80% floor, and how to preview a partial locale.
- Glossary — added
docs/i18n-glossary.mdand expanded ARCHITECTURE §23 with the partial-catalogue contract. - Changelog — documented the new behaviour under
[Unreleased].
deepseek-v4.1-flash | 𝕏
|
|
||
| 1. Copy `internal/i18n/locales/en.json` to `internal/i18n/locales/<code>.json` and translate | ||
| the values. Delete the keys you have not got to yet if that helps; a missing key falls | ||
| back to English at lookup time, so a partial file is mergeable and useful. |
There was a problem hiding this comment.
This tells translators a missing key falls back to English and that ?lang= is a safe way to preview a partial file, but that only holds for server-rendered copy. The JS-driven tables on the booking page, manage page and embed widget are built from Locale.JSON() and return the raw key on a miss, so a translator previewing a partial locale will see confirm_booking rather than English. Either qualify this section (fallback applies to server-rendered strings only) or land the Locale.JSON() English merge before advertising the workflow.
Technical details
# Partial-locale preview is misleading on JS surfaces
## Affected sites
- `CONTRIBUTING.md:91` — promises English fallback for a missing key.
- `CONTRIBUTING.md:107` — says `?lang=` is how to check work in place.
- `internal/i18n/i18n.go:233` — `Locale.JSON()` marshals `l.strings` only, no English merge.
- `internal/handler/templates/book.html:281`, `book.html:856`, `manage.html:271` — `t(key)` returns `key` on a miss.
- `internal/handler/embed.js:42` — same lookup.
## Required outcome
The contributor guide must not promise behaviour the client surfaces do not implement. Either qualify the fallback claim to server-rendered copy, or merge English into `Locale.JSON()` (tracked from the prior review) so the claim becomes true everywhere.gosec flags os.ReadFile with a variable path as G304 (file inclusion). Here the path is the repository root the tool was invoked from plus a fixed catalogue name: a developer report, never served, with no untrusted input. Annotated with the reason, matching the repo's convention that every finding carries a #nosec justification. Verified locally: gosec reports 0 issues for the package.

Split out of the #217 follow-up plan. This is the booker catalogue's half; the admin catalogue's equivalent lands after #217.
Why
A locale file had to be complete before it could merge.
TestAllLocalesHaveTheSameKeysasserted exact set equality in both directions, so a half-finished locale was unmergeable.The runtime never needed that.
Locale.Talready falls back to English per key (i18n.go:161), so the only thing standing between a volunteer and a useful contribution was the test. Nobody translates 169 strings in one sitting, and someone who does 60 of them had nothing to show for it. That is the practical reason a community language never gets off the ground, more than any architecture choice.What changes
TestLocalesHaveNoUnknownKeysreplacesTestAllLocalesHaveTheSameKeys. An unknown key stays an error, because it is always a typo or a rename and renders for nobody. A missing key is the fallback working as designed.MinCoverage(80%): a locale is offered to visitors, in the switcher,Accept-Languagematching and the public API payload, only at or above the floor. A mostly-English "German" booking page is worse for a booker than English.?lang=<code>resolves, so whoever is translating it can check their work in place, and startup logs which locales are withheld and why.TestSupportedLocaleshad to change too. It assertedlen(SupportedLocales()) == len(locales), which only holds while every shipped locale is complete, and would have failed the first time a translation landed in stages.Why 80%
The floor carries headroom on purpose. The English catalogue grows, and growth can push a complete locale under the line. At 169 keys, roughly 42 new English strings would be needed to hide a full locale, which should read as "chase the translators", not "lower the floor".
Verification
offered == loaded == 11: no behaviour change today.TestLocalesHaveNoUnknownKeys,TestOfferedLocalesMeetCoverageFloor, andTestCoverageFloorHidesPartialLocale, which proves the rule with synthetic locales rather than needing a half-translated file in the repo.go test ./internal/i18n/,go vet,gofmtclean.Conflict note
This touches
CHANGELOG.mdanddocs/ARCHITECTURE.md, which #217 also edits. #217 already needs a rebase, so I am holding this one until #217 lands and rebasing after it, rather than making the contributor resolve against a second moving target.