Little beads with letters on them

Internationalization in WordPress 5.0

In my previous blog post I explained the importance of the text domain in WordPress internationalization. Today, I want to have a look at the bigger picture of the (new) internationalization features in WordPress 5.0 and beyond. This includes, but is not limited to, enhanced JavaScript internationalization.

If you’re building a WordPress plugin or theme and want to make sure it can be fully localized, this post is for you.

WordPress JavaScript Internationalization

WordPress 5.0 shipped with a completely new editing experience called Gutenberg. This new editor is mainly written in JavaScript, which means a lot of internationalization now happens client-side instead of on the server. Although WordPress core has previously used functions like wp_localize_script() to make some of its more dynamic UIs translatable, a more robust solution was needed for such a complex addition like Gutenberg.

JavaScript Localization Functions

New JavaScript I18N Support in WordPress 5.0 brings the same capabilities to JavaScript development for WordPress that we’re already used to from PHP. This starts with a new wp-i18n JavaScript package that provides localization functions like __(), _x(), _n()_nx(), and even sprintf(). These functions mirror their PHP equivalents and can be used in the same ways.

To use this package, you need to add the wp-i18n script as a dependency when registering your JavaScript:

wp_register_script(
	'my-plugin-script',
	plugins_url( 'js/my-script.js', __FILE__ ),
	array( 'wp-i18n' ),
	'0.0.1'
);Code language: PHP (php)

After that, the localization functions are available under the wp.i18n global variable in JavaScript. You can use them like this:

const { __, _x, _n, sprintf } = wp.i18n;

__( 'Hello World', 'my-plugin' );

_x( 'Glitter Box', 'block name', 'my-plugin' );

// Get the comment count from somewhere else in our script.
const commentCount = wp.data.select( 'my/data-store' ).getCommentCount();

/* translators: %s: number of comments */
sprintf( _n( 'There is %s comment', 'There are %s comments', commentCount, 'my-plugin' ), commentCount );Code language: JavaScript (javascript)

That’s all you need to make your JavaScript components fully localizable.

If you’re familiar with the PHP translation functions in WordPress core, you’ll notice the absence of something like esc_html() or esc_html__(). These aren’t needed in JavaScript because the browser is already capable of escaping unsafe characters.

Note: although it’s discouraged to use HTML in translatable strings, sometimes this is necessary, e.g. for adding links (Check out this link to <a href="%s">my website</a>.). Right now, it’s not easily possible to do so, at least not without using innerHTML / dangerouslySetInnerHTML. However, this is actively being discussed on GitHub.

Loading JavaScript Translations

Keep in mind that just using the __() family of functions isn’t enough for a WordPress plugin or theme to be fully internationalized and localized. We also need to tell WordPress to load the translations for our scripts. This can be achieved by using the new wp_set_script_translations() function introduced in WordPress 5.0.

That function takes three arguments: the registered script handle (my-plugin-script in the previous example), the text domain (my-plugin), and optionally a path to the directory containing translation files. The latter is only needed if your plugin or theme is not hosted on WordPress.org, which provides these translation files automatically.

Note: If you’re registering multiple scripts that all use wp.i18n, you have to call wp_set_script_translations for each one of them.

wp_register_script(
	'my-plugin-script',
	plugins_url( 'js/my-script.js', __FILE__ ),
	array( 'wp-i18n' ),
	'0.0.1'
);

wp_register_script(
	'my-awesome-block',
	plugins_url( 'js/my-block.js', __FILE__ ),
	array( 'wp-i18n' ),
	'0.0.1'
);

wp_set_script_translations( 'my-plugin-script', 'my-plugin' );
wp_set_script_translations( 'my-awesome-block', 'my-plugin' );Code language: PHP (php)

The reason for this is performance. Translations are only loaded when your script is actually enqueued. If that is the case, WordPress loads the translation files into memory and provides them to wp.i18n via inline JavaScript. That means WordPress requires one translation file per script handle with each file only containing strings relevant for that script.

