Plan: Multi-Language Support (i18n)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Architecture decision (select approach) |
Not started |
2 |
Plan file and nav entry |
Not started |
3 |
Translation framework integration |
Not started |
4 |
String extraction from templates |
Not started |
5 |
First translation (Spanish) |
Not started |
6 |
Locale detection + language switcher |
Not started |
7 |
Public intake form translation |
Not started |
8 |
Testing |
Not started |
9 |
Documentation, commit, push, MR |
Not started |
Issues: TBD
Branch: feature/multi-language
Context
CRAIG serves non-English-speaking families in child welfare contexts. Multi-language support is listed in the roadmap for both Phase 10 (Web UI) and Phase 11 (Portals). The public intake form (report suspected child abuse) is the highest-priority page for translation — a reporter who cannot read English may be unable to submit a report.
The web UI is built with Askama templates (server-rendered HTML), htmx (partial page updates), and Alpine.js (client-side interactivity). All display strings are currently hardcoded in English in .html template files.
Architecture Options
Three approaches were evaluated. Option A is recommended.
Option A: Mozilla Fluent + Askama Custom Filter (Recommended)
Use Mozilla’s Project Fluent (.ftl files) for translations, with a custom Askama filter for template integration.
How it works:
-
Translation files in
locales/{lang}/*.ftl(e.g.,locales/en/web.ftl,locales/es/web.ftl) -
Fluent bundles loaded at startup per locale
-
Locale selected per-request from
Accept-Languageheader, session preference, or URL prefix -
Custom Askama filter:
{{ "dashboard-title"|t }}resolves the key against the active locale’s bundle -
Fluent supports ICU-like formatting: plurals, gender, numbers, dates
Fluent file example:
# locales/en/web.ftl
dashboard-title = Dashboard
case-list-title = Cases
report-form-title = Report Suspected Child Abuse or Neglect
report-911-warning = If a child is in immediate danger, call 911 first.
submit-button = Submit Report
# locales/es/web.ftl
dashboard-title = Panel de Control
case-list-title = Casos
report-form-title = Reportar Sospecha de Abuso o Negligencia Infantil
report-911-warning = Si un niño está en peligro inmediato, llame al 911 primero.
submit-button = Enviar Reporte
Template usage:
<!-- Before -->
<h1>Dashboard</h1>
<!-- After -->
<h1>{{ "dashboard-title"|t }}</h1>
Pros:
-
Industry standard (used by Firefox, MDN, Pontoon)
-
Rich formatting (plurals, dates, gender-aware)
-
Rust-native implementation (
fluent-rscrate) -
Translation files are plain text — easy for non-developers to edit
-
Server-rendered — works with htmx, no FOUC
Cons:
-
Every display string needs a key — significant template rework (~25 pages)
-
Fluent bundle must be accessible in all templates (via PageContext or global state)
-
fluent-rscrate adds a dependency
Estimated effort: Large (2-3 days for framework, 1-2 days per language)
Option B: Gettext (.po files)
Traditional gettext approach with .po files and gettext-rs crate.
Template usage:
<h1>{{ gettext("Dashboard") }}</h1>
Pros:
-
Mature tooling (Poedit, Weblate, Transifex)
-
xgettext-like extraction tools available -
Familiar to most translators
Cons:
-
Less expressive than Fluent for complex formatting
-
Requires compile-time
.mofile generation -
Rust gettext ecosystem is less mature than Fluent
Estimated effort: Large (similar to Option A)
Option C: JSON Translation Files + Alpine.js Client-Side
Client-side translation via JSON files and Alpine.js.
Template usage:
<h1 x-text="$t('dashboard.title')">Dashboard</h1>
Pros:
-
No server changes needed for adding languages
-
Runtime language switching without page reload
Cons:
-
Flash of untranslated content (FOUC)
-
Doesn’t work without JavaScript
-
Duplicates server/client rendering logic
-
SEO/accessibility concerns (content in JS, not HTML)
-
htmx partial updates would need re-translation
Estimated effort: Medium (but poor UX)
Design (Option A)
Dependencies
Add to services/craig-web/Cargo.toml:
fluent = "0.16"
fluent-syntax = "0.11"
fluent-bundle = "0.15"
intl-memoizer = "0.5"
unic-langid = "0.9"
Locale Loading
File: services/craig-web/src/i18n.rs — new module
pub struct I18n {
bundles: HashMap<String, FluentBundle<FluentResource>>,
default_locale: String,
}
impl I18n {
pub fn load(locales_dir: &Path, default: &str) -> Self { ... }
pub fn translate(&self, locale: &str, key: &str) -> String { ... }
}
Askama Filter
File: services/craig-web/src/filters.rs — add:
/// Translate a message key using the request's active locale.
#[filter_fn]
pub fn t(key: &dyn fmt::Display, _args: &dyn Values) -> Result<String> {
// Access locale from thread-local or template context
let locale = CURRENT_LOCALE.with(|l| l.borrow().clone());
let key_str = key.to_string();
I18N.translate(&locale, &key_str)
}
Locale Detection (per-request)
Priority order:
1. Session preference (user explicitly chose a language)
2. Accept-Language header
3. Default locale from config (CRAIG_WEB__DEFAULT_LOCALE, default: en)
Language Switcher
Add a dropdown or link in the header (next to username/logout) that sets a session cookie with the preferred locale.
String Extraction Scope
~25 template files with an estimated ~500 translatable strings: * Page titles and headings * Button labels * Form labels and placeholders * Table column headers * Badge text * Flash messages * Empty-state messages * Footer text
The public intake form (/report) has ~100 strings and is the highest priority for translation.
Verification
-
All pages render correctly in English (no regression)
-
Public intake form renders in Spanish when locale=es
-
Language switcher persists preference across pages
-
Missing translation keys fall back to English
-
All E2E tests pass (they run in English)
Documentation Updates
-
CHANGELOG.adoc -
docs/modules/ROOT/pages/roadmap.adoc— tick multi-language item -
docs/modules/ROOT/pages/configuration-reference.adoc— documentDEFAULT_LOCALEsetting