I18N: Add translation support for script modules#11543
I18N: Add translation support for script modules#11543manzoorwanijk wants to merge 43 commits intoWordPress:trunkfrom
Conversation
|
Hi @manzoorwanijk! 👋 Thank you for your contribution to WordPress! 💖 It looks like this is your first pull request to No one monitors this repository for new pull requests. Pull requests must be attached to a Trac ticket to be considered for inclusion in WordPress Core. To attach a pull request to a Trac ticket, please include the ticket's full URL in your pull request description. Pull requests are never merged on GitHub. The WordPress codebase continues to be managed through the SVN repository that this GitHub repository mirrors. Please feel free to open pull requests to work on any contribution you are making. More information about how GitHub pull requests can be used to contribute to WordPress can be found in the Core Handbook. Please include automated tests. Including tests in your pull request is one way to help your patch be considered faster. To learn about WordPress' test suites, visit the Automated Testing page in the handbook. If you have not had a chance, please review the Contribute with Code page in the WordPress Core Handbook. The Developer Hub also documents the various coding standards that are followed:
Thank you, |
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
|
Thanks for the PR! Can you expose which AI tool do you use in PR? |
Updated. Thanks |
e68fb27 to
0e73045
Compare
Add `wp_set_script_module_translations()` and supporting infrastructure to enable i18n for script modules (ES modules), mirroring the existing `wp_set_script_translations()` for classic scripts. Script modules registered via `wp_register_script_module()` currently have no way to load translation data, leaving strings untranslated on pages like Connectors and Fonts that are built as script modules. New public API: - `wp_set_script_module_translations()` in script-modules.php - `load_script_module_textdomain()` in l10n.php New methods on `WP_Script_Modules`: - `set_translations()` — stores text domain per module - `get_registered_src()` — public accessor for module source URL - `print_script_module_translations()` — outputs inline setLocaleData() calls after classic scripts load but before modules execute See #65015.
Add two integration tests verifying the full translation flow: - test_print_script_module_translations_outputs_set_locale_data covers the happy path, asserting that the inline script contains the expected module ID, the wp.i18n.setLocaleData() call, and the translated string. - test_print_script_module_translations_includes_dependencies covers translations for dependency modules (not just directly enqueued ones). Both tests use the pre_load_script_translations filter to provide mock translation data inline, so no fixture JSON files are needed. See #65015.
…ules hook. This hook registration was accidentally moved during iteration on the translation hook timing. Restore it to its original position to keep the diff minimal. See #65015.
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Update the test and the caller in load_script_module_textdomain() to match the ?string return type of WP_Script_Modules::get_registered_src(), which returns null (not false) for unregistered modules.
0e73045 to
eb78a18
Compare
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Refactor load_script_textdomain() and load_script_module_textdomain() to share a private helper, _load_script_textdomain_from_src(), that handles the path resolution and file lookup logic. Each public function now only resolves the source URL for its respective registry and delegates to the helper with the appropriate filter name. Reduces duplication by ~88 lines while preserving behavior and the existing load_script_textdomain_relative_path filter.
Thank you for testing this.
The remaining untranslated strings come from a couple of different places:
You can confirm (1) by extending the mu-plugin's filter to intercept additional module IDs like |
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Stop requiring explicit wp_set_script_module_translations() calls to enable translation loading. Instead, print_script_module_translations() now iterates every enqueued script module (and its dependencies) and calls load_script_module_textdomain() with the 'default' text domain by default. This removes the need to maintain a hardcoded list of modules that use wp-i18n in wp_default_script_modules(), and lets translations flow through automatically as long as the JSON chunk for a module exists on disk. Modules that use a non-default text domain or a custom translation path can still opt in via set_translations().
With auto-detection, print_script_module_translations() iterates every enqueued module and its dependencies, so the load_script_textdomain_relative_path filter now fires for both the main module and its dependency rather than only for the dependency that had set_translations() called on it.
|
@jsnajdr thank you for the feedback. I have addressed it and it is now much easier to test the PR, since translations are loaded automatically now. |
jsnajdr
left a comment
There was a problem hiding this comment.
Works great for me and fixes the pressing issue in WP 7.0. ![]()
One downside is that we statically include translations even for modules that are loaded dynamically. That defeats a part of the dynamic lazy initialization.
As a next step, we could implement dynamic loading. I have a proof of concept where I create synthetic scoped __i18n__ modules for each script module, and add them to importmap. Then each JSON chunk is loaded dynamically.
Store the textdomain and translations_path for a script module directly on its entry in WP_Script_Modules::\$registered, rather than in a separate \$translations property. This mirrors how WP_Dependency stores those fields for classic scripts and keeps all data about a module in one place. The nested 'translations' key is only set when set_translations() is called for that module, so the 'optional override' intent is preserved.
| ); | ||
| $source_url = rawurlencode( "wp-script-module-translation-data-{$id}" ); | ||
| $output .= "\n//# sourceURL={$source_url}"; | ||
| wp_print_inline_script_tag( $output, array( 'id' => "wp-script-module-translation-data-{$id}" ) ); |
There was a problem hiding this comment.
It's possible at this point that the wp-i18n script hasn't actually been printed, right? To guard against that, should this first do something like:
| wp_print_inline_script_tag( $output, array( 'id' => "wp-script-module-translation-data-{$id}" ) ); | |
| if ( ! wp_script_is( 'wp-i18n', 'done' ) ) { | |
| wp_scripts()->do_items( array( 'wp-i18n' ) ); | |
| } | |
| wp_print_inline_script_tag( $output, array( 'id' => "wp-script-module-translation-data-{$id}" ) ); |
With that, then the test_print_script_module_translations_outputs_set_locale_data test (and other tests) will need to be updated to account for this new script being added first.
There was a problem hiding this comment.
It's possible at this point that the
wp-i18nscript hasn't actually been printed, right?
The PR tries to prevent this by making sure that the print_script_module_translations handler is registered with priority 11/21 and runs only after _wp_footer_scripts has printed the wp-i18n script. Is that insufficient or unreliable?
Maybe we should throw an error or print a warning when wp-i18n is not done. Instead of calling do_items at an unexpected place, outside the "natural" order.
There was a problem hiding this comment.
The wp-i18n script is not always enqueued, is it? I think it is preferable to print the wp-i18n script just-in-time rather than to assume it is present or else to short-circuit if it wasn't printed. I don't see any issue with printing a script in this way, as WP_Scripts will keep track of it being printed to avoid re-printing it later.
There was a problem hiding this comment.
Priority ordering alone isn't sufficient: it only guarantees we run after wp_print_footer_scripts, which prints enqueued classic scripts. If nothing in the page tree happened to enqueue wp-i18n, the ordering buys us nothing. Script modules don't declare classic-script dependencies, so there's no mechanism that enqueues wp-i18n just because a module calls __().
There was a problem hiding this comment.
The
wp-i18nscript is not always enqueued, is it?
This gets interesting 🙂 The original version of print_script_module_translations printed the translations only when i) someone called set_translations explicitly and ii) the script module has wp-i18n in dependencies.
In that original version wp-i18n was always enqueued because it was a declared dependency. But the current version of print_script_module_translations removed both checks, it prints translations for all modules for which a JSON translation file exists. That can lead to wp-i18n not being enqueued yet.
But should we really call wp_scripts()->do_items( array( 'wp-i18n' ) ) when processing script modules? There are environments that are module-only and don't want to use the "legacy" scripts. Like Interactivity API on frontend. Then, when one of such modules has a translation file (it's generated automatically by GlotPress, you don't have much control over that), the module-only environment will get "contaminated" by a legacy script, and you can't opt out from that.
I see two solutions that are less intrusive:
- add back the check for
wp-i18ndependency. If you want a localized script module, it needs to have the dependencies in order. - make the
wp.i18n.setLocaleDatain the inline script fail-safe. Check the presence ofwp.i18nbefore calling, and warn if its' missing.
FYI @sirreal who does a lot of work in the Interactivity API and script module area and might have some insights.
There was a problem hiding this comment.
I'm not sure I fully agree. The reality is that the script module here does have a dependency on the wp-i18n classic script, right? We don't want there to be this dependency, but this dependency currently exists for it to work. For the translations to work correctly, as I understand, a developer currently has to do:
wp_enqueue_script( 'wp-i18n' );
wp_enqueue_script_module( 'foo' );
wp_set_script_module_translations( 'foo', 'foo' );If they forget to enqueue wp-i18n then the result at the moment is that the translations don't appear, correct? This seems like a worse outcome for users, and it's an easy mistake to make when a developer is only working in English.
Also, this seems to have the effect of cementing the dependency in code. If we are able to improve script module i18n to eliminate the need for the wp-i18n script in a future release, then any such just-in-time printing of the i18n script would be to our advantage because we wouldn't have legacy wp-i18n classic scripts being enqueued still in module-only environments.
Disclaimer: I'm not an expert on how i18n works.
There was a problem hiding this comment.
I think that makes. The dependency is real, and fail-safe just turns a debuggable missing-script into a silent "English on every translated site." The contamination concern mostly dissolves once you trace it: no __() calls → no chunks → no inline script → no force-print. For the guard to fire at all, the module genuinely needs wp.i18n. And keeping the coupling inside Core (rather than pushing wp_enqueue_script( 'wp-i18n' ) out to every plugin author) is easier to walk back later if a module-native i18n runtime lands.
There was a problem hiding this comment.
The reality is that the script module here does have a dependency on the
wp-i18nclassic script, right?
That brings up a more basic question: when there are __() calls in a script module, where does the __ function come from? Ideally, it would be imported also from a native script module:
import { __ } from '@wordpress/i18n';But we don't have that native i18n module yet. The current usage of script modules in the @wordpress/route framework (used by the new Connectors and Font Library pages) therefore makes a compromise: it uses a hybrid approach where a script module depends on the wp-i18n classic script and gets the __ function from the window.wp.i18n global variable.
What I'd like to point out is that the print_script_module_translations function we are implementing here further hardcodes this compromise with the classic wp-i18n script. We probably don't have any other option: even the wp.i18n.setLocaleData call in the generated translation script assumes the wp-i18n script. One day there will be an ESM variant:
<script type="module" id="wp-script-module-translations-foo">
import { setLocaleData } from '@wordpress/i18n';
setLocaleData( { fooTranslations } );
</script>So, OK, let's auto-enqueue the wp-i18n script and whatever else helps us being more ergonomic and fail-safe. But with the awareness that it's a temporary compromise because of an incomplete ESM framework in WordPress. One day we'll have to revisit this.
Also, this seems to have the effect of cementing the dependency in code. If we are able to improve script module i18n to eliminate the need for the
wp-i18nscript in a future release, then any such just-in-time printing of the i18n script would be to our advantage
Yes, this is a great point. If we auto-enqueue the classic script in Core, we can change that later, again in Core. Instead of having a big number of published plugins that do the same on their own, and updating very slowly or not at all. I'm convinced 🙂
script modules can't declare classic-script deps through the current register API
This is another sign how temporary and improvised the classic script dependencies really are. wp-build detects during build which import is a module and which is a script (looking at package.json data) and then writes the .asset.php file with dependencies and module_dependencies data. But during registration, only module_dependencies are looked at. The classic script dependencies are ignored, it's just assumed that all the needed scripts are already enqueued.
There was a problem hiding this comment.
Glad we converged 🙂 Reverting to the do_items( 'wp-i18n' ) guard and dropping the JS window.wp?.i18n?.setLocaleData check - with the force-print in place it's dead code. Agreed the whole compromise is temporary; a future ESM @wordpress/i18n will retire this code path cleanly, and keeping the coupling inside Core is what makes that retirement tractable.
…ead of force-printing.
|
Does this address https://core.trac.wordpress.org/ticket/60234? |
|
I've come across this very late, so I'll just share some thoughts looking ahead. None of this is blocking or critique of this PR.
Thanks for your work here @manzoorwanijk 👍 |
Co-authored-by: Jon Surrell <sirreal@users.noreply.github.com>
Co-authored-by: Weston Ruter <westonruter@gmail.com>
|
Shall I commit this? |