Imagine writing a JavaScript-heavy WordPress plugin with lots of different packages that can also be used independently of each other. You don’t want to load all translations if you just need the ones for a single package.

JavaScript Translation Files

We have now covered loading the JavaScript translation files, but what exactly is so special about them? Well, this time we’re not dealing with PO or MO files, but with JSON files instead. Since JSON can be read very easily in JavaScript, it’s a convenient format to store translations in.

Also, the  wp-i18n package uses a library under the hood that is largely compatible with the Jed JavaScript gettext library, which requires Jed-style JSON translation data. As mentioned in the previous section, WordPress.org provides these translation files automatically. But if you want to ship your own, you need to create such JSON files yourself.

A very simple Jed-style JSON translation file looks like this:

{
   "domain": "messages",
   "locale_data": {
      "messages": {
         "": {
            "domain": "messages",
            "plural_forms": "nplurals=2; plural=(n != 1);",
            "lang": "de_DE"
         },
         "Source": [
            "Quelle"
         ],
         "Enter the information for this recommendation.": [
            "Gib die Informationen zu dieser Empfehlung ein."
         ],
         "%s comment": [
            "%s Kommentar",
            "%s Kommentare"
         ],
         "block name\u0004Recommendation": [
            "Empfehlung"
         ]
      }
   }
}Code language: JSON / JSON with Comments (json)

If you’re familiar with PO translation files already, this format contains similar information like information about the locale (de_DE) and its plural forms. All the strings are in the messages object, with the originals as keys, and the translations being the value. If a string has an additional context, it is prepended by it, with \u0004 acting as a delimiter.

Note: An important detail here is the text domain, which right now needs to be messages and not the one you actually use in the code. There’s a WordPress Trac ticket for this though, so it might be supported in the future.

JavaScript Translation File Names

PO and MO translation files in WordPress usually have the format $textdomain-$locale.po, e.g. my-plugin-de_DE.po. For the JSON files things are a bit different now.

You might remember that we need to pass the script handle name to wp_set_script_translations(). This handle needs to be in the file name as well, in the form $textdomain-$locale-$handle.json.

So for our my-plugin-script script handle, the translation file name needs to be my-plugin-de_DE-my-plugin-script.json.

For technical reasons, WordPress also looks for files in the form $textdomain-$locale-$md5.json, where $md5 is the MD5 hash of the JavaScript file name including the extension. In the earlier example, my-plugin-script points to js/my-script.js. The MD5 hash of my-script.js is 537607a1a008da40abcd98432295d39e. So the alternative file name for our translation file is my-plugin-de_DE-537607a1a008da40abcd98432295d39e.json.

Generating JavaScript Translation Files

Since WordPress requires one translation file per script handle, with each file only containing strings relevant for that script, this quickly means dealing with plenty of JSON files. Luckily, there’s no need to write these by hand.

The recommended way to generate the JSON translation files is by using WP-CLI. The latest version, WP-CLI 2.1.0, provides a dedicated wp i18n make-json command for this.

The wp i18n make-json command extracts all the JavaScript strings from your regular PO translation files and puts them into individual JSON files.

Note: WP-CLI 2.1.0 been released on December 18. Make sure you’re using the latest version by running wp cli update. You can check your current version using wp cli version.

Let’s say in your plugin folder my-plugin you have three source files: my-plugin.php, js/my-script.js and js/my-block.js. You use WP-CLI to extract the strings and generate the translation catalogue (POT) like this:

wp i18n make-pot my-plugin my-plugin/languages/my-plugin.pot

