3.9 KiB
3.9 KiB
WP Guard — Internationalization Reference
Contents
- Wrapper selection
- Text domain rules
- Placeholders and translator comments
- Plurals
- Sentence assembly
- JavaScript i18n
- Dates, numbers, RTL
- Multilingual-plugin gotchas
Wrapper selection
| Situation | Use |
|---|---|
| Return a translated string | __( 'Text', 'my-plugin' ) |
| Echo a translated string | _e( 'Text', 'my-plugin' ) — or better, esc_html_e() |
| Translate + escape for HTML | esc_html__() / esc_html_e() |
| Translate + escape for attribute | esc_attr__() / esc_attr_e() |
| Ambiguous word needing context | _x( 'Post', 'verb', 'my-plugin' ) |
| Plural | _n( '%s item', '%s items', $count, 'my-plugin' ) |
| Plural + context | _nx() |
When a string is both translated and output, the combined wrappers (esc_html__) are required — translations are untrusted input like any other (a compromised translation file is an XSS vector).
Text domain rules
- Literal string, always:
__( 'Text', 'my-plugin' ). Never__( 'Text', PLUGIN_DOMAIN ), never a variable — static analysis tools and translate.wordpress.org both fail on non-literal domains. - Must match the plugin slug exactly (the WordPress.org directory enforces this).
- One domain per plugin/theme. AI agents copying snippets from other projects routinely import foreign text domains — grep for domains that don't match the project's.
Placeholders and translator comments
/* translators: 1: customer name, 2: order number. */
$message = sprintf(
__( 'Hi %1$s, your order #%2$d is on its way.', 'my-plugin' ),
$customer_name,
$order_id
);
- Numbered placeholders (
%1$s) whenever there is more than one — translators reorder words. - A
/* translators: … */comment on every string with placeholders, immediately above the line. - Never put variables or HTML soup inside the translatable string when it can sit outside.
Plurals
_n() exists because languages have between one and six plural forms. Never:
// Wrong — English-only logic.
$label = $count === 1 ? __( 'item', 'my-plugin' ) : __( 'items', 'my-plugin' );
Always _n( '%s item', '%s items', $count, 'my-plugin' ), then sprintf() with number_format_i18n( $count ).
Sentence assembly
Never build sentences by concatenation — word order differs across languages:
// Wrong: translators get fragments they cannot reorder.
echo __( 'Imported', 'my-plugin' ) . ' ' . $count . ' ' . __( 'products', 'my-plugin' );
// Right: one string, one placeholder, full context.
/* translators: %s: number of imported products. */
printf( esc_html__( 'Imported %s products.', 'my-plugin' ), number_format_i18n( $count ) );
JavaScript i18n
wp_set_script_translations( 'my-handle', 'my-plugin' )after enqueueing; use__()from@wordpress/i18nin the JS.- Legacy pattern (
wp_localize_scriptwith pre-translated strings) is acceptable in legacy codebases — match the project.
Dates, numbers, RTL
- Dates:
wp_date()/date_i18n()with the site's format options — never rawdate()for display. - Numbers:
number_format_i18n(). - CSS: logical properties (
margin-inline-start, notmargin-left) for new styles; don't hand-write directional CSS — build-rtl.cssfiles with RTLCSS. Auto-loading them applies only to core styles and block.json-registered styles; classic plugin/theme handles must opt in withwp_style_add_data( $handle, 'rtl', 'replace' ).
Multilingual-plugin gotchas (WPML / Polylang)
- Strings stored in options/meta are NOT translated by
__()— they need string registration (WPML String Translation /pll_register_string). Flag stored user-facing strings during review. - IDs are language-specific: a hardcoded
page_idpoints at one language's page. Resolve through the multilingual plugin's API or filters when the project uses one. - Queries on multilingual sites are language-filtered by default — explicitly note when a query intentionally crosses languages.