Skip to content

I18N: Add translation support for script modules#11543

Open
manzoorwanijk wants to merge 43 commits intoWordPress:trunkfrom
manzoorwanijk:add/script-module-translations
Open

I18N: Add translation support for script modules#11543
manzoorwanijk wants to merge 43 commits intoWordPress:trunkfrom
manzoorwanijk:add/script-module-translations

Conversation

@manzoorwanijk
Copy link
Copy Markdown
Member

@manzoorwanijk manzoorwanijk commented Apr 10, 2026

Summary

Adds translation support for script modules (WP_Script_Modules), so strings using __() from @wordpress/i18n inside 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)}.json chunk for each, and emits an inline <script> that calls wp.i18n.setLocaleData() with the loaded translations.
  • Hooked at priority 11 on admin_print_footer_scripts (after classic scripts load wp-i18n at priority 10) and priority 21 on wp_footer (after wp_print_footer_scripts at priority 20), so the inline data is available before deferred ES modules execute.
  • load_script_module_textdomain( $id, $domain, $path ) — new function in l10n.php, mirrors load_script_textdomain() but reads the module's source URL from WP_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.
  • The existing load_script_textdomain_relative_path filter 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 underlying WP_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.php covering:

  • Auto-detection: print_script_module_translations() loads and prints translations for an enqueued module without any explicit registration.
  • Dependency handling: translations are also printed for static/dynamic dependencies of enqueued modules.
  • Override path: set_translations() correctly overrides the auto-detected text domain.
  • The is_module argument passed to the load_script_textdomain_relative_path filter.

Tests use the pre_load_script_translations filter 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

npm run test:php -- --group script-modules

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:

  1. Install and activate Spanish:

    Via WP-CLI:

    wp core language install es_ES
    wp site switch-language es_ES

    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.

  2. Visit Settings > Connectors (/wp-admin/options-connectors.php).

  3. 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.

  4. View page source and search for wp-script-module-translation-data- — inline <script> tags with IDs like wp-script-module-translation-data-wp/routes/connectors-home/content should 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.

Before After
Screenshot 2026-04-21 at 7 16 11 PM Screenshot 2026-04-21 at 7 13 15 PM

@github-actions
Copy link
Copy Markdown

Hi @manzoorwanijk! 👋

Thank you for your contribution to WordPress! 💖

It looks like this is your first pull request to wordpress-develop. Here are a few things to be aware of that may help you out!

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 WordPress Project

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 10, 2026

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 props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props manzoorwanijk, westonruter, mukesh27, jsnajdr, jonsurrell, peterwilsoncc.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions
Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@mukeshpanchal27
Copy link
Copy Markdown
Member

Thanks for the PR! Can you expose which AI tool do you use in PR?

## Use of AI Tools

<!--
You are free to use artificial intelligence (AI) tooling to contribute, but you must disclose what tooling you are using and to what extent a pull request has been authored by AI. It is your responsibility to review and take responsibility for what AI generates. See the WordPress AI Guidelines: <https://make.wordpress.org/ai/handbook/ai-guidelines/>.

Example disclosure:

AI assistance: Yes
Tool(s): GitHub Copilot, ChatGPT
Model(s): GPT-5.1
Used for: Initial code skeleton and test suggestions; final implementation and tests were reviewed and edited by me.
-->

@manzoorwanijk
Copy link
Copy Markdown
Member Author

Thanks for the PR! Can you expose which AI tool do you use in PR?

Updated. Thanks

manzoorwanijk added a commit to WordPress/gutenberg that referenced this pull request Apr 10, 2026
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
@manzoorwanijk manzoorwanijk force-pushed the add/script-module-translations branch 2 times, most recently from e68fb27 to 0e73045 Compare April 11, 2026 23:58
manzoorwanijk and others added 9 commits April 12, 2026 05:45
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.
@manzoorwanijk manzoorwanijk force-pushed the add/script-module-translations branch from 0e73045 to eb78a18 Compare April 12, 2026 00:15
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/l10n.php Outdated
Comment thread src/wp-includes/l10n.php
Comment thread src/wp-includes/l10n.php Outdated
Comment thread src/wp-includes/l10n.php Outdated
@westonruter
Copy link
Copy Markdown
Member