From there you can translate your plugin as usual and create the needed PO and MO files. Let’s say we add a German translation to my-plugin/languages/my-plugin-de_DE.po first. After that, you can simply run wp i18n make-json my-plugin/languages to generate the JavaScript translation files. The result will be as follows:

  • A new my-plugin/languages/my-plugin-de_DE-537607a1a008da40abcd98432295d39e.json file contains the translations for my-script.js.
  • A new my-plugin/languages/my-plugin-de_DE-dad939d0db25804f91959baeec56ea8a.json file contains the translations for my-block.js.
  • The my-plugin/languages/my-plugin-de_DE.po now only contains the translations that are needed on the server side.

If you don’t want to modify the PO file, pass the --no-purge argument to the WP-CLI command, as explained in the documentation.

Note: There are a few known issues in these WP-CLI commands with some edge cases. We’re continuously working on improving the tooling as we learn about how people use them.

Tooling

These new processes introduced with WordPress 5.0 and Gutenberg can feel a bit complex at the beginning. To make lives easier, I want to share some tips and tricks for your project’s configuration.

Webpack Configuration

If you reference the global variables like wp.i18n in your project everywhere, you don’t benefit from your code editor’s power to show things like type hints. To change that, I recommend installing the @wordpress/i18n package as a (development) dependency using npm / yarn. After that, you can use import { __ } from '@wordpress/i18n; throughout your project.

Normally, this would make Webpack bundle the library with your code. Since WordPress already exposes the library via the wp.i18n global, there’s no need for code duplication. To prevent this, add the following to your Webpack configuration:

externals: {
    '@wordpress/i18n': { this: [ 'wp', 'i18n' ] }
}Code language: JavaScript (javascript)

This way you’ll benefit from both your IDE’s powers as well as the already available wp.i18n global. Just make sure you add wp-i18n as a dependency when calling wp_register_script().

Babel Integration

In the previous section I mentioned using wp i18n make-pot to create the necessary translation catalogue from which you can create the actual localizations. Depending on your developer workflow, you might want to look into using a build tool for Babel called @wordpress/babel-plugin-makepot to create the POT file. The latter approach integrates with Babel to extract the I18N methods.

To do so, run npm install --save-dev @wordpress/babel-plugin-makepot and add the following plugin to your Babel configuration:

[
    '@wordpress/babel-plugin-makepot',
    {
        output: 'languages/my-plugin-js.pot',
    },
]Code language: JavaScript (javascript)

Note: You still want to create a POT file for the rest of your PHP files, not just your JavaScript files. You can still do that using WP-CLI. Just skip the JavaScript string extraction and merge the resulting POT files like this:

wp i18n make-pot my-plugin my-plugin/languages/my-plugin.pot --skip-js --merge=my-plugin/languages/my-plugin-js.pot

In this scenario, languages/my-plugin-js.pot would only be of temporary nature, so you could remove it again afterwards.

Available Hooks and Filters

WordPress provides filters like load_textdomain and gettext to allow overriding the path to translation files or individual translations.

In WordPress 5.0.2 we added the following filters to allow filtering the behavior of  wp_set_script_translations() so you can do the same for JavaScript translations. The following filters are available:

  • pre_load_script_translations: Pre-filters script translations for the given file, script handle and text domain. This way you can short-circuit the script translation logic to return your own translations.
  • load_script_translation_file: Filters the file path for loading script translations for the given script handle and text domain..
  • load_script_translations: Filters script translations for the given file, script handle and text domain. This way you can override translations after they have been loaded from the translation file.

In addition to that, pull request #12517 to the Gutenberg project aims to add i18n.gettext, i18n.gettext_with_context, i18n.ngettext, and i18n.ngettext_with_context filters to the @wordpress/i18n package. To override an individual translation, you could use them like this:

wp.hooks.addFilter(
    'i18n.gettext',
    'myplugin/filter-gettext',
    function( translation, text, domain ) {
        if ( 'Source' === text && 'foo-domain' === domain ) {
            return 'New translation';
        }

        return translation;
    }
);Code language: JavaScript (javascript)

WordPress PHP Internationalization

With so many mentions of JavaScript in this post, you might be wondering if we also changed something on the PHP side of things. The answer to this is: no.

