diff --git a/.planning/phases/09-i18n/09-RESEARCH.md b/.planning/phases/09-i18n/09-RESEARCH.md new file mode 100644 index 0000000..b285328 --- /dev/null +++ b/.planning/phases/09-i18n/09-RESEARCH.md @@ -0,0 +1,636 @@ +# Phase 9: Internationalization - Research + +**Researched:** 2026-02-06 +**Domain:** WordPress i18n (gettext-based localization) +**Confidence:** HIGH + +## Summary + +WordPress internationalization uses the gettext system with well-established patterns. Plugins wrap user-facing strings in translation functions (`__()`, `_e()`, `esc_html__()`, etc.), load a text domain in the `init` hook, and ship POT/PO/MO files in a `languages/` directory. + +**Critical findings for this phase:** +1. **Text domain mismatch detected:** Plugin header declares `umzugsliste` but folder is `Siegel-Umzugsliste`. WordPress best practice requires text domain to match the plugin slug (folder name). Need to change text domain to `siegel-umzugsliste` (lowercase with dashes). +2. **Email locale forcing:** WordPress provides `switch_to_locale()` and `restore_previous_locale()` for temporarily changing locale, but there's a known issue where plugin translations don't reload automatically. The workaround is using the `change_locale` hook to manually reload the text domain. +3. **Translating array data:** Furniture/room names in data arrays should use inline `__()` calls within the array definitions. Email generation needs to access German originals regardless of current locale. +4. **Loco Translate compatibility:** Ship an up-to-date POT file named exactly `siegel-umzugsliste.pot` in the `languages/` directory for Loco Translate auto-discovery. + +**Primary recommendation:** Use WP-CLI `wp i18n make-pot` to generate POT file, wrap all user-facing strings in appropriate gettext functions, implement locale switching for email generation with the `change_locale` hook workaround, and ship both POT template and German PO/MO files for out-of-box German support. + +## Standard Stack + +The established libraries/tools for WordPress i18n: + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| WordPress gettext | Core API | String translation via `__()`, `_e()`, etc. | Built into WordPress core since 2.1 | +| WP-CLI i18n | WP-CLI package | POT file generation via `wp i18n make-pot` | Official WordPress tool, replaces manual xgettext | +| GNU gettext tools | System package | PO/MO compilation via `msgfmt`, `msginit` | Industry standard for gettext workflows | +| Loco Translate | WordPress plugin | Translation management UI | De facto standard for in-dashboard translation editing | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `load_plugin_textdomain()` | Core function | Loads translation files | Called on `init` hook to register plugin text domain | +| `switch_to_locale()` | Core function (4.7+) | Temporarily changes locale | For sending emails in specific language regardless of site locale | +| `restore_previous_locale()` | Core function (4.7+) | Restores previous locale | After `switch_to_locale()` to return to original locale | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| WP-CLI | Manual xgettext | WP-CLI is WordPress-aware, handles PHP and JS, better string extraction | +| Loco Translate | WPML / Polylang | Loco is free and focused on translation editing, not multilingual content management | +| `load_plugin_textdomain()` | `load_textdomain()` | `load_plugin_textdomain()` is plugin-specific convenience wrapper with standard paths | + +**Installation:** +```bash +# WP-CLI (for POT generation during development) +# Usually already installed in WordPress dev environments +wp cli version + +# GNU gettext tools (for PO/MO compilation) +# macOS: +brew install gettext + +# Debian/Ubuntu: +apt-get install gettext + +# Loco Translate (for end-user translation management) +# Install via WordPress admin: Plugins > Add New > Search "Loco Translate" +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +siegel-umzugsliste/ +├── languages/ # Translation files directory +│ ├── siegel-umzugsliste.pot # Template (required for Loco Translate) +│ ├── siegel-umzugsliste-de_DE.po # German translations (source) +│ └── siegel-umzugsliste-de_DE.mo # German translations (compiled) +├── includes/ +│ ├── class-*.php # All strings wrapped in gettext +│ └── ... +└── umzugsliste.php # Plugin header with Text Domain +``` + +### Pattern 1: Text Domain Loading +**What:** Register the plugin's text domain so WordPress knows where to find translation files +**When to use:** Always, in main plugin file on `init` hook +**Example:** +```php +// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/ + +/** + * Load plugin text domain + */ +function siegel_umzugsliste_load_textdomain() { + load_plugin_textdomain( + 'siegel-umzugsliste', + false, + dirname( plugin_basename( __FILE__ ) ) . '/languages' + ); +} +add_action( 'init', 'siegel_umzugsliste_load_textdomain' ); +``` + +**Note:** As of WordPress 4.6+, if plugin requires 4.6+, `load_plugin_textdomain()` is optional for wordpress.org-hosted plugins (translate.wordpress.org is prioritized). However, including it ensures local translation files work and maintains compatibility with Loco Translate. + +### Pattern 2: Basic String Translation +**What:** Mark strings for translation using appropriate gettext functions +**When to use:** All user-facing strings in PHP code +**Example:** +```php +// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/ + +// Simple translation (returns string) +$label = __( 'Moving Date', 'siegel-umzugsliste' ); + +// Echo translation (outputs directly) +_e( 'Moving Date', 'siegel-umzugsliste' ); + +// Translation with HTML escaping (returns escaped string) +$safe_label = esc_html__( 'Moving Date', 'siegel-umzugsliste' ); + +// Translation with HTML escaping (echoes escaped string) +esc_html_e( 'Moving Date', 'siegel-umzugsliste' ); + +// Translation with attribute escaping (for use in HTML attributes) +echo ''; +``` + +**Key rules:** +- Always pass text domain as second parameter +- Text domain must be a string literal (not variable or constant) +- Use escaped variants (`esc_html__()`, `esc_attr__()`) for security + +### Pattern 3: Variables in Translatable Strings +**What:** Use placeholders with `sprintf()` or `printf()` for strings containing variables +**When to use:** Any translatable string that includes dynamic content +**Example:** +```php +// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/ + +// Correct approach with sprintf +printf( + /* translators: %s: Customer name */ + esc_html__( 'Moving list for %s', 'siegel-umzugsliste' ), + esc_html( $customer_name ) +); + +// WRONG - variables inside translation string +// _e( "Moving list for $customer_name", 'siegel-umzugsliste' ); // Don't do this! + +// Multiple placeholders with numbered arguments (for reordering in translations) +printf( + /* translators: 1: source address, 2: destination address */ + esc_html__( 'Moving from %1$s to %2$s', 'siegel-umzugsliste' ), + esc_html( $source ), + esc_html( $destination ) +); +``` + +**Why this matters:** Translators see the literal string during extraction. If variables are embedded, translations break because the runtime string differs from the extracted string. + +### Pattern 4: Translating Data Array Strings +**What:** Wrap array values in `__()` calls so they're extracted to POT and translatable +**When to use:** Static data arrays like furniture items and room names +**Example:** +```php +// Based on research: https://codex.wordpress.org/I18n_for_WordPress_Developers + +public static function get_rooms() { + return array( + 'wohnzimmer' => __( 'Wohnzimmer', 'siegel-umzugsliste' ), + 'schlafzimmer' => __( 'Schlafzimmer', 'siegel-umzugsliste' ), + 'arbeitszimmer' => __( 'Arbeitszimmer', 'siegel-umzugsliste' ), + 'bad' => __( 'Bad', 'siegel-umzugsliste' ), + 'kueche_esszimmer' => __( 'Küche/Esszimmer', 'siegel-umzugsliste' ), + 'kinderzimmer' => __( 'Kinderzimmer', 'siegel-umzugsliste' ), + 'keller' => __( 'Keller/Speicher/Garage', 'siegel-umzugsliste' ), + ); +} + +// For nested arrays (furniture items) +array( + 'name' => __( 'Sofa, Couch, je Sitz', 'siegel-umzugsliste' ), + 'cbm' => 0.4, + 'montage' => true +), +``` + +**Important:** For email generation that must always be in German, you'll need a separate mechanism to get original German strings (see Pattern 6). + +### Pattern 5: Locale Switching for Email Generation +**What:** Temporarily switch to German locale when generating email, then restore +**When to use:** Email generation that must always be in German regardless of site locale +**Example:** +```php +// Source: https://developer.wordpress.org/reference/functions/switch_to_locale/ +// Known issue: https://core.trac.wordpress.org/ticket/39210 + +public static function generate_email_in_german( $data ) { + // Switch to German + switch_to_locale( 'de_DE' ); + + // Generate email using translated strings + // All __() calls will now return German translations + $email_html = self::generate( $data ); + + // Restore previous locale + restore_previous_locale(); + + return $email_html; +} +``` + +**Critical caveat:** `switch_to_locale()` has a known bug where plugin translations don't automatically reload. See "Don't Hand-Roll" section for the required workaround. + +### Pattern 6: Accessing Original Strings for Email +**What:** Maintain separate German string lookup to bypass translation system for email +**When to use:** When you need original German strings regardless of translation state +**Example:** +```php +// Alternative to locale switching if it proves problematic + +/** + * Get untranslated German furniture data for email + * Returns furniture items with original German names + */ +public static function get_furniture_items_german( $room_key ) { + // Same structure as get_all_furniture_data() but without __() calls + $german_data = array( + 'wohnzimmer' => array( + array( 'name' => 'Sofa, Couch, je Sitz', 'cbm' => 0.4, 'montage' => true ), + // ... rest of items with hardcoded German strings + ), + ); + + return $german_data[ $room_key ] ?? array(); +} +``` + +**Tradeoff:** Duplicates data but guarantees German email regardless of translation state. Decision: Use locale switching first, fall back to this if issues arise. + +### Pattern 7: POT File Generation +**What:** Generate POT template file that contains all translatable strings +**When to use:** During development after adding/changing translatable strings, before release +**Example:** +```bash +# Source: https://developer.wordpress.org/cli/commands/i18n/make-pot/ + +# Navigate to plugin root +cd /path/to/wp-content/plugins/siegel-umzugsliste + +# Generate POT file +wp i18n make-pot . languages/siegel-umzugsliste.pot --domain=siegel-umzugsliste + +# Initialize German PO file from POT (first time only) +msginit --input=languages/siegel-umzugsliste.pot \ + --locale=de_DE \ + --output-file=languages/siegel-umzugsliste-de_DE.po + +# After editing PO file, compile to MO +msgfmt languages/siegel-umzugsliste-de_DE.po \ + -o languages/siegel-umzugsliste-de_DE.mo +``` + +### Anti-Patterns to Avoid +- **Variables in gettext calls:** Never use `__( "Text $variable", 'domain' )` - breaks translation extraction +- **Concatenating translations:** Don't split sentences across multiple `__()` calls - breaks grammar in other languages +- **Dynamic text domains:** Never use `__( 'Text', $variable_domain )` - must be string literal +- **Forgetting text domain:** Omitting the second parameter breaks translations +- **Wrong escaping context:** Using `esc_html__()` for attributes or vice versa - security risk +- **Text domain in variable:** `__( 'Text', DOMAIN_CONSTANT )` won't work - must be literal string + +## Don't Hand-Roll + +Problems that look simple but have existing solutions: + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| POT file extraction | Custom regex/parser to find strings | `wp i18n make-pot` | Handles PHP, JS, edge cases, comments, context, plurals. Regex misses nested calls, string concatenation, heredoc. | +| PO to MO compilation | Custom binary MO writer | `msgfmt` from gettext tools | MO format is complex binary with endianness, hash tables, offsets. Msgfmt is battle-tested. | +| Locale switching with plugin translations | Just use `switch_to_locale()` alone | Add `change_locale` hook to reload text domain | Core bug (#39210): plugin translations unload on locale switch. Hook workaround required. | +| Fallback to German for unsupported locales | Custom `gettext` filter to check locale | Language Fallback plugin OR custom `locale` filter | German fallback needs to apply before translation loading, not during gettext calls. Requires locale chain modification. | +| JavaScript translation | Custom JSON translation loader | `wp_localize_script()` for simple cases, `wp_set_script_translations()` for Gutenberg | WordPress has built-in mechanisms. Custom loaders miss WordPress hooks, filters, caching. | +| Translation file discovery | Custom file scanner | Loco Translate plugin | Handles .pot/.po/.mo scanning, fuzzy matching, MO compilation, editor UI. Complex directory structures. | + +**Key insight:** WordPress i18n has sharp edges (locale switching bug, text domain literal requirement, MO binary format). The ecosystem has mature tools that handle these. Custom solutions inevitably rediscover the same bugs. + +## Common Pitfalls + +### Pitfall 1: Text Domain Doesn't Match Plugin Slug +**What goes wrong:** Translations don't load, WordPress.org translation system doesn't work, Loco Translate can't auto-discover +**Why it happens:** Developer chooses descriptive text domain without realizing it must match folder name +**How to avoid:** Text domain must match plugin folder name, use dashes (not underscores), lowercase only +**Warning signs:** `load_plugin_textdomain()` returns false, translations exist but don't apply, Loco Translate doesn't find the plugin +**Current issue:** This plugin has `Text Domain: umzugsliste` in header but folder is `Siegel-Umzugsliste`. Correct text domain should be `siegel-umzugsliste`. + +### Pitfall 2: switch_to_locale() Doesn't Load Plugin Translations +**What goes wrong:** After calling `switch_to_locale('de_DE')`, plugin strings still show in original locale instead of German +**Why it happens:** WordPress core bug #39210 - `switch_to_locale()` unloads all text domains and reloads core translations, but plugin text domains aren't automatically reloaded +**How to avoid:** Hook into `change_locale` action and manually call `load_plugin_textdomain()` to reload plugin translations +**Warning signs:** Core WordPress strings change language but plugin strings don't, translations work on normal pages but not after `switch_to_locale()` +**Solution:** +```php +// Workaround for switch_to_locale() plugin translation bug +add_action( 'change_locale', function( $locale ) { + // Reload plugin text domain after locale change + unload_textdomain( 'siegel-umzugsliste' ); + load_plugin_textdomain( + 'siegel-umzugsliste', + false, + dirname( plugin_basename( __FILE__ ) ) . '/languages' + ); +} ); +``` + +### Pitfall 3: Variables Inside Translation Strings +**What goes wrong:** Translations don't work - string with variable content never matches extracted POT string +**Why it happens:** POT extraction sees literal code, but runtime sees different string after variable interpolation +**How to avoid:** Always use `sprintf()` or `printf()` with placeholders, never embed variables directly +**Warning signs:** Translation exists in PO file but doesn't apply, POT file contains partial string without variable content +**Example:** +```php +// WRONG - variable interpolation +$msg = __( "Hello $name", 'siegel-umzugsliste' ); + +// CORRECT - sprintf with placeholder +$msg = sprintf( __( 'Hello %s', 'siegel-umzugsliste' ), $name ); +``` + +### Pitfall 4: Forgetting to Regenerate POT After Code Changes +**What goes wrong:** New strings don't appear in Loco Translate, translators can't translate new features, translations fall back to English +**Why it happens:** POT file is static - it's a snapshot of translatable strings at generation time +**How to avoid:** Regenerate POT file before each release, add to release checklist, consider adding to CI/CD +**Warning signs:** Recent code changes have translatable strings but they don't appear in translation editor +**Solution:** Always run `wp i18n make-pot` before committing translation-related changes + +### Pitfall 5: Wrong Escaping Function for Context +**What goes wrong:** XSS vulnerabilities or broken HTML entity display +**Why it happens:** `esc_html__()` encodes quotes differently than `esc_attr__()`, using wrong one breaks output +**How to avoid:** Use `esc_html__()` for HTML content, `esc_attr__()` for HTML attributes, `esc_js__()` for JavaScript strings +**Warning signs:** Quote marks display as `"` in visible text, or HTML attributes break with special characters +**Example:** +```php +// CORRECT - html escaping for content +echo '

