Plan: canopy-portal Fluent i18n (Issue #381)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
LocaleManager rewrite. Replace the stub at |
Done (2026-05-11) — uses |
2 |
.ftl bundles. New |
Done (2026-05-11) — Spanish values are first-pass; review-by-native-speaker process documented in the contributor doc. |
3 |
Axum extractor. New |
Done (2026-05-11) — hand-rolled q-weighted parser inside |
4 |
Tests. 5 unit tests in |
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 |
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.
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 |
|---|---|
|
Real LocaleManager implementation |
|
New en bundle |
|
New es bundle |
|
New axum extractor |
|
Wire LocaleManager into AppState; mount extractor |
|
Add |
|
5 unit tests |
|
New contributor doc |
|
|
Verification
-
cargo nextest run -p canopy-portal— unit tests pass; en + es bundles resolve, fallback chain exercised, malformed .ftl loads fail loudly. -
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