Plan: canopy-portal Fluent i18n (Issue #381)

On this page

Status

Step Description Status

1

LocaleManager rewrite. Replace the stub at services/canopy-portal/src/i18n.rs:16-46 with a real implementation: LocaleManager::new(bundle_dir: &Path) → Self walks services/canopy-portal/locales/{locale}/*.ftl, parses each into a FluentBundle, and stores them in a HashMap<String, Arc<FluentBundle>>. LocaleManager::format(&self, locale: &str, key: &str, args: Option<&FluentArgs>) → Cow<'_, str> does lookup with the fallback chain: requested locale → enkey literal.

Done (2026-05-11) — uses FluentBundle<FluentResource, IntlLangMemoizer> (the concurrent memoizer; the default isn’t Send + Sync); set_use_isolating(false) so bidi marks don’t mojibake when HTML-escaped downstream. intl-memoizer added as explicit dep so the concurrent variant compiles. Empty-bundle-dir and malformed-.ftl both fail closed.

2

.ftl bundles. New services/canopy-portal/locales/en/main.ftl and services/canopy-portal/locales/es/main.ftl containing 8 starter keys: portal-welcome-title, portal-welcome-subtitle, portal-signin-button, portal-signin-reference, portal-application-status, portal-language-switch, portal-error-not-found, portal-error-internal. English values are placeholder copy from existing canopy-portal templates; Spanish values are professional translations of the same.

Done (2026-05-11) — Spanish values are first-pass; review-by-native-speaker process documented in the contributor doc.

3

Axum extractor. New services/canopy-portal/src/extractors/locale.rs exposing LocaleExt(pub String) that reads (in priority order): (a) session-stored locale preference, (b) Accept-Language header (parsed via the accept-language crate or hand-rolled), (c) default en. Wired into the existing axum middleware stack.

Done (2026-05-11) — hand-rolled q-weighted parser inside LocaleManager::negotiate; extractor reads session → header → default. #[allow(dead_code)] on LocaleExt and the plumbing-only LocaleManager methods (with_default_locale, format, bundle, negotiate) — first consumer is the Dioxus rewrite post-UAT.

4

Tests. 5 unit tests in services/canopy-portal/src/i18n.rs: (a) en bundle resolves a known key, (b) es bundle resolves the same key with Spanish value, (c) missing key returns the literal key string, (d) missing locale falls back to en, (e) malformed .ftl file fails LocaleManager construction loudly. No e2e test in scope: canopy-portal currently has no domain routes / Askama templates (the service is "session wired" only per CLAUDE.md), so there is no rendered page to assert against. The rendered-page check lands when the Dioxus rewrite (post-UAT, ADR-008) introduces the first real applicant-facing view.

Done (2026-05-11) — 8 unit tests total (5 from plan + 3 extras: empty-bundle-dir fails loudly, negotiate picks quality-weighted locale, loaded_locales sorted). All 8 pass; tempfile-backed fixtures.

5

Docs. New docs/modules/ROOT/pages/services/canopy-portal-i18n.adoc covering the Fluent setup, contributor process for adding new keys, and the Spanish translation review path. CHANGELOG === Added. Plan archives.

Done (2026-05-11) — contributor doc covers bundle layout, key conventions, adding-keys process, adding-locales process, translation-review path, runtime behavior, bidi-isolation note. nav.adoc updated.

Issue: #381
Branch: feat/canopy-portal-fluent-i18n
Labels: type::feature, priority::low, service::portal, program::cross-program, compliance::wcag-21-aa, workflow::ready

Context

services/canopy-portal/src/i18n.rs:16-46 is a LocaleManager stub with a // TODO: Load .ftl files from locales/ directory, build per-locale bundles, negotiate language from Accept-Language header, provide Axum extractor. comment. This is the only in-code TODO left in the entire services/, crates/, and tools/ tree as of this plan’s writing — closing it removes the last open TODO marker in the repo and justifies the priority despite the surface being session-wired-only today. services/canopy-portal/Cargo.toml:30-31 already declares fluent = "0.16" and fluent-bundle = "0.16". ADR-008 specifies en/es support from day one for the constituent-facing portal. No .ftl files exist in the repo yet.

This plan ships the plumbing — LocaleManager, extractor, two starter bundles — and nothing else. canopy-portal has no templates/ directory and no Askama dependency today (CLAUDE.md flags the service as "session wired" only); there are no rendered pages to translate. The plan therefore establishes only the i18n contract that the Dioxus rewrite (post-UAT, ADR-008) consumes when it introduces the first real applicant-facing view.

Code references

  • services/canopy-portal/src/i18n.rs:16-46 — stub (the sole in-code TODO remaining in the repo).

  • services/canopy-portal/Cargo.toml:30-31 — Fluent deps already declared.

  • ADR-008 — Applicant portal architecture

Scope

In scope:

  • LocaleManager real implementation.

  • en/es .ftl bundles with 8 starter keys.

  • Axum extractor + Accept-Language parsing.

  • Unit tests on LocaleManager.

  • Contributor doc.

Out of scope:

  • Template integration. canopy-portal has no templates/ directory and no Askama dependency today; there are no rendered pages to wire up. The first translated page lands with the Dioxus rewrite (post-UAT, ADR-008).

  • e2e Playwright spec. Without a rendered page, there is nothing to flip Accept-Language against. Lands with the first Dioxus view.

  • Translating every constituent-facing string (Dioxus rewrite post-UAT covers it).

  • Locales beyond en + es. Adding a third locale follows the same pattern; deferred until a need surfaces.

  • Right-to-left language support — neither en nor es needs it; future plan if a RTL locale lands.

  • Locale negotiation via URL path / subdomain. Header- and session-based only.

  • Server-rendered date/time/currency formatting (separate concern; can use ICU through Fluent if/when needed).

Dependencies

  • ADR-008 — establishes the en/es target.

  • No prerequisite plans on disk.

Design

LocaleManager:

use fluent_bundle::FluentBundle;
use fluent_bundle::FluentResource;
use unic_langid::LanguageIdentifier;
use std::collections::HashMap;
use std::sync::Arc;

pub struct LocaleManager {
    bundles: HashMap<String, Arc<FluentBundle<FluentResource>>>,
    default_locale: String,
}

impl LocaleManager {
    pub fn new(bundle_dir: &Path) -> anyhow::Result<Self> {
        let mut bundles = HashMap::new();
        for entry in fs::read_dir(bundle_dir)? {
            let entry = entry?;
            let locale = entry.file_name().to_string_lossy().to_string();
            let bundle_files = fs::read_dir(entry.path())?;
            let lang_id: LanguageIdentifier = locale.parse()?;
            let mut bundle = FluentBundle::new(vec![lang_id]);
            for ftl in bundle_files {
                let ftl = ftl?;
                let source = fs::read_to_string(ftl.path())?;
                let resource = FluentResource::try_new(source).map_err(|e| anyhow!("{:?}", e))?;
                bundle.add_resource(resource).map_err(|e| anyhow!("{:?}", e))?;
            }
            bundles.insert(locale, Arc::new(bundle));
        }
        Ok(Self { bundles, default_locale: "en".to_string() })
    }

    pub fn format<'a>(
        &'a self,
        locale: &str,
        key: &str,
        args: Option<&FluentArgs>,
    ) -> Cow<'a, str> {
        let bundle = self.bundles.get(locale)
            .or_else(|| self.bundles.get(&self.default_locale));
        let Some(bundle) = bundle else { return Cow::Borrowed(key); };
        let Some(message) = bundle.get_message(key) else { return Cow::Borrowed(key); };
        let Some(pattern) = message.value() else { return Cow::Borrowed(key); };
        let mut errors = vec![];
        bundle.format_pattern(pattern, args, &mut errors).into_owned().into()
    }
}

main.ftl (en):

portal-welcome-title = Welcome to Georgia Benefits
portal-welcome-subtitle = Apply for SNAP, TANF, Medicaid, and more.
portal-signin-button = Sign in
portal-signin-reference = Sign in with reference number
portal-application-status = Application status
portal-language-switch = Español
portal-error-not-found = Page not found.
portal-error-internal = Something went wrong. Please try again.

main.ftl (es): mirrors with Spanish translations.

Files Touched

File Change

services/canopy-portal/src/i18n.rs

Real LocaleManager implementation

services/canopy-portal/locales/en/main.ftl

New en bundle

services/canopy-portal/locales/es/main.ftl

New es bundle

services/canopy-portal/src/extractors/locale.rs

New axum extractor

services/canopy-portal/src/main.rs

Wire LocaleManager into AppState; mount extractor

services/canopy-portal/Cargo.toml

Add unic-langid if not already present

services/canopy-portal/src/i18n.rs (test module)

5 unit tests

docs/modules/ROOT/pages/services/canopy-portal-i18n.adoc

New contributor doc

CHANGELOG.adoc

=== Added

Verification

  1. cargo nextest run -p canopy-portal — unit tests pass; en + es bundles resolve, fallback chain exercised, malformed .ftl loads fail loudly.

  2. cargo xtask validate — full battery green.

Documentation Updates

  • docs/modules/ROOT/pages/services/canopy-portal-i18n.adoc — new contributor doc

  • CHANGELOG.adoc — entry under == Unreleased / === Added

  • .claude/docs/services.md — note canopy-portal now has working i18n stub

  • Plan archive: move to plans/archive/ post-merge

Edit this page · default