docs(09): research phase domain
Phase 09: Internationalization - WordPress gettext patterns documented - WP-CLI and Loco Translate compatibility confirmed - switch_to_locale() bug #39210 workaround identified - Text domain mismatch detected (needs correction) - Email locale forcing strategy researched Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
636
.planning/phases/09-i18n/09-RESEARCH.md
Normal file
636
.planning/phases/09-i18n/09-RESEARCH.md
Normal file
@@ -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 '<input type="text" placeholder="' . esc_attr__( 'Enter your name', 'siegel-umzugsliste' ) . '">';
|
||||
```
|
||||
|
||||
**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 '<p>' . esc_html__( 'Room: "Living Room"', 'siegel-umzugsliste' ) . '</p>';
|
||||
// Output: <p>Room: "Living Room"</p>
|
||||
|
||||
// CORRECT - attribute escaping for attributes
|
||||
echo '<input placeholder="' . esc_attr__( 'Enter name', 'siegel-umzugsliste' ) . '">';
|
||||
|
||||
// WRONG - html escaping in attribute breaks quotes
|
||||
// echo '<input placeholder="' . esc_html__( 'Enter name', 'siegel-umzugsliste' ) . '">';
|
||||
```
|
||||
|
||||
### 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 = '<h1>' . esc_html__( 'Moving List', 'siegel-umzugsliste' ) . '</h1>';
|
||||
|
||||
// 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 .= '<h2>' . esc_html( $room_label ) . '</h2>';
|
||||
}
|
||||
|
||||
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 ) {
|
||||
?>
|
||||
<label>
|
||||
<?php esc_html_e( 'Customer Name', 'siegel-umzugsliste' ); ?>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="<?php echo esc_attr( $field_name ); ?>"
|
||||
placeholder="<?php esc_attr_e( 'Enter your name', 'siegel-umzugsliste' ); ?>"
|
||||
>
|
||||
<?php
|
||||
}
|
||||
|
||||
public static function render_submit_button() {
|
||||
?>
|
||||
<button type="submit">
|
||||
<?php esc_html_e( 'Submit Moving List', 'siegel-umzugsliste' ); ?>
|
||||
</button>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Messages with Variables
|
||||
```php
|
||||
// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/
|
||||
|
||||
class Umzugsliste_Form_Handler {
|
||||
|
||||
private function validate_date( $day, $month, $year ) {
|
||||
if ( empty( $day ) || empty( $month ) || empty( $year ) ) {
|
||||
return sprintf(
|
||||
/* translators: %s: field name that is missing */
|
||||
esc_html__( 'Please enter %s', 'siegel-umzugsliste' ),
|
||||
esc_html__( 'moving date', 'siegel-umzugsliste' )
|
||||
);
|
||||
}
|
||||
|
||||
if ( ! checkdate( $month, $day, $year ) ) {
|
||||
return esc_html__( 'Invalid date. Please check your entry.', 'siegel-umzugsliste' );
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function validate_cbm_total( $total_cbm ) {
|
||||
if ( $total_cbm <= 0 ) {
|
||||
return esc_html__( 'Please select at least one furniture item.', 'siegel-umzugsliste' );
|
||||
}
|
||||
|
||||
if ( $total_cbm > 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)
|
||||
Reference in New Issue
Block a user