- Add comprehensive i18n design document with domain-based organization
(default, errors, auth, equipment, admin, emails domains)
- Document helper functions, implementation patterns, and migration workflow
- Fix multiple test suite issues:
* user_settings_live_test.exs: Update all tests to match current UI structure
- Fix user.name references (use first_name/last_name)
- Fix TOTP device creation (use create_totp_device/2)
- Update tab URLs and form IDs
- Update tab names (Account, API, Notifications)
- Remove obsolete tabs (Organizations, Activity)
- Fix password tests (pattern match redirect without flash token)
- Update sudo mode behavior
* account_data_controller_test.exs: Fix user profile assertions
* rate_limit_test.exs: Fix unused variable warning
* settings_live_test.exs: Fix organization form validation test
10 KiB
Gettext Internationalization Design
Date: 2026-02-02 Status: Approved Scope: Comprehensive i18n for entire Towerops web application Initial Language: English only (prepared for future multilingual support)
Overview
Move all user-facing text in Towerops to Gettext for internationalization. This enables future language support while maintaining English as the primary language. The implementation uses domain-based organization for better maintainability as the application grows.
Goals
- Extract all user-facing text to Gettext translation files
- Organize translations by feature domain for maintainability
- Maintain English-only translations initially
- Prepare infrastructure for future language additions
- Ensure no functional regressions during migration
Architecture
Gettext Domain Structure
Organize translations into 6 domains:
| Domain | Purpose | Examples |
|---|---|---|
default |
Common UI elements, navigation, generic actions | "Dashboard", "Save", "Cancel", "Loading..." |
errors |
Validation errors, system errors, API errors | "can't be blank", "Not found", "Unauthorized" |
auth |
Authentication flows, user management | "Log in", "Sign up", "Reset password", "TOTP" |
equipment |
Equipment/device management, SNMP, monitoring | "Add device", "SNMP settings", "Last polled", "Alerts" |
admin |
Admin-specific features | "Impersonate user", "GeoIP import", "Audit logs" |
emails |
Email templates and subjects | "Reset password instructions", email bodies |
Rationale:
- Smaller .po files are easier to review and maintain
- Clear ownership - developers know which domain to use
- Enables assigning different translators to different areas
- Standard pattern in mature i18n applications
Helper Module Pattern
Create domain-specific helper functions to reduce verbosity:
# lib/towerops_web/gettext_helpers.ex
defmodule ToweropsWeb.GettextHelpers do
import ToweropsWeb.Gettext
def t(msgid), do: dgettext("default", msgid)
def t_auth(msgid), do: dgettext("auth", msgid)
def t_equipment(msgid), do: dgettext("equipment", msgid)
def t_admin(msgid), do: dgettext("admin", msgid)
def t_email(msgid), do: dgettext("emails", msgid)
# Errors use existing gettext/1
end
Benefits:
- Shorter syntax:
t("Save")vsdgettext("default", "Save") - Clear domain intent:
t_auth("Log in") - Easy refactoring if domain structure changes
Import Strategy
Web Layer (LiveViews, Components, Controllers):
- Add
GettextHelperstohtml_helpers/0inToweropsWeb - All LiveViews and components get helpers automatically via
use ToweropsWeb, :live_view
Non-Web Layer (Emails, Background Jobs):
- Explicit import:
import ToweropsWeb.GettextHelpers - Use in notifier modules, workers, contexts
Implementation Patterns
Template Patterns
Basic string replacement:
<!-- Before -->
<h1>Equipment List</h1>
<.button>Add Device</.button>
<!-- After -->
<h1>{t_equipment("Equipment List")}</h1>
<.button>{t_equipment("Add Device")}</.button>
String interpolation:
<p>{t_equipment("Last polled: %{time}", time: format_timestamp(@last_polled))}</p>
<p>{t_equipment("Found %{count} devices", count: @device_count)}</p>
Pluralization:
ngettext("1 device", "%{count} devices", device_count)
dngettext("equipment", "1 alert", "%{count} alerts", alert_count)
Flash Messages
# Before
put_flash(socket, :info, "Device created successfully")
# After
put_flash(socket, :info, t_equipment("Device created successfully"))
Email Templates
# Before
subject = "Reset password instructions"
body = """
Hi #{user.email},
You can reset your password by visiting the URL below:
#{url}
"""
# After
subject = t_email("Reset password instructions")
body = t_email("""
Hi %{email},
You can reset your password by visiting the URL below:
%{url}
If you didn't request this change, please ignore this.
""", email: user.email, url: url)
Validation Messages
Ecto changeset validations already use Gettext via the errors domain automatically. Ensure custom error messages use dgettext("errors", ...).
What NOT to Translate
- Log messages (internal, for developers)
- API response keys (machine-readable)
- Database column names
- Configuration keys
- Code comments
- Developer-facing error details (stack traces, debug info)
Migration Workflow
Phase 1: Setup (Infrastructure)
-
Create domain-specific .pot template files:
priv/gettext/auth.potpriv/gettext/equipment.potpriv/gettext/admin.potpriv/gettext/emails.pot- Keep existing
default.potanderrors.pot
-
Create corresponding English .po files:
priv/gettext/en/LC_MESSAGES/auth.popriv/gettext/en/LC_MESSAGES/equipment.popriv/gettext/en/LC_MESSAGES/admin.popriv/gettext/en/LC_MESSAGES/emails.po
-
Create
lib/towerops_web/gettext_helpers.ex -
Update
lib/towerops_web.exto import helpers inhtml_helpers/0 -
Create Mix task to auto-populate English translations (msgstr = msgid)
Phase 2: Module-by-Module Migration
Order of migration (smallest to largest):
-
Emails (
lib/towerops/accounts/user_notifier.ex)- Self-contained, minimal dependencies
- Domain:
emails
-
Auth Flows
lib/towerops_web/controllers/user_*_html.exlib/towerops_web/live/user_settings_live/- Domain:
auth
-
Core Components (
lib/towerops_web/components/)core_components.ex- Generic labelslayouts.ex- Navigation, user menu- Domain:
default
-
Equipment Features
lib/towerops_web/live/device_live/lib/towerops_web/live/equipment_live/lib/towerops_web/live/alert_live/- Domain:
equipment
-
Admin Features
lib/towerops_web/live/admin/- Domain:
admin
-
Error Messages
- Review all
put_flash(:error, ...)calls - Ecto validation messages
- Domain:
errors
- Review all
Per-module process:
- Update module to use gettext calls
- Run
mix gettext.extract --merge - Run
mix populate_englishto fill English translations - Compile and verify no errors
- Manual QA - check pages render correctly
- Run
mix testto verify no regressions - Commit changes for that module
Phase 3: Verification
- Run
mix gettext.extract --check-up-to-date(fails if untranslated strings) - Add to CI pipeline to catch missing translations
- Visual QA pass on all major pages
- Test all flash messages, error states, email previews
Gettext Commands Reference
# Extract new strings from code
mix gettext.extract
# Merge extracted strings into existing .po files (preserves translations)
mix gettext.merge priv/gettext
# Extract and merge in one step
mix gettext.extract --merge
# Check for unused translations (fail if out of date)
mix gettext.extract --check-up-to-date
Testing Strategy
- Unit Tests: Continue to work unchanged (test behavior, not strings)
- Integration Tests: May need updates if asserting on specific text
- CI Check: Add
mix gettext.extract --check-up-to-dateto CI pipeline - Manual QA: Visual inspection of key pages after migration
- Email Preview: Use Swoosh mailbox (
/dev/mailbox) to verify email translations
Future Enhancements
Adding New Languages
When ready to add a new language (e.g., Spanish):
- Create
priv/gettext/es/LC_MESSAGES/*.pofiles - Copy structure from English .po files
- Replace msgstr values with Spanish translations
- Recompile application
- Set user locale via session/cookie
Locale Selection
Add user preference for language:
- Add
localefield to users table (default: "en") - Set
Gettext.put_locale/1in authentication pipeline - Add language selector in user settings
- Persist choice in session/database
Dynamic Content
For database-stored content that needs translation:
- Consider using a translation table pattern
- Or use JSONB columns with locale keys
- Libraries like
transcan help with database translations
File Structure
priv/gettext/
├── default.pot # Common UI template
├── errors.pot # Error messages template
├── auth.pot # Auth flows template
├── equipment.pot # Equipment features template
├── admin.pot # Admin features template
├── emails.pot # Email templates template
└── en/
└── LC_MESSAGES/
├── default.po # English common UI
├── errors.po # English errors
├── auth.po # English auth
├── equipment.po # English equipment
├── admin.po # English admin
└── emails.po # English emails
lib/
├── towerops_web/
│ ├── gettext.ex # Existing Gettext backend
│ ├── gettext_helpers.ex # NEW: Domain helper functions
│ └── ...
└── mix/
└── tasks/
└── populate_english.exs # NEW: Auto-fill English translations
Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Forgetting to translate new strings | Add mix gettext.extract --check-up-to-date to CI |
| Breaking existing tests | Run test suite after each module migration |
| Inconsistent domain choices | Document examples in CLAUDE.md after implementation |
| Performance impact | Gettext compiles translations at build time (no runtime cost) |
| Large PR review burden | Migrate module-by-module with separate commits |
Success Criteria
- ✅ All user-facing text extracted to Gettext
- ✅ All English .po files populated (msgstr = msgid)
- ✅ All tests passing
- ✅ CI check for untranslated strings
- ✅ No functional regressions
- ✅ Documentation updated in CLAUDE.md
- ✅ Infrastructure ready for adding new languages
Estimated Scope
- 34 LiveView modules + 22 HEEx templates
- ~8 controller HTML modules
- 1 email notifier module (5 email templates)
- Dozens of flash messages across modules
- Core components with embedded strings
Implementation Time:
- Setup phase: ~2 hours
- Migration work: ~1-2 days (incremental, module-by-module)
- Testing & QA: ~4 hours