Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e23d4f2
I18N: Add translation support for script modules.
manzoorwanijk Apr 10, 2026
7057417
Tests: Add end-to-end coverage for script module translation printing.
manzoorwanijk Apr 10, 2026
40bbbf2
Script Loader: Restore original position of print_enqueued_script_mod…
manzoorwanijk Apr 11, 2026
e23c08a
Docs: Update `@since` tags to 7.0.0 for script module translation APIs.
manzoorwanijk Apr 11, 2026
1a0d9ca
Simplify by using null coalescing operator
manzoorwanijk Apr 11, 2026
05c6016
Use ES6 and PHP 7.4 syntax
manzoorwanijk Apr 11, 2026
de8090f
Make PHPCS happy
manzoorwanijk Apr 11, 2026
44a1f56
Revert the change to heredoc indentation
manzoorwanijk Apr 11, 2026
eb78a18
Tests: Align null return type for get_registered_src().
manzoorwanijk Apr 11, 2026
cee8f0c
Apply suggestions from code review
manzoorwanijk Apr 13, 2026
c3380cc
I18N: Extract shared helper for loading script translation files.
manzoorwanijk Apr 13, 2026
2be0c57
Merge branch 'trunk' into add/script-module-translations
manzoorwanijk Apr 13, 2026
28cfe1c
Use type-hints
manzoorwanijk Apr 13, 2026
1354cb0
I18N: Reuse load_script_textdomain_relative_path filter for script mo…
manzoorwanijk Apr 13, 2026
918490c
Merge branch 'trunk' into add/script-module-translations
manzoorwanijk Apr 13, 2026
38dd31a
Rename get_registered_src() to get_registered()
westonruter Apr 14, 2026
7aad84d
Use get_echo() in tests
westonruter Apr 15, 2026
a146cf8
Add covers for WP_Script_Modules::set_translations()
westonruter Apr 15, 2026
555f309
Add assertions for new is_module arg for load_script_textdomain_relat…
westonruter Apr 15, 2026
d360841
Use HTML Tag Processor for inspecting output
westonruter Apr 15, 2026
db4106a
Fix variable name to use locale instead of local
westonruter Apr 15, 2026
da7e67a
Move load_script_module_textdomain() to immediately follow load_scrip…
westonruter Apr 15, 2026
7d734d9
Add covers for load_script_module_textdomain()
westonruter Apr 15, 2026
33b7f3b
Merge branch 'trunk' into add/script-module-translations
westonruter Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/wp-includes/class-wp-script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,22 @@
* Core class used to register script modules.
*
* @since 6.5.0
*
* @phpstan-type ScriptModule array{
* src: string,
* version: string|false|null,
* dependencies: array<int, array{ id: string, import: 'static'|'dynamic' }>,
* in_footer: bool,
* fetchpriority: 'auto'|'low'|'high',
* }
*/
class WP_Script_Modules {
/**
* Holds the registered script modules, keyed by script module identifier.
*
* @since 6.5.0
* @var array<string, array<string, mixed>>
* @phpstan-var array<string, ScriptModule>
*/
private $registered = array();

Expand Down Expand Up @@ -81,6 +90,17 @@ class WP_Script_Modules {
*/
private $modules_with_missing_dependencies = array();

/**
* Holds translation data for script modules, keyed by script module identifier.
*
* Each entry contains 'domain' and 'path' keys for the text domain
* and the path to translation files respectively.
*
* @since 7.0.0
* @var array<string, array{domain: string, path: string}>
*/
private array $translations = array();

/**
* Registers the script module if no script module with that script module
* identifier has already been registered.
Expand Down Expand Up @@ -328,6 +348,80 @@ public function deregister( string $id ) {
unset( $this->registered[ $id ] );
}

/**
* Sets translated strings for a script module.
*
* Works similar to {@see WP_Scripts::set_translations()} but for script modules.
* The translations will be loaded and output as inline scripts before
* the script modules are printed, calling `wp.i18n.setLocaleData()`.
*
* @since 7.0.0
*
* @param string $id The identifier of the script module.
* @param string $domain Optional. Text domain. Default 'default'.
* @param string $path Optional. The full file path to the directory containing translation files.
* @return bool True if the text domain was registered, false if the module is not registered.
*/
public function set_translations( string $id, string $domain = 'default', string $path = '' ): bool {
if ( ! isset( $this->registered[ $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.

Suggested change
if ( ! isset( $this->registered[ $id ] ) ) {
if ( null === get_registered( $id ) ) {

Better to use new function here

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.

I assume you mean to use a method call:

Suggested change
if ( ! isset( $this->registered[ $id ] ) ) {
if ( null === $this->get_registered( $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.

Yes ☝️

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.

Simpler yet:

Suggested change
if ( ! isset( $this->registered[ $id ] ) ) {
if ( ! $this->get_registered( $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.

I see many other places where it use if ( ! isset( $this->registered[ $id ] ) ) {, good to update that one also or we can do that in follow-up PR

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 would lean towards updating all of those in a follow-up

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.

Sounds good to me.

return false;
}

$this->translations[ $id ] = array(
'domain' => $domain,
'path' => $path,
);

return true;
}

/**
* Prints translations for all enqueued script modules that have translations set.
*
* Outputs inline `<script>` tags that call `wp.i18n.setLocaleData()` with
* the translated strings for each script module. This must run before
* the script modules execute.
*
* @since 7.0.0
*/
public function print_script_module_translations(): void {
// Collect all module IDs that will be on the page (enqueued + their dependencies).
$module_ids = $this->get_sorted_dependencies( $this->queue );

foreach ( $module_ids as $id ) {
if ( ! isset( $this->translations[ $id ] ) ) {
continue;
}

$domain = $this->translations[ $id ]['domain'];
$path = $this->translations[ $id ]['path'];

$json_translations = load_script_module_textdomain( $id, $domain, $path );

if ( ! $json_translations ) {
continue;
}

$set_locale_data_js_function = <<<JS
( domain, translations ) => {
const localeData = translations.locale_data[ domain ] || translations.locale_data.messages;
localeData[""].domain = domain;
wp.i18n.setLocaleData( localeData, domain );
}
JS;

$output = sprintf(
'( %s )( %s, %s );',
$set_locale_data_js_function,
wp_json_encode( $domain ),
$json_translations
);
$source_url = rawurlencode( "{$id}-js-module-translations" );
$output .= "\n//# sourceURL={$source_url}";
wp_print_inline_script_tag( $output, array( 'id' => "{$id}-js-module-translations" ) );
}
}

/**
* Adds the hooks to print the import map, enqueued script modules and script
* module preloads.
Expand Down Expand Up @@ -359,6 +453,15 @@ public function add_hooks() {
add_action( 'admin_print_footer_scripts', array( $this, 'print_enqueued_script_modules' ) );
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_preloads' ) );

/*
* Print translations after classic scripts like wp-i18n are loaded (at
* priority 10 via _wp_footer_scripts), but before the script modules
* execute. Script modules with type="module" are deferred by default,
* so inline translation scripts at priority 11 will execute before them.
*/
add_action( 'wp_footer', array( $this, 'print_script_module_translations' ), 21 );
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_translations' ), 11 );

add_action( 'wp_footer', array( $this, 'print_script_module_data' ) );
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_data' ) );
add_action( 'wp_footer', array( $this, 'print_a11y_script_module_html' ), 20 );
Expand Down Expand Up @@ -631,6 +734,7 @@ private function get_import_map(): array {
* @since 6.5.0
*
* @return array<string, array<string, mixed>> Script modules marked for enqueue, keyed by script module identifier.
* @phpstan-return array<string, ScriptModule>
*/
private function get_marked_for_enqueue(): array {
return wp_array_slice_assoc(
Expand All @@ -652,6 +756,7 @@ private function get_marked_for_enqueue(): array {
* @param string[] $import_types Optional. Import types of dependencies to retrieve: 'static', 'dynamic', or both.
* Default is both.
* @return array<string, array<string, mixed>> List of dependencies, keyed by script module identifier.
* @phpstan-return array<string, ScriptModule>
*/
private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ): array {
$all_dependencies = array();
Expand Down Expand Up @@ -840,6 +945,19 @@ private function sort_item_dependencies( string $id, array $import_types, array
return true;
}

/**
* Gets the data for a registered script module.
*
* @since 7.0.0
*
* @param string $id The script module identifier.
* @return array|null The script module data, or null if not registered.
* @phpstan-return ScriptModule|null
*/
public function get_registered( string $id ): ?array {
return $this->registered[ $id ] ?? null;
}

/**
* Gets the versioned URL for a script module src.
*
Expand Down
80 changes: 66 additions & 14 deletions src/wp-includes/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -1134,24 +1134,80 @@ function load_child_theme_textdomain( $domain, $path = false ) {
*
* @see WP_Scripts::set_translations()
*
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
*
* @param string $handle Name of the script to register a translation domain to.
* @param string $domain Optional. Text domain. Default 'default'.
* @param string $path Optional. The full file path to the directory containing translation files.
* @return string|false The translated strings in JSON encoding on success,
* false if the script textdomain could not be loaded.
*/
function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
/** @var WP_Textdomain_Registry $wp_textdomain_registry */
global $wp_textdomain_registry;

$wp_scripts = wp_scripts();

if ( ! isset( $wp_scripts->registered[ $handle ] ) ) {
return false;
}

$src = $wp_scripts->registered[ $handle ]->src;

if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && str_starts_with( $src, $wp_scripts->content_url ) ) ) {
$src = $wp_scripts->base_url . $src;
}

return _load_script_textdomain_from_src( $handle, $src, $domain, $path, false );
}

/**
* Loads the translation data for a given script module ID and text domain.
*
* Works like {@see load_script_textdomain()} but for script modules registered
* via {@see wp_register_script_module()}.
*
* @since 7.0.0
*
* @param string $id The script module identifier.
* @param string $domain Optional. Text domain. Default 'default'.
* @param string $path Optional. The full file path to the directory containing translation files.
* @return string|false The JSON-encoded translated strings for the given script module and text domain.
* False if there are none.
*/
function load_script_module_textdomain( string $id, string $domain = 'default', string $path = '' ) {
$module = wp_script_modules()->get_registered( $id );
if ( null === $module ) {
return false;
}
$src = $module['src'];

// Ensure src is an absolute URL for path resolution.
if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
$src = site_url( $src );
}

return _load_script_textdomain_from_src( $id, $src, $domain, $path, true );
}

/**
* Resolves and loads the translation JSON file for a given script or script module source URL.
*
* This is a shared implementation used by {@see load_script_textdomain()} and
* {@see load_script_module_textdomain()} to avoid duplicating the path
* resolution and file lookup logic.
*
* @since 7.0.0
* @access private
*
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
*
* @param string $handle Name of the script or script module identifier to register a translation domain to.
* @param string $src Absolute source URL of the script or script module.
* @param string $domain Text domain.
* @param string $path The full file path to the directory containing translation files,
* or an empty string to use the default path from the text domain registry.
* @param bool $is_module Whether the source belongs to a script module (true) or a classic script (false).
* @return string|false The JSON-encoded translated strings on success, false otherwise.
*/
function _load_script_textdomain_from_src( string $handle, string $src, string $domain, string $path, bool $is_module ) {
global $wp_textdomain_registry;

$locale = determine_locale();

if ( ! $path ) {
Expand All @@ -1172,12 +1228,6 @@ function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
}
}

$src = $wp_scripts->registered[ $handle ]->src;

if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && str_starts_with( $src, $wp_scripts->content_url ) ) ) {
$src = $wp_scripts->base_url . $src;
}

$relative = false;
$languages_path = WP_LANG_DIR;

Expand Down Expand Up @@ -1245,11 +1295,13 @@ function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
* Filters the relative path of scripts used for finding translation files.
*
* @since 5.0.2
* @since 7.0.0 The `$is_module` parameter was added.
*
* @param string|false $relative The relative path of the script. False if it could not be determined.
* @param string $src The full source URL of the script.
* @param string|false $relative The relative path of the script. False if it could not be determined.
* @param string $src The full source URL of the script.
* @param bool $is_module Whether the source belongs to a script module (true) or a classic script (false).
*/
$relative = apply_filters( 'load_script_textdomain_relative_path', $relative, $src );
$relative = apply_filters( 'load_script_textdomain_relative_path', $relative, $src, $is_module );

// If the source is not from WP.
if ( false === $relative ) {
Expand Down
24 changes: 24 additions & 0 deletions src/wp-includes/script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ function wp_deregister_script_module( string $id ) {
wp_script_modules()->deregister( $id );
}

/**
* Sets translated strings for a script module.
*
* Works similar to {@see wp_set_script_translations()} but for script modules
* registered via {@see wp_register_script_module()}.
*
* @since 7.0.0
*
* @see WP_Script_Modules::set_translations()
*
* @param string $id The identifier of the script module.
* @param string $domain Optional. Text domain. Default 'default'.
* @param string $path Optional. The full file path to the directory containing translation files.
* @return bool True if the text domain was successfully localized, false otherwise.
*/
function wp_set_script_module_translations( string $id, string $domain = 'default', string $path = '' ): bool {
return wp_script_modules()->set_translations( $id, $domain, $path );
}

/**
* Registers all the default WordPress Script Modules.
*
Expand Down Expand Up @@ -197,6 +216,11 @@ function wp_default_script_modules() {
$path = includes_url( "js/dist/script-modules/{$file_name}" );
$module_deps = $script_module_data['module_dependencies'] ?? array();
wp_register_script_module( $script_module_id, $path, $module_deps, $script_module_data['version'], $args );

// Set up translations for script modules that use wp-i18n.
if ( isset( $script_module_data['dependencies'] ) && in_array( 'wp-i18n', $script_module_data['dependencies'], true ) ) {
wp_set_script_module_translations( $script_module_id, 'default' );
}
}

wp_register_script_module(
Expand Down
Loading
Loading