Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 15 / 18 30 MIN

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.

Lab 35 minutes

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.