Translating a WordPress plugin, including the JavaScript

Text domains, when translations load, and getting strings and data into JavaScript the current way

Leave a comment
Risograph illustration of a desk globe on a stand beside an empty speech bubble

Internationalisation is making a plugin translatable. Localisation is someone actually translating it. We do the first one, and if we do it wrong the second one is impossible. This covers text domains, when translations load, the PHP functions, and getting strings and data into JavaScript, where the 2022 advice has aged worst.

The text domain

A text domain is a unique string that tells WordPress which group of strings belongs to our plugin. It must match the plugin slug.

my-basics-plugin/my-basics-plugin.phpphp
* Text Domain: my-basics-plugin
 * Domain Path: /languages

Since WordPress 4.6 the Text Domain header is optional, because it has to equal the slug anyway. Domain Path only matters when the plugin bundles its own translation files somewhere other than its own folder. A plugin in the WordPress.org directory can leave it out.

Mismatch the slug and translations from WordPress.org never load, with no error to tell us why.

When translations load

For a plugin hosted on WordPress.org, load_plugin_textdomain() is not needed at all. Since 4.6, WordPress loads the plugin’s translations just in time, the first time a string from that domain is requested.

Since 6.7 that timing is enforced. Ask for a translated string before after_setup_theme and WordPress logs a notice instead of quietly loading early:

text
Translation loading for the my-basics-plugin domain was triggered too early.
This is usually an indicator for some code in the plugin or theme running too early.
Translations should be loaded at the init action or later.

The file format changed too. Since 6.5, WordPress looks for a .l10n.php translation file before the .mo, because PHP files load faster. wp i18n make-php builds them from .po files, and the translation_file_format filter switches back to .mo if needed.

The PHP functions

php
__( 'String', 'text-domain' );         // returns
_e( 'String', 'text-domain' );         // echoes
esc_html__( 'String', 'text-domain' ); // returns, escaped for HTML
esc_attr__( 'String', 'text-domain' ); // returns, escaped for an attribute

Two rules matter more than the function list:

  • escape at output. A translated string is still untrusted input, because we did not write the translation
  • use placeholders, never string concatenation

Concatenating is the one that actively breaks translation. Word order differs by language, and a translator handed half a sentence cannot fix it:

php
printf(
	/* translators: %s: movie title */
	esc_html__( 'Now showing: %s', 'movie-library' ),
	esc_html( $title )
);

The comment has to start with translators: and be the last PHP comment before the call, or the string extractor will not pick it up.

JavaScript strings

Import __ from @wordpress/i18n, which is how every block label gets translated. Then declare wp-i18n as a script dependency and register the translations:

php
wp_register_script( 'movie-library-editor', $url, array( 'wp-i18n' ), $version );
wp_set_script_translations( 'movie-library-editor', 'movie-library' );

WordPress then looks for a JSON translation file for that handle, including the ones from translate.wordpress.org. An optional third argument points it at a local directory. wp i18n make-json builds those files from a .po, one per JavaScript source file.

Two more cases:

  • a block registered from block.json with a textdomain field needs no call. Core calls wp_set_script_translations() for each of its scripts that depends on wp-i18n
  • script modules, the ES module kind the Interactivity API uses, got translation support in 7.0. A plugin module with its own text domain registers it with wp_set_script_module_translations()

Passing data to JavaScript

The August version of this post said wp_localize_script() is for data, not translations. The function reference says the opposite: wp_add_inline_script() is now the best practice for data, and wp_localize_script() should only be used to localize strings. For strings, wp_set_script_translations() does it better. So the function named localize has one job left, and even that one has a replacement.

There is a technical reason too. wp_localize_script() runs every top-level scalar through (string) before encoding, so 'postId' => 123 arrives in JavaScript as "123", and true arrives as "1".

Older code

wp_localize_script()

  • prints var name = {...} before the script
  • top-level numbers and booleans become strings
  • the reference now reserves it for localizing strings

Chosen

wp_add_inline_script()

  • we write the JavaScript ourselves
  • wp_json_encode() keeps the types
  • the reference calls it best practice for data
php
wp_add_inline_script(
	'movie-library-editor',
	'const movieLibrary = ' . wp_json_encode(
		array(
			'restUrl' => rest_url( 'movie-library/v1/' ),
			'nonce'   => wp_create_nonce( 'wp_rest' ),
			'postId'  => get_the_ID(),
		)
	) . ';',
	'before'
);

'before' prints it ahead of the script tag, so movieLibrary exists by the time our code runs.

What this does not cover

Plural forms with _n(), context with _x(), generating .pot files with wp i18n make-pot, and the naming rules for JavaScript translation files, which are their own small pile of md5 hashes.

Leave a comment

Seen this one too, or think I got something wrong? Your email is not published, and a first comment waits for me to approve it.