However, now is a good time to do some sort of I18N spring cleaning for your plugin or theme. Here is some helpful information for that:

  • Make sure Text Domain is set in your main plugin file / theme stylesheet and that you use that very same text domain throughout the project.
  • If your WordPress plugin or theme is hosted on WordPress.org and requires WordPress 4.6 or higher (indicated via the Tested up to header in the readme), you don’t need to call load_plugin_textdomain() in it.
  • You can run wp i18n make-pot --debug to see which of your translatable strings should be improved.

Further Reading


Thanks to Omar Reiss, Gary Jones, and Dominik Schilling for their feedback and proofreading of this post.


Comments

9 responses to “Internationalization in WordPress 5.0”

  1. I see what you did with the random comment count there, but you might want to clarify that a bit, because people tend to think of things in code blocks as literal things to copy and paste into their code. So, explanation might be needed.

    1. Good point. I updated the code example there a bit for clarification.

  2. if you were going mad like me, gutenberg hashes the relative path with the filename.
    so, if its not the md5 of “my-script.js” but of “js/my-script.js” …

  3. Francesco Pepe Avatar
    Francesco Pepe

    Hi,
    I’m going crazy with this. My plugin has text domain: “search-console”. I have created the .json file that is named “search-console-it_IT-searchconsole-admin.json”. I include the script with “wp_enqueue_script( ‘searchconsole-admin’ )” and then call “wp_set_script_translations( ‘searchconsole-admin’, ‘search-console’ )”. But the translation is not loaded. What I’m doing wrong here?

    1. With this filename, you’d need to call wp_set_script_translations with the third argument, the full path to the directory containing translation files.

      Example: wp_set_script_translations( 'searchconsole-admin', 'search-console', plugin_dir_path( __FILE__ ) . '/translations' );

  4. […] you do? Pascal Birchler has a very thorough overview of the state of localization in WordPress 5.0 here. In that article he notes that the gettext filters do not exist yet, but there is a PHP filter […]

  5. I am also going crazy with this 🙁 I am trying to have the JS translations being loaded for my plugin (https://wordpress.org/plugins/ai-engine/).

    A translator translated everything in Russian. To try it, I set my environment to Russian, and the JSON files related to my plugin got indeed loaded. I realized they have a md5 hash at the end which is how I found this article.

    I am using this:
    wp_set_script_translations( ‘mwai’, ‘ai-engine’, $languagesDir );

    Doesn’t work. As a test, I moved the file (originally called ai-engine-ru_RU-0bdc92e05d8f3ab638aa855679db059e.json by WP) under my own /languages directory (represented by $languagesDir above). I renamed it into “ai-engine-ru_RU-mwai.json”, to make sure the handle is the same. That worked.

    Of course, that’s not what I want; I would like it to work dynamically, with the files created/loaded by WordPress, but I have no clues how. I am pretty sure their MD5 creates the issue, as it doesn’t seem related to either my handle or my domain.

    Anyone can help? Thanks a lot!

    1. It looks like all your translatable strings are in `app/i18n.js`. `0bdc92e05d8f3ab638aa855679db059e` is the MD5 hash value of that path, so that makes sense.

      The problem is that `app/i18n.js` is not used anywhere in your plugin. Your bundler actually includes all of `app/i18n.js` into `app/index.js`. You can quickly see this when searching for some strings like “Max Sentences”, which will be found in both files.

      Now the problem is, in `app/index.js` the function names are mangled and renamed from `__(“Max Sentences”,”ai-engine”)` to `Pt(“Max Sentences”,”ai-engine”)`. And when they’re renamed, WP.org doesn’t know that these are supposed to be translations.

      You’ll need to change your bundler to not mangle the i18n function names, and then let WP.org create a new language pack for your plugin.

      The `app/i18n.js` file can be removed from your final build as it’s not actually used.

Leave a Reply

Your email address will not be published. Required fields are marked *