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.

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-Language header, 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-rs crate)

  • 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-rs crate 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 .mo file 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.

File Structure

services/craig-web/
  locales/
    en/
      web.ftl        — authenticated UI strings
      public.ftl     — public intake form strings
      common.ftl     — shared strings (buttons, pagination, badges)
    es/
      web.ftl
      public.ftl
      common.ftl
  src/
    i18n.rs          — I18n loader + translator
    filters.rs       — add t() filter

Verification

  1. All pages render correctly in English (no regression)

  2. Public intake form renders in Spanish when locale=es

  3. Language switcher persists preference across pages

  4. Missing translation keys fall back to English

  5. 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 — document DEFAULT_LOCALE setting

Edit this page · latest