Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
cfd6f50
Merge branch 'trunk' into add/script-module-translations
manzoorwanijk Apr 16, 2026
c374a89
Merge branch 'trunk' into add/script-module-translations
westonruter Apr 18, 2026
1e9d62f
Merge branch 'trunk' into add/script-module-translations
manzoorwanijk Apr 20, 2026
1eaaa51
I18N: Auto-detect script module translations.
manzoorwanijk Apr 21, 2026
63e70d4
Tests: Update assertion for auto-detection in dependencies test
manzoorwanijk Apr 21, 2026
07fbf49
Merge remote-tracking branch 'upstream/trunk' into add/script-module-…
manzoorwanijk Apr 21, 2026
f8bd260
Script Modules: Merge translation overrides into \$registered.
manzoorwanijk Apr 22, 2026
8c14a86
I18N: Namespace script module translation inline script IDs.
manzoorwanijk Apr 22, 2026
e9345df
Script Modules: Drop redundant translations_ prefix from nested path …
manzoorwanijk Apr 22, 2026
d3f1691
Merge branch 'trunk' into add/script-module-translations
manzoorwanijk Apr 22, 2026
fc8c957
Script Modules: Hoist locale-data JS heredoc out of the translations …
manzoorwanijk Apr 22, 2026
e112a15
Script Modules: Print wp-i18n just-in-time before translation inline …
manzoorwanijk Apr 22, 2026
e5b32e7
Script Modules: Simplify translation override lookup with null-coales…
manzoorwanijk Apr 22, 2026
f4d4e69
Script Modules: Guard setLocaleData call against missing wp.i18n inst…
manzoorwanijk Apr 22, 2026
37a6b84
Add safety flags to JSON encode
manzoorwanijk Apr 22, 2026
0cd71d3
Reduce string literal duplication
manzoorwanijk Apr 22, 2026
5b592e7
Script Modules: Add failure messages to multi-assertion translation t…
manzoorwanijk Apr 23, 2026
3a0a32d
Script Modules: Force-print wp-i18n before translation inline scripts.
manzoorwanijk Apr 23, 2026
11c37c2
Merge branch 'trunk' into add/script-module-translations
manzoorwanijk Apr 23, 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
117 changes: 117 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,23 @@
* 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',
Comment thread
westonruter marked this conversation as resolved.
* translations?: array{ textdomain: string, path: string },
* }
*/
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 @@ -328,6 +338,89 @@ public function deregister( string $id ) {
unset( $this->registered[ $id ] );
}

/**
* Overrides the text domain and path used to load translations for a script module.
*
* This is only needed for modules whose text domain differs from 'default'
* or whose translation files live outside the standard locations, for
* example plugin modules that register their own text domain. Translations
* for modules that use the default domain are loaded automatically by
* {@see WP_Script_Modules::print_script_module_translations()}.
*
* @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 ] ) ) {
Comment thread
manzoorwanijk marked this conversation as resolved.
return false;
}

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

return true;
}

/**
* Prints translations for all enqueued script modules.
*
* 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.
*
* Auto-detects the text domain and translation path for each module from
* its source URL. Modules whose text domain or path differs from the
* defaults can opt into a specific domain/path via
* {@see WP_Script_Modules::set_translations()}.
*
* @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 );

$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;

foreach ( $module_ids as $id ) {
$domain = $this->registered[ $id ]['translations']['textdomain'] ?? 'default';
$path = $this->registered[ $id ]['translations']['path'] ?? '';

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

if ( ! $json_translations ) {
continue;
}

$output = sprintf(
'( %s )( %s, %s );',
$set_locale_data_js_function,
wp_json_encode( $domain, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
$json_translations
);
$script_id = "wp-script-module-translation-data-{$id}";
$output .= "\n//# sourceURL=" . rawurlencode( $script_id );

// Ensure wp-i18n is printed; the inline script below relies on wp.i18n.setLocaleData().
if ( ! wp_script_is( 'wp-i18n', 'done' ) ) {
wp_scripts()->do_items( array( 'wp-i18n' ) );
}

wp_print_inline_script_tag( $output, array( 'id' => $script_id ) );
}
}

/**
* Adds the hooks to print the import map, enqueued script modules and script
* module preloads.
Expand Down Expand Up @@ -359,6 +452,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 +733,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 +755,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 +944,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
21 changes: 21 additions & 0 deletions src/wp-includes/script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,27 @@ function wp_deregister_script_module( string $id ) {
wp_script_modules()->deregister( $id );
}

/**
* Overrides the text domain and path used to load translations for a script module.
*
* Translations for script modules are loaded automatically from the default
* text domain and language directory. Use this function only when a module's
* text domain differs from `'default'` or when translation files live outside
* the standard location, for example plugin modules using their own text domain.
*
* @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 registered, false if the module is not registered.
*/
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
Loading
Loading