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 = '