Security and language
Internationalisation From POT to Plurals
What a POT is, why the text domain must match the plugin slug, and how a missing translator comment or a hard-coded plural rule breaks a translation you never read.
There is a particular kind of bug that a developer can never reproduce. A translator
reads %s remaining, has no idea whether %s is a duration, a
distance or a person's name, guesses, and gets it wrong in a language you do not speak. The
plugin ships, the store owner's customers see a strange sentence, and no error log anywhere
records it. Translation defects are invisible from the development side by construction,
which is why the work of preventing them happens at the moment you write the string.
Sifero Courier Timeline is in better shape here than most plugins that never left wordpress.org. It declares a text domain and a domain path, it ships a POT file with 700 extractable strings, and it labels 35 of them with translator comments. It also has defects in exactly the places this lesson is about, and one of them I could only see by regenerating the POT file from the current source and comparing. That comparison is the second half of this lesson.
Three file types, one of which runs
A POT file is a template. It holds every translatable string in the source, the file and line each came from, and any comments you attached to them, all with empty translations. It is generated from your code, never edited by hand, and it is the thing you hand to a translation platform. It has no plural rules because it is not a translation of anything yet.
A PO file is a POT with a language attached. One translator takes the
template, sets the header fields, and fills in msgstr for each
msgid. It is still a text file, still reviewable in a diff, and it is what a
translation team edits.
An MO file is the compiled form of the PO. It is binary, it is smaller,
and it is the only one of the three that WordPress reads at runtime.
load_textdomain() wants an MO file; nothing in a normal request parses a PO or
a POT. That is the split to remember: POT and PO are for humans and tooling, MO is for the
request.
You can see the plugin respecting it. Nothing in its languages/ directory is
an MO, because shipping an MO for a translation you did not write is how you end up with a
half-translated interface and no way to update it. What it ships instead is the template:
languages/sifero-courier-timeline-for-woocommerce.pot 70,840 bytes, 3,080 lines
The text domain is the plugin slug, and that is not a style choice
The header and the constant agree, and both agree with the plugin slug:
// sifero-courier-timeline-for-woocommerce.php, lines 3-11, shortened
/**
* Plugin Name: Sifero Courier Timeline for WooCommerce
* Version: 1.2.0
* Text Domain: sifero-courier-timeline-for-woocommerce
* Domain Path: /languages
*/
define( 'SFCT_TEXT_DOMAIN', 'sifero-courier-timeline-for-woocommerce' );
Text Domain is the name every __( $text, $domain ) call uses, and
the name WordPress uses to find language packs. When it matches the plugin slug, a translation
approved on wordpress.org lands in
wp-content/languages/plugins/sifero-courier-timeline-for-woocommerce-es_ES.mo and
loads with no code from you. Change it and the pack still installs, because WordPress matches
the slug, and nothing uses it, because your calls look under the other name. The plugin then
appears translated on the wordpress.org page and is untranslated on every site.
load_plugin_textdomain does much less in 7.0.7 than its name suggests
This is the first thing to check before you copy a tutorial, because the function's behaviour is not what most explanations describe. Here is its body in 7.0.7:
// wp-includes/l10n.php, WordPress 7.0.7
function load_plugin_textdomain( $domain, $deprecated = false, $plugin_rel_path = false ) {
global $wp_textdomain_registry, $l10n;
if ( ! is_string( $domain ) ) {
return false;
}
if ( false !== $plugin_rel_path ) {
$path = WP_PLUGIN_DIR . '/' . trim( $plugin_rel_path, '/' );
} elseif ( false !== $deprecated ) {
_deprecated_argument( __FUNCTION__, '2.7.0' );
$path = ABSPATH . trim( $deprecated, '/' );
} else {
$path = WP_PLUGIN_DIR;
}
$wp_textdomain_registry->set_custom_path( $domain, $path );
return true;
}
It sets a path and returns true. It loads nothing, and has not since just-in-time loading
arrived. The load happens when a translation function first asks for a domain, in
_load_textdomain_just_in_time(), which looks the path up and calls
load_textdomain() itself. A plugin on wordpress.org needs no call at all.
Two cases still want an explicit call: a bundled MO in languages/, where the
relative path tells the registry that directory, and a translation somewhere WordPress would
not look. This plugin does the second, preferring the language-pack copy:
// includes/languages.php, lines 15-27
function sfct_load_textdomain() {
$locale = sfct_get_locale();
if ( $locale ) {
$global_mo = trailingslashit( WP_LANG_DIR ) . 'plugins/' . SFCT_TEXT_DOMAIN . '-' . $locale . '.mo';
$local_mo = SFCT_PATH . 'languages/' . SFCT_TEXT_DOMAIN . '-' . $locale . '.mo';
if ( file_exists( $global_mo ) ) {
load_textdomain( SFCT_TEXT_DOMAIN, $global_mo );
} elseif ( file_exists( $local_mo ) ) {
load_textdomain( SFCT_TEXT_DOMAIN, $local_mo );
}
}
The order of the branches is the design decision: a WordPress language pack wins over a copy in the plugin folder, so a site owner can update a translation without editing the plugin.
// sifero-courier-timeline-for-woocommerce.php, line 261
add_action( 'plugins_loaded', 'sfct_load_textdomain' );
One warning attaches to that hook. If a translation function runs before
after_setup_theme, WordPress fires a _doing_it_wrong() notice saying
translations should load at init or later, and has since 6.7.0:
// wp-includes/l10n.php, WordPress 7.0.7, shortened
if ( ! doing_action( 'after_setup_theme' ) && ! did_action( 'after_setup_theme' ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: 1: The text domain. 2: 'init'. */
__( 'Translation loading for the %1$s domain was triggered too early. ...' ),
'<code>' . $domain . '</code>',
'<code>init</code>'
),
'6.7.0'
);
}
plugins_loaded is before after_setup_theme, so the plugin loads
inside the window that notice describes. It gets away with it because the notice fires when a
translation function triggers just-in-time loading for a registered domain, and this code
loads its own file directly. Moving the call to init breaks nothing.
Four functions, four different jobs
The names all look alike and the differences matter.
__() returns the translated string, which is why every escaped variant is
built on it. _e() echoes it with no escaping, and a translator's stray
< becomes markup; esc_html_e() and esc_attr_e()
exist for that reason. Count the calls in this plugin and the escaped forms carry the volume:
399 __(), 354 esc_html_e(), 135 esc_html__(), 14
esc_attr__(), one esc_attr_e() and not a single bare
_e().
_x() adds a context string to the lookup key, which WordPress builds as
$context . "\4" . $text. So _x( 'Post', 'noun' ) and
_x( 'Post', 'verb' ) are separate entries a translator can render differently,
and changing a context string orphans every existing translation of it. This plugin never
calls _x() or _nx().
_n() takes a singular form, a plural form and a count, and selects between
them. The count is not decoration; it is the only input to the selection. This plugin has
exactly one _n() call in the whole codebase:
// includes/orders.php, lines 442-443
<?php /* translators: %s: number of products in the order. */ ?>
<em><?php echo esc_html( sprintf( _n( '%s product', '%s products', $item_count, 'sifero-courier-timeline-for-woocommerce' ), number_format_i18n( $item_count ) ) ); ?></em>
That call shows three good habits in one line. The count is the real count.
number_format_i18n() formats it with the locale's separators, so a German store
sees 1.000 rather than 1,000. The escape wraps the
sprintf(), not the translated string, so an HTML-special character in a
translation cannot break out of the element. And %s rather than %d
is deliberate, because after number_format_i18n() the value is a string.
It is the plugin's only _n() call, though the POT has two entries with
msgid_plural. The second is _n_noop() at
statuses.php:473, which declares the plural pair for a custom status's
label_count; WordPress renders that count itself, so the no-op form registers the
pair without translating it at the point of definition.
A placeholder without a comment is a bug you are handing to someone else
A translator sees exactly two things about your string: the string, and whatever you wrote
above it. If the string is %s remaining, the string is telling them almost
nothing. Is the placeholder a duration, a count of items, a person, a tracking code? Do
French and Spanish need a different word order? Does the noun agree with the number?
You know the answers, and the comment is one line. This plugin uses the standard form in 35 places, and the difference between a useful comment and a useless one is whether it names the placeholder's meaning as well as its type:
// includes/tracking.php, line 341 and line 367
/* translators: %d: Number of hours. */
/* translators: 1: Start hour. 2: End hour. */
The first names the unit, which cannot be guessed. The second names both positional placeholders, which matters when a translation reorders them. Two rules follow: use numbered placeholders whenever a string has more than one, and write the comment for a stranger who cannot read your code.
Here is what the audit of this plugin's POT file found. Of 26 entries containing a
%s or %d placeholder, 25 carry a #. translator comment
and one does not:
#: includes/templates-frontend.php:500
#, php-format
msgid "%d%% progress"
and this is the entry whose source was already fixed, because the code at that line now carries the comment the POT lacks:
// includes/templates-frontend.php, lines 500-501
/* translators: %d: progress percentage for the current shipment step. */
$html .= '<small>' . esc_html( sprintf( __( '%d%% progress', 'sifero-courier-timeline-for-woocommerce' ), $progress ) ) . '</small>';
So the POT is stale, not the source. That distinction is the whole reason to regenerate
rather than hand-edit. I ran wp i18n make-pot against the current source and
diffed it against the shipped file. Three things changed, and one of them is not a comment
at all:
-"Project-Id-Version: Sifero Courier Timeline for WooCommerce 1.1.178\n"
+"Project-Id-Version: Sifero Courier Timeline for WooCommerce 1.2.0\n"
-"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/Sifero Courier Timeline for WooCommerce (free)\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/sifero-courier-timeline-for-woocommerce\n"
-msgid "Location no detectada"
+msgid "Use a square image of 500 × 500 px. It will also be used to adapt the card color."
Take them in order. The POT was generated for version 1.1.178 and the plugin header says
1.2.0, so it did not get regenerated in the last release. The
Report-Msgid-Bugs-To header holds a display name wrapped around a URL, with
spaces in it; that field is a URI, and a bug report link that is not a URI is a translation
bug report that never arrives. And "Location no detectada" is a Spanish string
sitting in an English template, referenced to includes/tracking.php:811, where
the current source contains no such text. It is a leftover from a build before that string
was translated in code. The regenerated file has 700 message ids instead of 701 and 36
translator comments instead of 35.
Plural rules belong to the language, not to the string
The source string '%s product' does not know how many forms a language has.
The translation does, and it says so in its own header, in a field the POT cannot have
because it is not a translation. WordPress parses that field from the MO file. The parser is
a real one, in wp-includes/pomo/plural-forms.php, not an
eval() of a string from a downloaded file, and the header is read as
nplurals plus a boolean expression:
// wp-includes/pomo/translations.php, WordPress 7.0.7
public function gettext_select_plural_form( $count ) {
if ( ! isset( $this->_gettext_select_plural_form ) || is_null( $this->_gettext_select_plural_form ) ) {
list( $nplurals, $expression ) = $this->nplurals_and_expression_from_header( $this->get_header( 'Plural-Forms' ) );
$this->_nplurals = $nplurals;
$this->_gettext_select_plural_form = $this->make_plural_form_function( $nplurals, $expression );
}
return call_user_func( $this->_gettext_select_plural_form, $count );
}
I built two MO files for the same '%s file' / '%s files' pair
and asked _n() for the same counts through each. The choice of
msgstr index came entirely from the header in the file being loaded:
--- two-form MO: nplurals=2; plural=n != 1;
_n( ..., 1 ) = %d archivo
_n( ..., 2 ) = %d archivos
_n( ..., 21 ) = %d archivos
--- three-form MO: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;
_n( ..., 1 ) = %d fajl
_n( ..., 2 ) = %d fajla
_n( ..., 5 ) = %d fajlova
_n( ..., 11 ) = %d fajlova
_n( ..., 21 ) = %d fajl
_n( ..., 22 ) = %d fajla
_n( ..., 25 ) = %d fajlova
Compare 21 and 22. In a two-form language every count above one takes the same string. In a three-form language 21 takes the singular form and 22 does not, because the rule is about the last digit and the last two digits, not about being greater than one. You cannot write that logic into a source string. Two consequences follow.
First, _n() needs a real integer. I passed a string that is not a number and
a float through a loaded translation and both were coerced by the rule arithmetic without
complaint, but a count you assembled from formatted text will silently select the wrong form.
Count with count(), absint() or an integer column, and format for
display afterwards.
Second, when a translation has no Plural-Forms header at all, WordPress falls
back to two forms and the English rule. MO::$_nplurals defaults to
2, and with no translation loaded at all _n() returns the source
forms on the source rule:
_n( ..., 1 ) = %d file
_n( ..., 2 ) = %d files
This plugin ships no MO at all, so every count on a non-English store falls back to the source forms. The machinery is wired correctly and has almost nothing plugged into it. The fix is not more code: it is a POT regenerated at release, strings that carry context and comments, and a platform where somebody other than you can do the work.
Pick eight translatable strings out of the plugin across at least three files: two
you would leave as __(), one you would change to _x() because
the English word is ambiguous, one that needs a %1$s positional
placeholder, one where the current code escapes in the wrong place, and one you would
convert to _n(). For each, write the corrected call including the text
domain, and for the ones with placeholders write the translator comment.
Then write the _n() conversion as a real call for a count the plugin
already has, run it on the lab install with the two MO files described above, and paste
the output. Say which count value produces a different string in each language and
why.
Finally, regenerate the POT with wp i18n make-pot and diff the message
ids against the shipped file. List every id that is in one and not the other, and for
each say whether the source or the template was wrong.
Show the solution and the reasoning
'Order' appears as a column heading, a card label and part of "order
status". The heading and label take the same translation in most languages and can stay as
__( 'Order', ... ). Where the word is a countable noun you want
_x( 'Order', 'noun', 'sifero-courier-timeline-for-woocommerce' ), and where it
is a verb, _x( 'Order', 'verb', ... ). The plugin never uses _x(),
so this is a change rather than a fix, and existing translations of the un-contextualised
entry do not carry over. That one-time loss is the reason to do it before a language has 700
entries rather than after.
Any string with two values needs positional placeholders, for example
'Displaying %1$s of %2$s orders', with the comment
/* translators: 1: number of orders shown, 2: total number of orders. */. With
%s twice, a translator who needs to swap them cannot. If the same value is
printed twice, use %1$s for both occurrences and say so; a translator who sees
%1$s ... %1$s can tell it is one value.
The misplaced escape is the easiest to miss because the output looks correct. A call
shaped esc_html__( 'Please enter a tracking code.', ... ) inside an attribute
is wrong: esc_html__() is for text nodes and an attribute needs
esc_attr__(). Both produce identical output, as the escaping lesson showed,
which is why it survives review. The fix still matters, because the filter names differ and
a site hooking attribute_escape will not see your value.
The plural conversion uses the count the plugin already has, which is
$item_count in sfct_render_orders_admin_card():
/* translators: %s: number of products in the order. */
echo esc_html( sprintf(
_n( '%s product', '%s products', $item_count, 'sifero-courier-timeline-for-woocommerce' ),
number_format_i18n( $item_count )
) );
The count that proves the point is 21. Under the English rule an order with twenty-one
products renders "21 products"; under the three-form rule it takes the singular, because the
expression tests n%10==1 && n%100!=11. Had you written
1 === $item_count ? $single : $plural into the template, as the plugin's own
sfct_builtin_ngettext_fallback() does, that language would be wrong for 21, 31
and 41. That is why plural rules are not yours to hard-code.
The POT diff has four findings and the order tells you what to fix first.
Project-Id-Version says 1.1.178 against a plugin at 1.2.0, which explains the
others: a POT not regenerated for a release does not describe the code.
Report-Msgid-Bugs-To is not a URI, so the translation bug-report link is
malformed. "Location no detectada" is a Spanish string referenced to a line
that no longer contains it, and here the template was wrong and the source right, so delete
the entry. "%d%% progress" is missing a translator comment the source now has,
so the template is what to regenerate.
One entry changed for the most common reason of all: the source string was edited. The
shipped file has "Upload an image from the media library..." and the current
source has "Use a square image of 500 × 500 px..." at
includes/admin-pages.php:546. Editing an English string discards its existing
translations, which is unavoidable, but doing it without regenerating the POT leaves a
translator working on text the site no longer shows.
If you want the one-line policy that prevents all of it: wp i18n make-pot
runs in CI, the build fails when the regenerated file differs from the committed one, and a
human reads the diff before the release. That turns every stale template into a failed
build instead of a translator guessing at a string the plugin stopped using.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.