canopy-portal Fluent i18n
On this page
Overview
canopy-portal uses Project Fluent for localization of every applicant-facing string. Per ADR-008, English and Spanish are the two day-one locales; adding a third is a drop-in.
The runtime sits behind a single LocaleManager type at services/canopy-portal/src/i18n.rs. Every locale bundle is loaded once at startup (main.rs ~79-85: LocaleManager::new walks the locales dir and fails closed on a malformed/missing bundle), and the resulting Arc<LocaleManager> is mounted as an Axum Extension over the whole router (main.rs ~201). Locale negotiation lives on the manager itself — i18n.rs’s `negotiate(accept_language) does a quality-weighted parse of the HTTP Accept-Language header and returns the best matching loaded locale (falling back to the default).
The i18n layer is wired and loaded at startup but not yet consumed at the page level — pages render hard-coded English today. The portal already serves its applicant domain routes (routes.rs: Welcome / Apply / Lookup / Recover / Home / Letters / Documents / Verifications) and the per-page Dioxus components in src/pages/ emit English strings inline; none of them call LocaleManager::format or negotiate yet. The en/ and es/ main.ftl files are ~12-line stubs (a handful of portal-* keys each) seeded for the day the page components start pulling strings through the manager. Localizing the rendered pages — threading a negotiated locale into each component and replacing inline English with format lookups — is the remaining i18n work.
Bundle layout
services/canopy-portal/locales/
en/
main.ftl
es/
main.ftl
Each subdirectory’s name is the locale identifier (parsed as a LanguageIdentifier). Every *.ftl file in a locale subdirectory is merged into one bundle per locale — splitting into multiple files (main.ftl, errors.ftl, forms.ftl, …) is purely organizational.
Key conventions
-
Kebab-case (
portal-welcome-title, notportal_welcome_titleorportalWelcomeTitle). -
Namespaced under
portal-*so per-component prefixes (portal-application-status-pending,portal-application-status-approved) stay scannable. -
English values stay first — every new key lands in
en/main.ftlbefore the translation bundles get an entry. The fallback chain returns the literal key string if a translation is missing, so an untranslated key surfaces visibly (not silently blank). -
Variables use
{$name}placement (Fluent’s standard) — no manual escaping needed in the .ftl source.
Adding a new key
-
Add the English value to
services/canopy-portal/locales/en/main.ftl. -
Add the Spanish value to
services/canopy-portal/locales/es/main.ftl. If you don’t speak Spanish, copy the English value and mark the key for translation review in the PR description; the translation lands in a follow-up MR. -
If the key is referenced from Rust (e.g., an error message format), pass it through
LocaleManager::format(locale, key, args). -
Run
cargo nextest run -p canopy-portal— the parser fails closed on malformed.ftl, so a typo’d entry fails startup loudly.
Adding a new locale
-
mkdir services/canopy-portal/locales/<lang>where<lang>is a valid BCP 47 subtag (e.g.,vi,zh-CN). -
Mirror every key from
en/main.ftlinto<lang>/main.ftl. -
No code change required —
LocaleManager::newdiscovers every subdirectory at startup. -
Update this page’s "two day-one locales" wording if the addition is permanent.
Translation review
Per the project’s process, every Spanish (or other-language) translation is reviewed by a native speaker before each release. The review path:
-
PR opens with the English + non-English values together.
-
Reviewer with native fluency reviews the
.ftldiff. -
Approved translations merge; unapproved translations stay in PR.
The fallback chain (format(locale, key, args) returns the requested locale → en → key literal) ensures an unreviewed translation never silently ships — either it’s approved and lands, or it falls back to English and the gap is visible.
Runtime behavior
-
LocaleManager::new(bundle_dir)walks the directory, parses every.ftl, builds oneFluentBundleper locale. Fails closed on missing dir, unparseable language identifier, or malformed.ftl. -
LocaleManager::format(locale, key, args)— the common-case shortcut. ReturnsCow<'_, str>. Fallback chain: requested locale’s bundle → default locale’s bundle → key literal. -
LocaleManager::negotiate(accept_language)— quality-weighted parse of an HTTPAccept-Languageheader. Returns the highest-q locale we have a bundle for, falling back to the default. Region-tagged inputs (en-US) fall back to primary subtag (en) when no exact match. This is the locale-resolution entry point pages will call once they consume the manager; there is no per-request extractor type today.
Bidi-isolation
FluentBundle::set_use_isolating(false) is called on every bundle. Fluent’s default wraps every interpolated variable in U+2068 / U+2069 bidi-isolation marks; that becomes mojibake when the rendered string is HTML-escaped downstream. If a RTL locale is added later, this default flips back at that time (separate plan).
Tests
services/canopy-portal/src/i18n.rs has 8 unit tests using a tempfile-backed fixture bundle directory. They cover:
-
en bundle resolves a known key.
-
es bundle resolves the same key with the Spanish value.
-
Missing key returns the literal key string.
-
Missing locale falls back to en.
-
Malformed
.ftlfails LocaleManager construction loudly. -
Empty bundle directory fails loudly.
-
Accept-Language negotiation picks the quality-weighted locale.
-
loaded_locales()returns alphabetically sorted.
These are unit tests of LocaleManager in isolation. The portal’s applicant-facing pages are exercised by the applicant-portal.spec.ts Playwright suite (tests/e2e/specs/), but those assertions are against the English strings the pages render today — there is no page-level locale-switch e2e coverage yet, because no page consumes the manager.
References
-
services/canopy-portal/src/i18n.rs—LocaleManagersource (new/format/negotiate) -
services/canopy-portal/src/main.rs— startup load (~79-85) + AxumExtensionmount (~201) -
services/canopy-portal/locales/— bundle files (en/main.ftl,es/main.ftl— ~12-line stubs today)