# Wordpress I18N

> This skill should be used when the user asks to "internationalize a plugin", "add translations", "set up i18n", "create a POT file", "translate strings", "load text domain", "use wp_set_script_translations", "add translator comments", or mentions "i18n", "l10n", "gettext", "text domain", "translation", "localization", "multilingual", "Poedit", "wp i18n make-pot", "wp_localize_script", "wp_set_script_translations", "__()".

- Skill: `iwritec0de/wordpress-i18n` (Agent Skill)
- Install (CLI): `npx skillmds@latest add iwritec0de/wordpress-i18n`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iwritec0de/wordpress-i18n/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: iwritec0de (https://skillmd.com/u/iwritec0de)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iwritec0de/wordpress-i18n

---


# WordPress Internationalization (i18n) & Localization (l10n)

This skill covers making WordPress plugins and themes translatable, generating translation files, and handling JavaScript translations.

## Core Concepts

| Term | Meaning |
|------|---------|
| i18n | Internationalization — preparing code for translation |
| l10n | Localization — translating strings for a specific locale |
| Text domain | Unique identifier matching your plugin/theme slug |
| POT file | Portable Object Template — source strings catalog |
| PO file | Portable Object — translated strings for one locale |
| MO file | Machine Object — compiled PO for runtime use |
| JSON | JavaScript translation files (WP 5.0+) |

## PHP Translation Functions

### Basic Functions

```php
// Return translated string (most common)
$label = __( 'Settings', 'myplugin' );

// Echo translated string
_e( 'Save Changes', 'myplugin' );

// Singular/plural
$message = sprintf(
    /* translators: %d: number of items */
    _n( '%d item found', '%d items found', $count, 'myplugin' ),
    $count
);

// Context-disambiguated (same English string, different meaning)
$label = _x( 'Post', 'noun — a blog post', 'myplugin' );
_ex( 'Post', 'verb — to publish', 'myplugin' );

// Singular/plural with context
$label = _nx( '%d item', '%d items', $count, 'cart items', 'myplugin' );
```

### Escaping + Translation Combos

Always use these when outputting translated strings in HTML:

```php
// Escape for HTML body
echo esc_html__( 'Untrusted translated text', 'myplugin' );
esc_html_e( 'Untrusted translated text', 'myplugin' );

// Escape for HTML attributes
echo esc_attr__( 'Button label', 'myplugin' );
esc_attr_e( 'Button label', 'myplugin' );

// Rich HTML — use wp_kses on translated strings containing markup
printf(
    wp_kses(
        /* translators: %s: settings page URL */
        __( 'Configure <a href="%s">settings</a>.', 'myplugin' ),
        [ 'a' => [ 'href' => [] ] ]
    ),
    esc_url( $settings_url )
);
```

### Function Reference

| Function | Returns | Escapes | Context | Plural |
|----------|---------|---------|---------|--------|
| `__()` | string | No | No | No |
| `_e()` | void (echoes) | No | No | No |
| `_n()` | string | No | No | Yes |
| `_x()` | string | No | Yes | No |
| `_ex()` | void (echoes) | No | Yes | No |
| `_nx()` | string | No | Yes | Yes |
| `esc_html__()` | string | HTML | No | No |
| `esc_html_e()` | void (echoes) | HTML | No | No |
| `esc_attr__()` | string | Attr | No | No |
| `esc_attr_e()` | void (echoes) | Attr | No | No |
| `esc_html_x()` | string | HTML | Yes | No |
| `esc_attr_x()` | string | Attr | Yes | No |

## Translator Comments

**Required** before any string with placeholders. The comment must be on the line immediately before the function call:

```php
/* translators: %s: user display name */
$greeting = sprintf( __( 'Hello, %s!', 'myplugin' ), $user->display_name );

/* translators: 1: start date, 2: end date */
$range = sprintf(
    __( 'From %1$s to %2$s', 'myplugin' ),
    $start_date,
    $end_date
);

/* translators: %d: number of results */
$summary = sprintf(
    _n( '%d result', '%d results', $count, 'myplugin' ),
    number_format_i18n( $count )
);
```

Rules:
- Use `/* translators: ... */` format (block comment, not `//`)
- Describe each placeholder: `%s`, `%d`, `%1$s`, etc.
- Keep translator comments short and descriptive
- Never concatenate translatable strings — translators need full sentence context

## Loading the Text Domain

### Plugins

```php
add_action( 'init', 'myplugin_load_textdomain' );

function myplugin_load_textdomain(): void {
    load_plugin_textdomain(
        'myplugin',
        false,
        dirname( plugin_basename( __FILE__ ) ) . '/languages'
    );
}
```

As of WordPress 6.7+, plugins hosted on WordPress.org get translations loaded automatically — `load_plugin_textdomain()` is still needed for custom/private plugins.

### Themes

```php
add_action( 'after_setup_theme', 'mytheme_load_textdomain' );

function mytheme_load_textdomain(): void {
    load_theme_textdomain( 'mytheme', get_template_directory() . '/languages' );
}
```

## Generating Translation Files

### Using WP-CLI (Recommended)

```bash
# Generate POT file from PHP source
wp i18n make-pot . languages/myplugin.pot --slug=myplugin --domain=myplugin

# Generate JSON files for JavaScript translations
wp i18n make-json languages/ --no-purge

# Generate MO from PO
wp i18n make-mo languages/
```

### POT File Placement

```
my-plugin/
├── languages/
│   ├── myplugin.pot                    # Source template
│   ├── myplugin-fr_FR.po              # French translations
│   ├── myplugin-fr_FR.mo              # Compiled French
│   ├── myplugin-de_DE.po              # German translations
│   ├── myplugin-de_DE.mo              # Compiled German
│   ├── myplugin-fr_FR-{md5}.json      # French JS translations
│   └── myplugin-de_DE-{md5}.json      # German JS translations
```

## JavaScript Translations (WP 5.0+)

### Registering JS Translations

```php
add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_scripts' );

function myplugin_enqueue_scripts(): void {
    wp_enqueue_script(
        'myplugin-frontend',
        plugin_dir_url( __FILE__ ) . 'build/frontend.js',
        [],
        MYPLUGIN_VERSION,
        true
    );

    wp_set_script_translations(
        'myplugin-frontend',  // Must match the script handle
        'myplugin',           // Text domain
        plugin_dir_path( __FILE__ ) . 'languages'
    );
}
```

### Using Translations in JavaScript

```js
import { __, _n, _x, sprintf } from '@wordpress/i18n';

// Basic translation
const label = __( 'Settings', 'myplugin' );

// Plural
const message = sprintf(
    _n( '%d item', '%d items', count, 'myplugin' ),
    count
);

// With context
const action = _x( 'Post', 'verb', 'myplugin' );

// With placeholder
const greeting = sprintf(
    /* translators: %s: user name */
    __( 'Hello, %s!', 'myplugin' ),
    userName
);
```

### Generating JSON Translation Files

After creating PO translations, generate the JSON files WP needs for JS:

```bash
# From the plugin root
wp i18n make-json languages/ --no-purge
```

The `--no-purge` flag keeps the original strings in the PO/MO files (needed if the same string is used in both PHP and JS).

## Common i18n Mistakes

### Never Do This

```php
// WRONG: Concatenated strings — translators can't reorder words
echo __( 'Posted on ', 'myplugin' ) . $date . __( ' by ', 'myplugin' ) . $author;

// CORRECT: Full sentence with placeholders
printf(
    /* translators: 1: date, 2: author name */
    esc_html__( 'Posted on %1$s by %2$s', 'myplugin' ),
    esc_html( $date ),
    esc_html( $author )
);

// WRONG: Variable as text domain
__( 'Hello', $domain );

// CORRECT: Text domain must be a string literal
__( 'Hello', 'myplugin' );

// WRONG: Translating HTML tags
__( '<strong>Important:</strong> Save your work.', 'myplugin' );

// CORRECT: Keep HTML outside or use wp_kses
sprintf(
    '<strong>%s</strong> %s',
    esc_html__( 'Important:', 'myplugin' ),
    esc_html__( 'Save your work.', 'myplugin' )
);

// WRONG: Dynamic strings
__( "Hello $name", 'myplugin' );

// CORRECT: sprintf with placeholder
sprintf( __( 'Hello %s', 'myplugin' ), $name );

// WRONG: Escaping before translation
__( esc_html( $string ), 'myplugin' );

// CORRECT: Escape after translation
esc_html__( 'Static string', 'myplugin' );
// Or for dynamic: esc_html( __( 'String', 'myplugin' ) );
```

### Number and Date Formatting

```php
// Use WordPress locale-aware formatting
$formatted_number = number_format_i18n( 1234567.89, 2 ); // "1,234,567.89" in en_US
$formatted_date = wp_date( 'F j, Y', $timestamp );        // Locale-aware date
$formatted_date = date_i18n( 'F j, Y', $timestamp );      // Legacy (still works)

// Never use PHP's number_format() or date() directly — they ignore locale
```

## RTL (Right-to-Left) Support

```php
// In functions.php — WP handles this automatically if you enqueue properly
function mytheme_enqueue_styles(): void {
    wp_enqueue_style( 'mytheme-style', get_stylesheet_uri() );

    // WP auto-loads style-rtl.css when locale is RTL
    wp_style_add_data( 'mytheme-style', 'rtl', 'replace' );
}

// In CSS — use logical properties
.widget {
    margin-inline-start: 1rem;  /* left in LTR, right in RTL */
    padding-inline-end: 1rem;   /* right in LTR, left in RTL */
}

// Check direction in PHP
if ( is_rtl() ) {
    // RTL-specific logic
}
```

## i18n Checklist

- [ ] Every user-facing string wrapped in `__()`, `_e()`, `_n()`, `_x()`, or escaped variants
- [ ] Text domain matches plugin/theme slug exactly (string literal, never variable)
- [ ] `/* translators: */` comment before every string with placeholders
- [ ] No string concatenation for translatable content — use `sprintf()` with numbered placeholders
- [ ] `load_plugin_textdomain()` or `load_theme_textdomain()` called
- [ ] POT file generated with `wp i18n make-pot`
- [ ] JS translations registered with `wp_set_script_translations()`
- [ ] JSON files generated with `wp i18n make-json` (if JS strings exist)
- [ ] Numbers formatted with `number_format_i18n()`
- [ ] Dates formatted with `wp_date()` or `date_i18n()`
- [ ] RTL stylesheet provided (`style-rtl.css` or logical CSS properties)
- [ ] No HTML inside translation strings (or properly handled with `wp_kses`)

