{"id":20863,"date":"2026-10-18T09:03:00","date_gmt":"2026-10-18T07:03:00","guid":{"rendered":"https:\/\/www.lukaswojcik.com\/blog\/?p=20863"},"modified":"2026-10-09T22:29:15","modified_gmt":"2026-10-09T20:29:15","slug":"plugin-internationalization","status":"publish","type":"post","link":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/","title":{"rendered":"Plugin i18n: text domain, language packs and JavaScript"},"content":{"rendered":"<p>A plugin written only in English shows English buttons on a German or Polish site, even when WordPress itself speaks the visitor\u2019s language. This blog runs in English, German and Polish, so every plugin string used here has to exist three times, including texts that JavaScript writes into the page later. WordPress handles this on three levels: gettext calls in PHP, separate JSON files for scripts, and language packs built by translate.wordpress.org. Many older tutorials still call <code>load_plugin_textdomain()<\/code> early in <code>plugins_loaded<\/code>, although since WordPress 6.7 that call only records the path; the notice added in 6.7 comes from translation calls before <code>after_setup_theme<\/code>.<\/p>\n<p>This article builds a small plugin, \u201cLW Reading Time\u201d, with one PHP file and one script that imports <code>@wordpress\/i18n<\/code>. It covers the translation functions, the plural rules of German and Polish, what WordPress 7.1.2 (the current release on 29.09.2026) loads by itself, how a string travels through GlotPress into a language pack, and how translation files are built locally with WP-CLI. <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-check-ci\/\">Plugin Check<\/a> and <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/readme-header-svn\/\">the fields of <code>readme.txt<\/code><\/a> each have a separate article in this series and appear here only where translations touch them.<\/p>\n<h2>The text domain: one identifier, identical to the slug<\/h2>\n<p>Every gettext call carries the text domain as its last argument. It tells WordPress which translation file a string belongs to. For plugins in the directory the rule is strict: the text domain has to match the plugin slug, so a plugin with the slug <code>akismet<\/code> uses the text domain <code>akismet<\/code>, and a plugin may use only one text domain. The Meta handbook states that plugins breaking this rule cannot be imported properly into translate.wordpress.org and do not get language packs. The format is lowercase with hyphens, no underscores and no spaces.<\/p>\n<p>The text domain must also be a literal string. Tools such as <code>wp i18n make-pot<\/code> and the importer on WordPress.org read the source code without running it, so a constant or variable in place of <code>'lw-reading-time'<\/code> is invisible to them; the plugin handbook explicitly advises against this shortcut. Strings that core already translates, such as \u201cSettings\u201d, still get the plugin\u2019s own text domain, otherwise they lose their translation once core changes its wording.<\/p>\n<p>According to the handbook, the <code>Text Domain<\/code> header has been optional since WordPress 4.6, and directory plugins may omit <code>Domain Path<\/code>. Both are still worth setting: <code>make-pot<\/code> reads the text domain from the header, and since WordPress 6.8 this pair lets plugins outside the directory load translations automatically. Plugin Check 2.1.0 (released on 16.08.2026) reports an invalid text domain format as an error, a header that differs from the slug as a warning, and a <code>Domain Path<\/code> that lacks a leading slash or points to a missing folder as a warning as well. The example plugin therefore ships an existing <code>languages\/<\/code> folder containing the POT file.<\/p>\n<h2>The gettext functions in PHP<\/h2>\n<p>The main file of the example plugin shows the reading time above single posts and adds a button that the script later relabels. All visible strings pass through a translation function:<\/p>\n<pre><code>&lt;?php\n\/**\n * Plugin Name:       LW Reading Time\n * Description:       Shows the estimated reading time above single posts.\n * Version:           1.0.0\n * Requires at least: 6.8\n * Requires PHP:      7.4\n * Author:            Lukas Wojcik\n * License:           GPL-2.0-or-later\n * License URI:       https:\/\/www.gnu.org\/licenses\/gpl-2.0.html\n * Text Domain:       lw-reading-time\n * Domain Path:       \/languages\n *\n * @package LW_Reading_Time\n *\/\n\ndefined( 'ABSPATH' ) || exit;\n\nconst LW_READING_TIME_WORDS_PER_MINUTE = 200;\n\n\/**\n * Returns the estimated reading time of a post in whole minutes.\n *\n * @param WP_Post $post Post object.\n * @return int Minutes, at least 1.\n *\/\nfunction lw_reading_time_minutes( WP_Post $post ) {\n\t$text  = wp_strip_all_tags( strip_shortcodes( $post-&gt;post_content ) );\n\t$words = preg_split( '\/\\s+\/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );\n\n\treturn max( 1, (int) ceil( count( $words ) \/ LW_READING_TIME_WORDS_PER_MINUTE ) );\n}\n\n\/**\n * Prepends the reading time and a \"mark as read\" button to single posts.\n *\n * @param string $content Post content.\n * @return string\n *\/\nfunction lw_reading_time_prepend( $content ) {\n\tif ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n\t\treturn $content;\n\t}\n\n\t$post    = get_post();\n\t$minutes = lw_reading_time_minutes( $post );\n\n\t$label = sprintf(\n\t\t\/* translators: %s: estimated reading time in minutes. *\/\n\t\t_n( 'Reading time: %s minute', 'Reading time: %s minutes', $minutes, 'lw-reading-time' ),\n\t\tnumber_format_i18n( $minutes )\n\t);\n\n\t$button = sprintf(\n\t\t'&lt;button type=\"button\" class=\"lw-reading-time__mark\" data-post=\"%1$d\"&gt;%2$s&lt;\/button&gt;',\n\t\t(int) $post-&gt;ID,\n\t\tesc_html__( 'Mark as read', 'lw-reading-time' )\n\t);\n\n\treturn '&lt;p class=\"lw-reading-time\"&gt;' . esc_html( $label ) . ' ' . $button . '&lt;\/p&gt;' . $content;\n}\nadd_filter( 'the_content', 'lw_reading_time_prepend' );\n\n\/**\n * Enqueues the front-end script and connects it to its translation files.\n *\/\nfunction lw_reading_time_enqueue_script() {\n\tif ( ! is_singular( 'post' ) ) {\n\t\treturn;\n\t}\n\n\t$asset_file = plugin_dir_path( __FILE__ ) . 'build\/index.asset.php';\n\tif ( ! file_exists( $asset_file ) ) {\n\t\treturn;\n\t}\n\t$asset = require $asset_file;\n\n\twp_enqueue_script(\n\t\t'lw-reading-time',\n\t\tplugins_url( 'build\/index.js', __FILE__ ),\n\t\t$asset['dependencies'],\n\t\t$asset['version'],\n\t\tarray( 'in_footer' =&gt; true )\n\t);\n\n\t\/\/ Looks for lw-reading-time-{locale}-{md5 of \"build\/index.js\"}.json in wp-content\/languages\/plugins, without a language pack there in languages\/ (Domain Path).\n\twp_set_script_translations( 'lw-reading-time', 'lw-reading-time' );\n}\nadd_action( 'wp_enqueue_scripts', 'lw_reading_time_enqueue_script' );<\/code><\/pre>\n<p><code>__()<\/code> returns the translation, <code>_e()<\/code> prints it. Printing without escaping is exactly what the WordPress Coding Standards flag: the <code>EscapeOutput<\/code> sniff lists <code>_e()<\/code> and <code>_ex()<\/code> as unsafe printing functions and suggests <code>esc_html_e()<\/code> or <code>esc_attr_e()<\/code> instead. The combined functions <code>esc_html__()<\/code>, <code>esc_html_e()<\/code>, <code>esc_attr__()<\/code>, <code>esc_attr_e()<\/code>, <code>esc_html_x()<\/code> and <code>esc_attr_x()<\/code> translate and escape in one step. A translated string is still text from outside the code and is escaped like any other output. In the example, <code>esc_html()<\/code> wraps the result of <code>sprintf()<\/code>, because escaping only the pattern would leave the inserted number unescaped.<\/p>\n<h3>Placeholders and translator comments<\/h3>\n<p>Variables never belong inside the translated string. A call like <code>_e( \"Your city is $city.\" )<\/code> fails because the extractor sees the literal <code>$city<\/code>, while at runtime WordPress looks up a string with the actual city name, which no translation file contains. The correct form is a placeholder plus <code>printf()<\/code> or <code>sprintf()<\/code>. With several placeholders the handbook recommends numbered arguments such as <code>%1$s<\/code> and <code>%2$s<\/code> in single quotes, so a translation can reorder the values.<\/p>\n<p>A comment starting with <code>translators:<\/code> tells the translator what a placeholder stands for. It has to be the last PHP comment before the gettext call; <code>make-pot<\/code> copies it into the POT file as a <code>#.<\/code> line, and GlotPress displays it next to the string. The number itself goes through <code>number_format_i18n()<\/code>, because German and Polish use different thousands separators than English.<\/p>\n<p><code>_x()<\/code> adds a context for words with several meanings; GlotPress treats context plus text as a separate entry. The script below uses <code>_x( 'Read', \u2026 )<\/code> for the state after the click: without context, \u201cRead\u201d might become the verb \u201cLesen\u201d or \u201cCzytaj\u201d, while the button needs \u201cGelesen\u201d or \u201cPrzeczytany\u201d.<\/p>\n<h3>Plural forms: two in English and German, three in Polish<\/h3>\n<p><code>_n()<\/code> receives a singular, a plural, the count and the text domain. English and German get by with two forms, Polish needs three. The rule lives in the <code>Plural-Forms<\/code> header of each locale. GlotPress, the software behind translate.wordpress.org, defines Polish with <code>nplurals=3<\/code> and the expression <code>(n == 1) ? 0 : ((n % 10 &gt;= 2 &amp;&amp; n % 10 &lt;= 4 &amp;&amp; (n % 100 &lt; 12 || n % 100 &gt; 14)) ? 1 : 2)<\/code>; a Polish export from translate.wordpress.org carries the same header. For the reading time this results in:<\/p>\n<table>\n<thead>\n<tr>\n<th>Minutes<\/th>\n<th>English<\/th>\n<th>German<\/th>\n<th>Polish (form)<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>1<\/td>\n<td>Reading time: 1 minute<\/td>\n<td>Lesezeit: 1 Minute<\/td>\n<td>Czas czytania: 1 minuta (0)<\/td>\n<\/tr>\n<tr>\n<td>2<\/td>\n<td>Reading time: 2 minutes<\/td>\n<td>Lesezeit: 2 Minuten<\/td>\n<td>Czas czytania: 2 minuty (1)<\/td>\n<\/tr>\n<tr>\n<td>5<\/td>\n<td>Reading time: 5 minutes<\/td>\n<td>Lesezeit: 5 Minuten<\/td>\n<td>Czas czytania: 5 minut (2)<\/td>\n<\/tr>\n<tr>\n<td>12<\/td>\n<td>Reading time: 12 minutes<\/td>\n<td>Lesezeit: 12 Minuten<\/td>\n<td>Czas czytania: 12 minut (2)<\/td>\n<\/tr>\n<tr>\n<td>21<\/td>\n<td>Reading time: 21 minutes<\/td>\n<td>Lesezeit: 21 Minuten<\/td>\n<td>Czas czytania: 21 minut (2)<\/td>\n<\/tr>\n<tr>\n<td>22<\/td>\n<td>Reading time: 22 minutes<\/td>\n<td>Lesezeit: 22 Minuten<\/td>\n<td>Czas czytania: 22 minuty (1)<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>This explains a rule from the handbook: the singular has to contain the placeholder as well, so <code>'%s minute'<\/code> and not <code>'One minute'<\/code>, because in some languages the \u201csingular\u201d slot also covers numbers other than one. The code only passes the number; which of the three Polish sentences appears is decided by the translation file. For plural strings whose count is not known yet at definition time, <code>_n_noop()<\/code> and <code>translate_nooped_plural()<\/code> exist.<\/p>\n<h2>Who loads the translations: just-in-time instead of load_plugin_textdomain<\/h2>\n<p>With WordPress 4.6, the way translations are loaded changed fundamentally. The dev note from July 2016 introduced just-in-time loading: when WordPress meets a text domain for the first time, <code>_load_textdomain_just_in_time()<\/code> looks in <code>wp-content\/languages<\/code> and loads the matching file. Since then, plugins from the directory no longer need <code>load_plugin_textdomain()<\/code>. The same release reversed the search order, so language packs in <code>wp-content\/languages<\/code> are checked before files inside the plugin folder.<\/p>\n<p>WordPress 6.5 replaced the translation engine. Language packs now also contain a <code>.l10n.php<\/code> file next to <code>.po<\/code> and <code>.mo<\/code>; when present, WordPress loads it instead of the <code>.mo<\/code> file, which is faster, uses less memory and benefits from OPcache. The filter <code>translation_file_format<\/code> switches back to <code>mo<\/code>.<\/p>\n<p>WordPress 6.7 changed <code>load_plugin_textdomain()<\/code> so that it only records the path; the just-in-time mechanism loads the translations. The same release added a <code>_doing_it_wrong()<\/code> notice. If translations for a domain are requested before <code>after_setup_theme<\/code>, WordPress reports that loading \u201cwas triggered too early\u201d and says to load translations at <code>init<\/code> or later. According to the dev note, early loading forces WordPress to determine the current user prematurely and prevents other plugins from filtering the locale or file location.<\/p>\n<p>For testing, a detail in core\u2019s <code>l10n.php<\/code> matters: the notice appears as soon as core knows a language directory for the domain, even if no translation file exists. Since 6.8, core registers the <code>Domain Path<\/code> folder for every active plugin, or the plugin folder if the header is absent. On WordPress 7.1.2, the snippet below therefore also triggers the notice on an English site without any translation file.<\/p>\n<pre><code>&lt;?php\n\/\/ Too early: runs while WordPress loads the plugin file, before after_setup_theme and init.\n\/\/ WordPress 6.7+ reports (since 6.8 also without a translation file, even on en_US)\n\/\/ _load_textdomain_just_in_time() via _doing_it_wrong().\ndefine( 'LW_READING_TIME_TITLE', __( 'Reading time', 'lw-reading-time' ) );\n\n\/\/ Better: translate at the moment the text is needed (init or later).\nfunction lw_reading_time_title() {\n\treturn __( 'Reading time', 'lw-reading-time' );\n}\n\n\/\/ Plugin metadata without translating it: third parameter $translate = false.\nfunction lw_reading_time_version() {\n\tif ( ! function_exists( 'get_plugin_data' ) ) {\n\t\trequire_once ABSPATH . 'wp-admin\/includes\/plugin.php';\n\t}\n\t$data = get_plugin_data( __FILE__, false, false );\n\n\treturn $data['Version'];\n}<\/code><\/pre>\n<p>The dev note names two typical causes: <code>get_plugin_data()<\/code> without <code>$translate = false<\/code>, and classes instantiated in the main file whose constructor calls <code>__()<\/code>. To locate the trigger, it suggests a callback on <code>doing_it_wrong_run<\/code> that prints a backtrace for <code>_load_textdomain_just_in_time<\/code>, or Query Monitor.<\/p>\n<p>WordPress 6.8 extended just-in-time loading to plugins outside the directory, based on the <code>Text Domain<\/code> and <code>Domain Path<\/code> headers. The dev note sums it up in two cases:<\/p>\n<ul>\n<li>Plugin hosted on WordPress.org and requiring at least WordPress 4.6: no <code>load_plugin_textdomain()<\/code> call is needed.<\/li>\n<li>Plugin with <code>Text Domain<\/code> and <code>Domain Path<\/code> headers and requiring at least WordPress 6.8: no call is needed either.<\/li>\n<\/ul>\n<p>Anything else, for example a plugin distributed elsewhere that still supports older versions, keeps the call but moves it to <code>init<\/code>. Plugin Check enforces this for the directory: the sniff <code>PluginCheck.CodeAnalysis.DiscouragedFunctions.load_plugin_textdomainFound<\/code> produces a warning as soon as the minimum WordPress version, taken from <code>Requires at least<\/code> in the header or readme, is above 4.6. The message adds that WordPress loads translations under the plugin slug automatically.<\/p>\n<h2>JavaScript: @wordpress\/i18n, build step and wp_set_script_translations<\/h2>\n<p>Since WordPress 5.0, the script handle <code>wp-i18n<\/code> provides the same functions in the browser: <code>__()<\/code>, <code>_x()<\/code>, <code>_n()<\/code>, <code>_nx()<\/code> and <code>sprintf()<\/code>. The example imports them from the npm package <code>@wordpress\/i18n<\/code> and builds the file with <code>@wordpress\/scripts<\/code>:<\/p>\n<pre><code>{\n\t\"name\": \"lw-reading-time\",\n\t\"version\": \"1.0.0\",\n\t\"private\": true,\n\t\"license\": \"GPL-2.0-or-later\",\n\t\"scripts\": {\n\t\t\"build\": \"wp-scripts build\",\n\t\t\"start\": \"wp-scripts start\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@wordpress\/scripts\": \"^36.0.0\"\n\t}\n}<\/code><\/pre>\n<pre><code>\/\/ src\/index.js\nimport { _n, _x, sprintf } from '@wordpress\/i18n';\n\nconst STORAGE_KEY = 'lwReadingTimeRead';\n\nfunction getReadPosts() {\n\ttry {\n\t\tconst stored = JSON.parse( window.localStorage.getItem( STORAGE_KEY ) );\n\t\treturn Array.isArray( stored ) ? stored : [];\n\t} catch ( error ) {\n\t\treturn [];\n\t}\n}\n\nfunction saveReadPosts( ids ) {\n\ttry {\n\t\twindow.localStorage.setItem( STORAGE_KEY, JSON.stringify( ids ) );\n\t} catch ( error ) {\n\t\t\/\/ Storage may be unavailable (e.g. private windows); the button still updates.\n\t}\n}\n\ndocument.querySelectorAll( '.lw-reading-time__mark' ).forEach( ( button ) =&gt; {\n\tbutton.addEventListener( 'click', () =&gt; {\n\t\tconst postId = Number( button.dataset.post );\n\t\tconst readPosts = getReadPosts();\n\n\t\tif ( ! readPosts.includes( postId ) ) {\n\t\t\treadPosts.push( postId );\n\t\t\tsaveReadPosts( readPosts );\n\t\t}\n\n\t\tbutton.disabled = true;\n\t\tbutton.textContent = _x(\n\t\t\t'Read',\n\t\t\t'status of a post after it was marked as read',\n\t\t\t'lw-reading-time'\n\t\t);\n\t\tbutton.title = sprintf(\n\t\t\t\/* translators: %d: number of posts marked as read in this browser. *\/\n\t\t\t_n(\n\t\t\t\t'%d post marked as read in this browser',\n\t\t\t\t'%d posts marked as read in this browser',\n\t\t\t\treadPosts.length,\n\t\t\t\t'lw-reading-time'\n\t\t\t),\n\t\t\treadPosts.length\n\t\t);\n\t} );\n} );<\/code><\/pre>\n<p>Without a <code>block.json<\/code>, <code>wp-scripts build<\/code> takes <code>src\/index.js<\/code> as the entry point and writes <code>build\/index.js<\/code>. The dependency extraction plugin replaces the import with the global <code>wp.i18n<\/code> and writes <code>build\/index.asset.php<\/code> with <code>wp-i18n<\/code> as a dependency plus a version hash; the PHP file passes both to <code>wp_enqueue_script()<\/code>. The webpack configuration keeps comments matching <code>translators:<\/code> in the minified output, and <code>make-pot<\/code> recognises the call forms webpack produces.<\/p>\n<p>The link between script and translation is <code>wp_set_script_translations()<\/code>, available since WordPress 5.0; since 5.1 the text domain parameter is optional. It only works for a registered script, identified by the same handle that <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/performance-optimization-via-functions-php-selective-dequeuing-of-wordpress\/\">the article on selectively dequeuing plugin scripts<\/a> relies on. WordPress then searches for a Jed JSON file and places its contents in <code>wp.i18n<\/code> before the script runs. The file name follows the pattern <code>{domain}-{locale}-{md5}.json<\/code>, where the hash is calculated from the script\u2019s path relative to the plugin folder. For <code>build\/index.js<\/code> that is <code>dfbff627e6c248bcb3b61d7d06da9ca9<\/code>. Core maps <code>.min.js<\/code> to <code>.js<\/code> before hashing, and <code>make-pot<\/code> skips <code>*.min.js<\/code> files by default. An optional third parameter names a directory that WordPress checks first, both for <code>{domain}-{locale}-{handle}.json<\/code> and for the hashed name, before it falls back to <code>wp-content\/languages\/plugins<\/code>.<\/p>\n<p>For script modules registered with <code>wp_register_script_module()<\/code>, WordPress 7.0 added the counterpart <code>wp_set_script_module_translations()<\/code>. The example uses a classic script, so the function only gets this mention here.<\/p>\n<h2>From the source code to the language pack<\/h2>\n<p>For directory plugins, WordPress.org parses all PHP and JavaScript files after the next SVN commit and imports the strings into translate.wordpress.org. Each plugin appears there with four sub-projects: Development, Development Readme, Stable (latest release) and Stable Readme (latest release). Existing <code>.mo<\/code> files in the plugin are imported once; after that, translation work happens on the platform.<\/p>\n<p>Anyone with a WordPress.org account can suggest translations. Suggestions start with the status \u201cWaiting\u201d and only count after a General Translation Editor for the locale, or a Project Translation Editor appointed for the plugin, sets them to \u201cCurrent\u201d. Unreviewed machine translations are not accepted. The first language pack for a locale is built once at least 90 percent of the strings in the Stable sub-project are approved; readme strings do not count toward this threshold. After that, every change to strings or translations leads to a new language pack, even if coverage falls below 90 percent again.<\/p>\n<p>A language pack contains the <code>.po<\/code> and <code>.mo<\/code> files, since WordPress 6.5 the <code>.l10n.php<\/code> file, and one JSON file per script that contains translatable strings. WordPress installs it through the regular translation updates into <code>wp-content\/languages\/plugins\/<\/code>, where just-in-time loading and <code>wp_set_script_translations()<\/code> find it without any further code in the plugin.<\/p>\n<figure class=\"lw-diagram\">\n<img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/diagrams\/wp-plugin-i18n-en.png?v=20260930\" width=\"1120\" height=\"580\" alt=\"One string from source code to output: __() and _n() in PHP and @wordpress\/i18n in scripts are imported after the SVN commit, suggested and approved in GlotPress, built into a language pack at 90 % approved and loaded just in time for PHP and JavaScript; locally, WP-CLI builds the files without GlotPress\"><figcaption>How translate.wordpress.org turns a gettext call into a language pack that WordPress loads for PHP and JavaScript<\/figcaption><\/figure>\n<h2>Own translation files with WP-CLI<\/h2>\n<p>Local files are needed outside the directory, before a first language pack exists, and for client projects with their own wording. The <code>wp i18n<\/code> commands cover the chain:<\/p>\n<pre><code># Run inside wp-content\/plugins\/lw-reading-time\/\n\n# 1. Build the script: creates build\/index.js and build\/index.asset.php (dependency \"wp-i18n\").\nnpm install\nnpm run build\n\n# 2. Extract all strings from PHP and from the built JavaScript into a template.\nwp i18n make-pot . languages\/lw-reading-time.pot --exclude=src\n\n# 3. Copy the template to languages\/lw-reading-time-pl_PL.po (and -de_DE.po),\n#    translate it in Poedit or a text editor, then compile the runtime files.\nwp i18n make-mo languages\nwp i18n make-php languages\nwp i18n make-json languages --no-purge\n\n# Result for Polish:\n# languages\/lw-reading-time-pl_PL.po\n# languages\/lw-reading-time-pl_PL.mo\n# languages\/lw-reading-time-pl_PL.l10n.php\n# languages\/lw-reading-time-pl_PL-dfbff627e6c248bcb3b61d7d06da9ca9.json   (md5 of \"build\/index.js\")<\/code><\/pre>\n<p><code>make-pot<\/code> scans PHP, Blade and JavaScript files as well as the plugin header and always excludes <code>node_modules<\/code>, <code>vendor<\/code>, version control folders and <code>*.min.js<\/code>. In the example, <code>--exclude=src<\/code> ensures that only the built file appears as the source of the JavaScript strings. <code>make-mo<\/code> compiles the binary files, <code>make-php<\/code> creates the <code>.l10n.php<\/code> files and arrived with WP-CLI 2.10.0 (i18n-command 2.6.0). <code>make-json<\/code> writes one Jed JSON file per JavaScript source file. By default it removes the JavaScript strings from the PO file afterwards; <code>--no-purge<\/code> leaves the PO file untouched. The Polish PO file then contains all three plural forms:<\/p>\n<pre><code># languages\/lw-reading-time-pl_PL.po (excerpt)\nmsgid \"\"\nmsgstr \"\"\n\"Language: pl_PL\\n\"\n\"Content-Type: text\/plain; charset=UTF-8\\n\"\n\"Plural-Forms: nplurals=3; plural=(n == 1) ? 0 : ((n % 10 &gt;= 2 &amp;&amp; n % 10 &lt;= 4 &amp;&amp; (n % 100 &lt; 12 || n % 100 &gt; 14)) ? 1 : 2);\\n\"\n\"X-Domain: lw-reading-time\\n\"\n\n#. translators: %s: estimated reading time in minutes.\n#: lw-reading-time.php:50\nmsgid \"Reading time: %s minute\"\nmsgid_plural \"Reading time: %s minutes\"\nmsgstr[0] \"Czas czytania: %s minuta\"\nmsgstr[1] \"Czas czytania: %s minuty\"\nmsgstr[2] \"Czas czytania: %s minut\"\n\n#: lw-reading-time.php:57\nmsgid \"Mark as read\"\nmsgstr \"Oznacz jako przeczytany\"\n\n#: build\/index.js:1\nmsgctxt \"status of a post after it was marked as read\"\nmsgid \"Read\"\nmsgstr \"Przeczytany\"\n\n#. translators: %d: number of posts marked as read in this browser.\n#: build\/index.js:2\nmsgid \"%d post marked as read in this browser\"\nmsgid_plural \"%d posts marked as read in this browser\"\nmsgstr[0] \"%d wpis oznaczony jako przeczytany w tej przegl\u0105darce\"\nmsgstr[1] \"%d wpisy oznaczone jako przeczytane w tej przegl\u0105darce\"\nmsgstr[2] \"%d wpis\u00f3w oznaczonych jako przeczytane w tej przegl\u0105darce\"<\/code><\/pre>\n<p>Since 4.6, language packs in <code>wp-content\/languages<\/code> take precedence over files inside the plugin. Where a plugin must load its own translations nonetheless, the handbook points to the filter <code>load_textdomain_mofile<\/code>; since 6.5, core also offers <code>load_translation_file<\/code>, which receives the path of either the <code>.mo<\/code> or the <code>.l10n.php<\/code> file. For scripts, the third parameter of <code>wp_set_script_translations()<\/code> sets the directory that WordPress checks first.<\/p>\n<h2>Limits and open points<\/h2>\n<ul>\n<li>The example plugin was tested on WordPress 7.1.2 with WP-CLI 2.12.0 and <code>@wordpress\/scripts<\/code> 36.0.0; the package declares Node 22.22.2 or newer, under Node 20 npm only warned. The line numbers in the PO excerpt come from that build and shift with every change to the code.<\/li>\n<li>The sources used do not say whether the WordPress.org importer skips folders such as <code>src\/<\/code> or minified files in the same way as <code>make-pot<\/code>. Either way, the built file has to be in SVN.<\/li>\n<li>Without a third parameter, <code>wp_set_script_translations()<\/code> on WordPress 7.1.2 also finds the JSON files in the <code>Domain Path<\/code> folder, because core uses the directory registered for the domain. As soon as <code>wp-content\/languages\/plugins<\/code> holds a translation file for the same locale, the plugin\u2019s own JSON files are ignored.<\/li>\n<li><code>_n()<\/code> expects a whole number; fractional values such as 1.5 minutes follow other grammatical rules in Polish. The example rounds up to whole minutes.<\/li>\n<\/ul>\n<div class=\"lw-faq\">\n<h2>Questions and answers<\/h2>\n<h3>Does a plugin in the directory have to ship its own .mo files?<\/h3>\n<p>No. After the import into translate.wordpress.org, language packs are built and WordPress downloads them into <code>wp-content\/languages\/plugins\/<\/code> by itself. Existing <code>.mo<\/code> files are imported once during the first import; according to the Meta handbook they can then be removed from the package and from SVN.<\/p>\n<h3>Does the too-early notice only show up on a site with a translation file?<\/h3>\n<p>No, not on WordPress 7.1.2. The check in <code>_load_textdomain_just_in_time()<\/code> runs as soon as core knows a language directory for the domain, and since 6.8 core registers the plugin folder or its <code>Domain Path<\/code> for every active plugin. A test on an <code>en_US<\/code> site without any translation file therefore shows the notice as well. Language packs remain necessary to check the translated output itself.<\/p>\n<h3>PHP strings are translated, script strings stay in English. What is the usual cause?<\/h3>\n<ul>\n<li><code>wp_set_script_translations()<\/code> runs before the script is registered or uses a different handle.<\/li>\n<li>The hash in the JSON file name does not match the relative path of the delivered file, for example because the POT file was built from <code>src\/<\/code> instead of <code>build\/<\/code>.<\/li>\n<li>The built file is missing from SVN, so WordPress.org finds no JavaScript strings.<\/li>\n<\/ul>\n<\/div>\n<div class=\"lw-quellen\">\n<h2>Sources<\/h2>\n<ul>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/internationalization\/how-to-internationalize-your-plugin\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: How to Internationalize Your Plugin<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/internationalization\/localization\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Localization<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/block-editor\/how-to-guides\/internationalization\/\" target=\"_blank\" rel=\"noopener noreferrer\">Block Editor Handbook: Internationalization<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/__\/\" target=\"_blank\" rel=\"noopener noreferrer\">Code Reference: __()<\/a>, <a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/_e\/\" target=\"_blank\" rel=\"noopener noreferrer\">_e()<\/a>, <a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/_x\/\" target=\"_blank\" rel=\"noopener noreferrer\">_x()<\/a>, <a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/_n\/\" target=\"_blank\" rel=\"noopener noreferrer\">_n()<\/a>, <a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/esc_html__\/\" target=\"_blank\" rel=\"noopener noreferrer\">esc_html__()<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/load_plugin_textdomain\/\" target=\"_blank\" rel=\"noopener noreferrer\">Code Reference: load_plugin_textdomain()<\/a> and <a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/wp_set_script_translations\/\" target=\"_blank\" rel=\"noopener noreferrer\">wp_set_script_translations()<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2016\/07\/06\/i18n-improvements-in-4-6\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: I18N Improvements in 4.6<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2018\/11\/09\/new-javascript-i18n-support-in-wordpress\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: JavaScript i18n support in WordPress 5.0<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2024\/02\/27\/i18n-improvements-6-5-performant-translations\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: I18N Improvements in 6.5 (Performant Translations)<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2024\/10\/21\/i18n-improvements-6-7\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: Internationalization improvements in 6.7<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2025\/03\/12\/i18n-improvements-6-8\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: Internationalization improvements in 6.8<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/meta\/handbook\/tutorials-guides\/translations\/\" target=\"_blank\" rel=\"noopener noreferrer\">Meta Handbook: Translations<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/polyglots\/handbook\/plugin-theme-authors-guide\/\" target=\"_blank\" rel=\"noopener noreferrer\">Polyglots Handbook: Plugin\/Theme Authors Guide<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/i18n\/make-pot\/\" target=\"_blank\" rel=\"noopener noreferrer\">WP-CLI: wp i18n make-pot<\/a>, <a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/i18n\/make-json\/\" target=\"_blank\" rel=\"noopener noreferrer\">make-json<\/a>, <a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/i18n\/make-php\/\" target=\"_blank\" rel=\"noopener noreferrer\">make-php<\/a>, <a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/i18n\/make-mo\/\" target=\"_blank\" rel=\"noopener noreferrer\">make-mo<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-includes\/l10n.php\" target=\"_blank\" rel=\"noopener noreferrer\">wordpress-develop, branch 7.1: wp-includes\/l10n.php<\/a> and <a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1\/src\/wp-includes\/script-modules.php\" target=\"_blank\" rel=\"noopener noreferrer\">script-modules.php<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/github.com\/GlotPress\/GlotPress\/blob\/develop\/locales\/locales.php\" target=\"_blank\" rel=\"noopener noreferrer\">GlotPress: locales.php (plural rules)<\/a> and <a href=\"https:\/\/translate.wordpress.org\/projects\/wp-plugins\/akismet\/\" target=\"_blank\" rel=\"noopener noreferrer\">translate.wordpress.org: Akismet project, Polish export<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/plugin-check\/releases\/tag\/2.1.0\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Check 2.1.0<\/a>: DiscouragedFunctionsSniff and Plugin_Header_Fields_Check (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/gutenberg\/tree\/trunk\/packages\/scripts\" target=\"_blank\" rel=\"noopener noreferrer\">@wordpress\/scripts<\/a> (README and webpack configuration) and <a href=\"https:\/\/github.com\/WordPress\/gutenberg\/tree\/trunk\/packages\/i18n\" target=\"_blank\" rel=\"noopener noreferrer\">@wordpress\/i18n<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/WordPress-Coding-Standards\/blob\/develop\/WordPress\/Sniffs\/Security\/EscapeOutputSniff.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress Coding Standards: EscapeOutputSniff<\/a> (retrieved 29.09.2026)<\/li>\n<li><a href=\"https:\/\/wordpress.org\/download\/releases\/\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress Releases<\/a> (retrieved 29.09.2026)<\/li>\n<\/ul>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>A small plugin in PHP and JavaScript shows how strings become translatable, how Polish plural forms work and which files translate.wordpress.org delivers.<\/p>\n","protected":false},"author":1,"featured_media":20905,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[92642],"tags":[91312,91093,91219,91096],"class_list":["post-20863","post","type-post","status-publish","format-standard","hentry","category-tutorials-en-wordpress-plugins-tricks","tag-javascript","tag-php","tag-tutorial","tag-wordpress"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.1 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Plugin i18n: text domain, language packs and JavaScript | Lukas Wojcik<\/title>\n<meta name=\"description\" content=\"Text domain, __(), _n() and @wordpress\/i18n: how WordPress plugins get translated, when load_plugin_textdomain is obsolete and what WP-CLI builds.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Plugin i18n: text domain, language packs and JavaScript | Lukas Wojcik\" \/>\n<meta property=\"og:description\" content=\"Text domain, __(), _n() and @wordpress\/i18n: how WordPress plugins get translated, when load_plugin_textdomain is obsolete and what WP-CLI builds.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/\" \/>\n<meta property=\"og:site_name\" content=\"Lukas Wojcik - Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-18T07:03:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20863-plugin-internationalization.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"630\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Lukas Wojcik\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Lukas Wojcik\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"18 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/\"},\"author\":{\"name\":\"Lukas Wojcik\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"headline\":\"Plugin i18n: text domain, language packs and JavaScript\",\"datePublished\":\"2026-10-18T07:03:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/\"},\"wordCount\":2422,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20863-plugin-internationalization.png\",\"keywords\":[\"JavaScript\",\"PHP\",\"Tutorial\",\"WordPress\"],\"articleSection\":[\"Tutorials\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/\",\"name\":\"Plugin i18n: text domain, language packs and JavaScript | Lukas Wojcik\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20863-plugin-internationalization.png\",\"datePublished\":\"2026-10-18T07:03:00+00:00\",\"description\":\"Text domain, __(), _n() and @wordpress\\\/i18n: how WordPress plugins get translated, when load_plugin_textdomain is obsolete and what WP-CLI builds.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#primaryimage\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20863-plugin-internationalization.png\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20863-plugin-internationalization.png\",\"width\":1200,\"height\":630,\"caption\":\"Plugin i18n: text domain, language packs and JavaScript\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/tutorials-en-wordpress-plugins-tricks\\\/plugin-internationalization\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Plugin i18n: text domain, language packs and JavaScript\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\",\"name\":\"Lukas Wojcik - Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\",\"name\":\"Lukas Wojcik\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"width\":424,\"height\":636,\"caption\":\"Lukas Wojcik\"},\"logo\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\"},\"sameAs\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Plugin i18n: text domain, language packs and JavaScript | Lukas Wojcik","description":"Text domain, __(), _n() and @wordpress\/i18n: how WordPress plugins get translated, when load_plugin_textdomain is obsolete and what WP-CLI builds.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/","og_locale":"en_US","og_type":"article","og_title":"Plugin i18n: text domain, language packs and JavaScript | Lukas Wojcik","og_description":"Text domain, __(), _n() and @wordpress\/i18n: how WordPress plugins get translated, when load_plugin_textdomain is obsolete and what WP-CLI builds.","og_url":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/","og_site_name":"Lukas Wojcik - Blog","article_published_time":"2026-10-18T07:03:00+00:00","og_image":[{"width":1200,"height":630,"url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20863-plugin-internationalization.png","type":"image\/png"}],"author":"Lukas Wojcik","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Lukas Wojcik","Est. reading time":"18 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#article","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/"},"author":{"name":"Lukas Wojcik","@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"headline":"Plugin i18n: text domain, language packs and JavaScript","datePublished":"2026-10-18T07:03:00+00:00","mainEntityOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/"},"wordCount":2422,"commentCount":0,"publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20863-plugin-internationalization.png","keywords":["JavaScript","PHP","Tutorial","WordPress"],"articleSection":["Tutorials"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/","url":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/","name":"Plugin i18n: text domain, language packs and JavaScript | Lukas Wojcik","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#primaryimage"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20863-plugin-internationalization.png","datePublished":"2026-10-18T07:03:00+00:00","description":"Text domain, __(), _n() and @wordpress\/i18n: how WordPress plugins get translated, when load_plugin_textdomain is obsolete and what WP-CLI builds.","breadcrumb":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#primaryimage","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20863-plugin-internationalization.png","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20863-plugin-internationalization.png","width":1200,"height":630,"caption":"Plugin i18n: text domain, language packs and JavaScript"},{"@type":"BreadcrumbList","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/tutorials-en-wordpress-plugins-tricks\/plugin-internationalization\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/www.lukaswojcik.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Plugin i18n: text domain, language packs and JavaScript"}]},{"@type":"WebSite","@id":"https:\/\/www.lukaswojcik.com\/blog\/#website","url":"https:\/\/www.lukaswojcik.com\/blog\/","name":"Lukas Wojcik - Blog","description":"","publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.lukaswojcik.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9","name":"Lukas Wojcik","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","width":424,"height":636,"caption":"Lukas Wojcik"},"logo":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg"},"sameAs":["https:\/\/www.lukaswojcik.com\/blog"]}]}},"_links":{"self":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20863","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/comments?post=20863"}],"version-history":[{"count":1,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20863\/revisions"}],"predecessor-version":[{"id":21738,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20863\/revisions\/21738"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media\/20905"}],"wp:attachment":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media?parent=20863"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/categories?post=20863"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/tags?post=20863"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}