Summary
Adds translation support for script modules (
WP_Script_Modules), so strings using__()from@wordpress/i18ninside script modules are translated at runtime.Script modules (ES modules registered via
wp_register_script_module()) currently have no mechanism to load i18n translation data. This affects the new admin pages in WordPress 7.0+ that are built as script modules, including Connectors and Fonts, where strings remain in English regardless of the site language.Trac ticket: https://core.trac.wordpress.org/ticket/65015
Changes
Runtime
WP_Script_Modules::print_script_module_translations()— iterates every enqueued script module (and its dependencies), looks up the{locale}-{md5(relative-path)}.jsonchunk for each, and emits an inline<script>that callswp.i18n.setLocaleData()with the loaded translations.admin_print_footer_scripts(after classic scripts loadwp-i18nat priority 10) and priority 21 onwp_footer(afterwp_print_footer_scriptsat priority 20), so the inline data is available before deferred ES modules execute.load_script_module_textdomain( $id, $domain, $path )— new function inl10n.php, mirrorsload_script_textdomain()but reads the module's source URL fromWP_Script_Modules. Both functions now share a private helper to avoid duplicating the path-resolution logic.WP_Script_Modules::get_registered( $id )— public accessor for a registered module's data, used by the loader.load_script_textdomain_relative_pathfilter now receives a third argument,$is_module, so callers can distinguish script modules from classic scripts.Public override API (optional)
wp_set_script_module_translations( $id, $domain, $path )and the underlyingWP_Script_Modules::set_translations()exist only as overrides, for modules whose text domain is not'default'or whose translation files live outside the standard location (for example, plugin modules registering their own text domain). Core and most plugin modules do not need to call this — translations are loaded automatically.Tests
New integration tests in
wpScriptModules.phpcovering:print_script_module_translations()loads and prints translations for an enqueued module without any explicit registration.set_translations()correctly overrides the auto-detected text domain.is_moduleargument passed to theload_script_textdomain_relative_pathfilter.Tests use the
pre_load_script_translationsfilter to supply mock translation data, so no fixture JSON files are needed.Related
Gutenberg companion PR: WordPress/gutenberg#77214 — adds a polyfill of the same API for the Gutenberg plugin on WP versions before this change lands.
Test plan
Automated
Manual
Real language packs already ship JSON translation chunks for the Connectors script module files — for example Spanish (
es_ES) has them translated. So end-to-end verification doesn't need any mocked data:Install and activate Spanish:
Via WP-CLI:
Or via the admin UI: switch the site language in Settings > General, then visit Dashboard > Updates (
/wp-admin/update-core.php) and click "Update translations" to ensure the latest language pack is installed.Visit Settings > Connectors (
/wp-admin/options-connectors.php).Expected: heading shows "Conectores", subtitle "Todas tus claves de API y credenciales…", buttons show "Instalar", connector descriptions show "Generación de texto con Claude.", etc.
View page source and search for
wp-script-module-translation-data-— inline<script>tags with IDs likewp-script-module-translation-data-wp/routes/connectors-home/contentshould appear after the classic scripts.Some locales may not have these strings translated yet and will render in English — that's the expected fallback. Any locale whose language pack includes the chunks will render translated without any configuration.
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.6
Used for: Implementation, tests, and PR description were authored with AI assistance; reviewed and tested by me.