' . esc_html__( 'Room: "Living Room"', 'siegel-umzugsliste' ) . '

'; +// Output:

Room: "Living Room"

+ +// CORRECT - attribute escaping for attributes +echo ''; + +// WRONG - html escaping in attribute breaks quotes +// echo ''; +``` + +### Pitfall 6: Translation Arrays Not Extracted to POT +**What goes wrong:** Array-based strings (like furniture items) don't appear in POT file for translation +**Why it happens:** If arrays use plain strings without `__()` calls, WP-CLI doesn't recognize them as translatable +**How to avoid:** Wrap every translatable string in arrays with `__()`, even if it looks redundant +**Warning signs:** Form displays furniture items but they don't appear in Loco Translate +**Solution:** +```php +// WRONG - plain strings in array +return array( + 'wohnzimmer' => 'Wohnzimmer', + 'schlafzimmer' => 'Schlafzimmer', +); + +// CORRECT - wrapped in __() +return array( + 'wohnzimmer' => __( 'Wohnzimmer', 'siegel-umzugsliste' ), + 'schlafzimmer' => __( 'Schlafzimmer', 'siegel-umzugsliste' ), +); +``` + +## Code Examples + +Verified patterns from official sources: + +### Loading Text Domain on Init Hook +```php +// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/ + +/** + * Load plugin textdomain + * Call this on 'init' hook or 'plugins_loaded' + */ +function siegel_umzugsliste_load_textdomain() { + load_plugin_textdomain( + 'siegel-umzugsliste', + false, + dirname( plugin_basename( __FILE__ ) ) . '/languages' + ); +} +add_action( 'init', 'siegel_umzugsliste_load_textdomain' ); +``` + +### Email Generation with Locale Forcing +```php +// Source: https://developer.wordpress.org/reference/functions/switch_to_locale/ +// Combined with workaround for https://core.trac.wordpress.org/ticket/39210 + +class Umzugsliste_Email_Generator { + + /** + * Generate email in German regardless of site locale + */ + public static function generate( $data ) { + // Save current locale + $original_locale = get_locale(); + + // Switch to German + switch_to_locale( 'de_DE' ); + + // Generate email content using __() functions + // All translatable strings will now return German translations + $email_html = self::build_email_html( $data ); + + // Restore original locale + restore_previous_locale(); + + return $email_html; + } + + /** + * Build email HTML using translatable strings + * Called after locale switch, so __() returns German + */ + private static function build_email_html( $data ) { + $html = '