I can confirm the changes currently fix the issue at least for some of the strings.

Before:

Screenshot 2026-04-12 at 15 19 57

After:

Screenshot 2026-04-12 at 15 20 41

I suppose the other strings are coming from the textdomain of the connector plugin and aren't provided yet? Or they haven't been translated for Spanish yet period?

manzoorwanijk and others added 2 commits April 13, 2026 09:50
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.
@manzoorwanijk
Copy link
Copy Markdown
Member Author

I can confirm the changes currently fix the issue at least for some of the strings.

Thank you for testing this.

I suppose the other strings are coming from the textdomain of the connector plugin and aren't provided yet? Or they haven't been translated for Spanish yet period?

The remaining untranslated strings come from a couple of different places:

  1. Other script modules. Strings like the connector descriptions and the "Install the AI plugin" card come from additional script modules (@wordpress/connectors and @wordpress/boot), not the wp/routes/connectors-home/content module. The test mu-plugin's filter only intercepts translation loading for that one module ID, so translations for other modules pass through and try to load from real language pack JSON files - which don't exist yet for trunk strings. Once the companion Gutenberg PR (#77214) lands, the generated code will call wp_set_script_module_translations() for those modules too, and they'll pick up translations from real language packs the same way.

  2. PHP-side strings passed via script_module_data. A few strings (e.g., the connector descriptions) are rendered PHP-side in connectors.php and passed to the module as data - those already use __() and work if translations exist in the language pack. For trunk/alpha the newer strings probably aren't in the Spanish language pack yet.

You can confirm (1) by extending the mu-plugin's filter to intercept additional module IDs like @wordpress/connectors with the same mock structure - those strings should then render in Spanish too.

manzoorwanijk added a commit to WordPress/gutenberg that referenced this pull request Apr 13, 2026
Comment thread src/wp-includes/l10n.php Outdated
Comment thread src/wp-includes/l10n.php Outdated
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.
@manzoorwanijk
Copy link
Copy Markdown
Member Author

@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.

Copy link
Copy Markdown
Member

@jsnajdr jsnajdr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works great for me and fixes the pressing issue in WP 7.0. :shipit:

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.

Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php
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.
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
);
$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}" ) );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's possible at this point that the wp-i18n script 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 __().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wp-i18n script 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-i18n dependency. If you want a localized script module, it needs to have the dependencies in order.
  • make the wp.i18n.setLocaleData in the inline script fail-safe. Check the presence of wp.i18n before 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.

Copy link
Copy Markdown
Member

@westonruter westonruter Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reality is that the script module here does have a dependency on the wp-i18n classic 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-i18n script 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in 3a0a32d

Comment thread src/wp-includes/class-wp-script-modules.php Outdated
@sirreal
Copy link
Copy Markdown
Member

sirreal commented Apr 22, 2026

Does this address https://core.trac.wordpress.org/ticket/60234?

Comment thread src/wp-includes/class-wp-script-modules.php Outdated
@sirreal
Copy link
Copy Markdown
Member

sirreal commented Apr 22, 2026

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.

  • I think it would be great for i18n APIs to be exposed from a script module.
  • I'm very excited to see what @jsnajdr has mentioned about dynamic loading.
  • I'd love to see classic scripts change to lazy or pull style configuration like is discussed on this ticket, analogous to what script modules do. The data is made available for scripts to use on demand without imperatively evaluating inline scripts. i18n script seems like a great candidate to leverage this and it would address the "fail-safe" idea mentioned above.

Thanks for your work here @manzoorwanijk 👍

Co-authored-by: Jon Surrell <sirreal@users.noreply.github.com>
Comment thread src/wp-includes/class-wp-script-modules.php Outdated
Co-authored-by: Weston Ruter <westonruter@gmail.com>
Comment thread tests/phpunit/tests/script-modules/wpScriptModules.php Outdated
@westonruter
Copy link
Copy Markdown
Member

Shall I commit this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants