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, not portal_welcome_title or portalWelcomeTitle).

  • 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.ftl before 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

  1. Add the English value to services/canopy-portal/locales/en/main.ftl.

  2. 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.

  3. If the key is referenced from Rust (e.g., an error message format), pass it through LocaleManager::format(locale, key, args).

  4. 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

  1. mkdir services/canopy-portal/locales/<lang> where <lang> is a valid BCP 47 subtag (e.g., vi, zh-CN).

  2. Mirror every key from en/main.ftl into <lang>/main.ftl.

  3. No code change required — LocaleManager::new discovers every subdirectory at startup.

  4. 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:

  1. PR opens with the English + non-English values together.

  2. Reviewer with native fluency reviews the .ftl diff.

  3. 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 one FluentBundle per locale. Fails closed on missing dir, unparseable language identifier, or malformed .ftl.

  • LocaleManager::format(locale, key, args) — the common-case shortcut. Returns Cow<'_, str>. Fallback chain: requested locale’s bundle → default locale’s bundle → key literal.

  • LocaleManager::negotiate(accept_language) — quality-weighted parse of an HTTP Accept-Language header. 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:

  1. en bundle resolves a known key.

  2. es bundle resolves the same key with the Spanish value.

  3. Missing key returns the literal key string.

  4. Missing locale falls back to en.

  5. Malformed .ftl fails LocaleManager construction loudly.

  6. Empty bundle directory fails loudly.

  7. Accept-Language negotiation picks the quality-weighted locale.

  8. 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

Edit this page · default