' . esc_html__( 'Moving List', 'siegel-umzugsliste' ) . '

'; + + // Furniture data with translated names + $rooms = Umzugsliste_Furniture_Data::get_rooms(); + foreach ( $rooms as $room_key => $room_label ) { + // $room_label is already translated via __() in get_rooms() + $html .= '

' . esc_html( $room_label ) . '

'; + } + + return $html; + } +} + +// CRITICAL: Add this workaround for switch_to_locale() bug +add_action( 'change_locale', function( $locale ) { + // Plugin translations don't auto-reload on locale switch (WP core bug #39210) + // Manually unload and reload to ensure plugin strings translate correctly + unload_textdomain( 'siegel-umzugsliste' ); + load_plugin_textdomain( + 'siegel-umzugsliste', + false, + dirname( plugin_basename( UMZUGSLISTE_PLUGIN_DIR . 'umzugsliste.php' ) ) . '/languages' + ); +}, 10, 1 ); +``` + +### Translating Furniture Data Arrays +```php +// Based on: https://codex.wordpress.org/I18n_for_WordPress_Developers + +class Umzugsliste_Furniture_Data { + + /** + * Get room definitions with translatable labels + */ + public static function get_rooms() { + return array( + 'wohnzimmer' => __( 'Wohnzimmer', 'siegel-umzugsliste' ), + 'schlafzimmer' => __( 'Schlafzimmer', 'siegel-umzugsliste' ), + 'arbeitszimmer' => __( 'Arbeitszimmer', 'siegel-umzugsliste' ), + 'bad' => __( 'Bad', 'siegel-umzugsliste' ), + 'kueche_esszimmer' => __( 'Küche/Esszimmer', 'siegel-umzugsliste' ), + 'kinderzimmer' => __( 'Kinderzimmer', 'siegel-umzugsliste' ), + 'keller' => __( 'Keller/Speicher/Garage', 'siegel-umzugsliste' ), + ); + } + + /** + * Get furniture items with translatable names + */ + private static function get_all_furniture_data() { + return array( + 'wohnzimmer' => array( + array( + 'name' => __( 'Sofa, Couch, je Sitz', 'siegel-umzugsliste' ), + 'cbm' => 0.4, + 'montage' => true + ), + array( + 'name' => __( 'Sessel mit Armlehne', 'siegel-umzugsliste' ), + 'cbm' => 0.8, + 'montage' => true + ), + // ... rest of items + ), + ); + } +} +``` + +### Form Rendering with Escaped Translations +```php +// Source: https://developer.wordpress.org/apis/security/escaping/ + +class Umzugsliste_Form_Renderer { + + public static function render_field( $field_name ) { + ?> + + + + + 1000 ) { + return sprintf( + /* translators: %d: maximum allowed cubic meters */ + esc_html__( 'Total volume exceeds maximum of %d cubic meters.', 'siegel-umzugsliste' ), + 1000 + ); + } + + return ''; + } +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Manual `load_plugin_textdomain()` required | Optional for WP.org plugins (4.6+) | WordPress 4.6 (2016) | WP.org plugins auto-load from translate.wordpress.org, but manual loading still needed for local files and Loco Translate | +| `xgettext` for POT extraction | `wp i18n make-pot` WP-CLI command | WP-CLI 2.1.0 (2019) | WordPress-aware extraction, handles edge cases, JS support, simpler workflow | +| Email always in site locale | `switch_to_locale()` for per-user language | WordPress 4.7 (2016) | Can send emails in recipient's language, but has plugin translation bug (#39210) requiring workaround | +| Manual JSON file generation for JS | `wp_set_script_translations()` auto-generates | WordPress 5.0 (2018) | Gutenberg blocks can use same POT file, automatic JSON extraction from PO files | +| Separate fallback locale logic | Native fallback chains proposed | Ticket #28197 (proposed, not in core) | Still requires Language Fallback plugin for locale family fallback (es_MX → es_ES) | + +**Deprecated/outdated:** +- **`pomo` library direct usage:** WordPress core abstracts this, use `load_plugin_textdomain()` instead +- **`makepot.php` script:** Replaced by WP-CLI `wp i18n make-pot`, more reliable and maintained +- **Domain Path without leading slash:** Modern convention is `/languages`, old plugins used `languages` (no slash) - both work but `/languages` is standard +- **`languages_loaded` action for text domain loading:** Use `init` hook instead, `languages_loaded` was never officially documented + +## Open Questions + +Things that couldn't be fully resolved: + +1. **German fallback for unsupported locales** + - What we know: WordPress doesn't natively fall back to de_DE for non-German, non-English locales (e.g., fr_FR with no French translation would show English, not German) + - What's unclear: Whether this is important enough to warrant the Language Fallback plugin, or if just shipping de_DE and en_US translations is sufficient + - Recommendation: Start without Language Fallback plugin. If a site uses a locale other than de_DE or en_US and lacks that translation, it will fall back to English (the strings in code). Only add Language Fallback if users actually need de_DE as ultimate fallback instead of English. Decision marked for Claude's discretion in CONTEXT.md. + +2. **Locale switching vs. separate German data** + - What we know: `switch_to_locale()` works but requires `change_locale` hook workaround, separate German data array avoids translation system entirely + - What's unclear: Which approach is more maintainable long-term and less fragile + - Recommendation: Implement `switch_to_locale()` with the `change_locale` workaround first (standard WordPress pattern). If it proves problematic during testing, fall back to separate German data array. Locale switching is more "WordPress-native" and keeps data in one place. + +3. **JavaScript localization scope** + - What we know: Frontend validation uses JavaScript and shows error messages + - What's unclear: Whether current JS validation messages are inline in HTML (easy to translate via PHP) or in separate JS files (requires `wp_localize_script()` or `wp_set_script_translations()`) + - Recommendation: Inspect existing JS validation implementation. If messages are in separate JS file, use `wp_localize_script()` to pass translated strings from PHP. If messages are inline in PHP-rendered HTML, wrap them in `esc_js( __() )`. + +4. **POT file freshness in version control** + - What we know: POT should be regenerated before each release, but committing it to git means it can go stale between releases + - What's unclear: Whether to commit POT file to git or generate it only for releases + - Recommendation: Commit POT file to git for Loco Translate compatibility on development installs, and add "regenerate POT" step to release checklist. Alternative: Use git hooks to auto-regenerate on commit, but that adds complexity. + +## Sources + +### Primary (HIGH confidence) +- [How to Internationalize Your Plugin – WordPress Plugin Handbook](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) - Official WordPress plugin i18n guide +- [switch_to_locale() – WordPress Function Reference](https://developer.wordpress.org/reference/functions/switch_to_locale/) - Official function documentation +- [wp i18n make-pot – WP-CLI Command](https://developer.wordpress.org/cli/commands/i18n/make-pot/) - Official WP-CLI POT generation tool +- [Loco Translate - Help for Authors](https://localise.biz/wordpress/plugin/authors) - Official Loco Translate plugin compatibility guide +- [Escaping Data – WordPress Common APIs Handbook](https://developer.wordpress.org/apis/security/escaping/) - Official escaping function documentation + +### Secondary (MEDIUM confidence) +- [WordPress i18n best practices (2026 blog post)](https://aki-hamano.blog/en/2026/02/04/wordpress-i18n/) - Recent developer blog with updated practices +- [switch_to_locale() unloads plugin translations – WordPress Trac #39210](https://core.trac.wordpress.org/ticket/39210) - Known bug and workaround discussions (WebSearch reference, direct access blocked) +- [How to translate emails? - Polylang Documentation](https://polylang.pro/doc/how-to-translate-emails/) - Confirms locale switching pattern for emails +- [Language Fallback Plugin](https://wordpress.org/plugins/language-fallback/) - Solution for locale family fallback + +### Tertiary (LOW confidence) +- [Text Domain in WordPress Internationalization - Pascal Birchler](https://pascalbirchler.com/text-domain-wordpress-internationalization/) - Community blog post about text domain requirements, not official but well-regarded source +- [WordPress wp_localize_script alternatives discussion](https://roots.io/stop-using-wp_localize_script-to-pass-data/) - Community discussion about when NOT to use wp_localize_script (for data vs. actual localization) + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH - WordPress i18n API is stable and well-documented since WP 2.1, gettext is industry standard +- Architecture: HIGH - Official handbook provides clear patterns, WP-CLI and Loco Translate are de facto standards +- Pitfalls: HIGH - Known issues like #39210 are documented in Trac, text domain matching is enforced by WP.org, variable interpolation bugs are common and well-known +- Email locale switching: MEDIUM - Pattern is documented but workaround required due to core bug, needs testing to confirm effectiveness +- German fallback: LOW - Language Fallback plugin exists but unclear if needed for this use case + +**Research date:** 2026-02-06 +**Valid until:** 2026-03-08 (30 days - WordPress i18n is stable, patterns unlikely to change rapidly) + +**Critical action items for planning:** +1. Change text domain from `umzugsliste` to `siegel-umzugsliste` throughout codebase +2. Implement `change_locale` hook workaround for `switch_to_locale()` bug +3. Decide: locale switching vs. separate German data for email generation (recommend trying locale switching first) +4. Decide: ship German PO/MO files or only POT template (recommend shipping both for out-of-box German support)