Skip to content

Commit e23d4f2

Browse files
committed
I18N: Add translation support for script modules.
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.
1 parent e28a4a4 commit e23d4f2

4 files changed

Lines changed: 390 additions & 1 deletion

File tree

src/wp-includes/class-wp-script-modules.php

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ class WP_Script_Modules {
8181
*/
8282
private $modules_with_missing_dependencies = array();
8383

84+
/**
85+
* Holds translation data for script modules, keyed by script module identifier.
86+
*
87+
* Each entry contains 'domain' and 'path' keys for the text domain
88+
* and the path to translation files respectively.
89+
*
90+
* @since x.y.z
91+
* @var array<string, array{domain: string, path: string}>
92+
*/
93+
private $translations = array();
94+
8495
/**
8596
* Registers the script module if no script module with that script module
8697
* identifier has already been registered.
@@ -328,6 +339,74 @@ public function deregister( string $id ) {
328339
unset( $this->registered[ $id ] );
329340
}
330341

342+
/**
343+
* Sets translated strings for a script module.
344+
*
345+
* Works similar to {@see WP_Scripts::set_translations()} but for script modules.
346+
* The translations will be loaded and output as inline scripts before
347+
* the script modules are printed, calling `wp.i18n.setLocaleData()`.
348+
*
349+
* @since x.y.z
350+
*
351+
* @param string $id The identifier of the script module.
352+
* @param string $domain Optional. Text domain. Default 'default'.
353+
* @param string $path Optional. The full file path to the directory containing translation files.
354+
* @return bool True if the text domain was registered, false if the module is not registered.
355+
*/
356+
public function set_translations( string $id, string $domain = 'default', string $path = '' ): bool {
357+
if ( ! isset( $this->registered[ $id ] ) ) {
358+
return false;
359+
}
360+
361+
$this->translations[ $id ] = array(
362+
'domain' => $domain,
363+
'path' => $path,
364+
);
365+
366+
return true;
367+
}
368+
369+
/**
370+
* Prints translations for all enqueued script modules that have translations set.
371+
*
372+
* Outputs inline `<script>` tags that call `wp.i18n.setLocaleData()` with
373+
* the translated strings for each script module. This must run before
374+
* the script modules execute.
375+
*
376+
* @since x.y.z
377+
*/
378+
public function print_script_module_translations(): void {
379+
// Collect all module IDs that will be on the page (enqueued + their dependencies).
380+
$module_ids = $this->get_sorted_dependencies( $this->queue );
381+
382+
foreach ( $module_ids as $id ) {
383+
if ( ! isset( $this->translations[ $id ] ) ) {
384+
continue;
385+
}
386+
387+
$domain = $this->translations[ $id ]['domain'];
388+
$path = $this->translations[ $id ]['path'];
389+
390+
$json_translations = load_script_module_textdomain( $id, $domain, $path );
391+
392+
if ( ! $json_translations ) {
393+
continue;
394+
}
395+
396+
$output = <<<JS
397+
( function( domain, translations ) {
398+
var localeData = translations.locale_data[ domain ] || translations.locale_data.messages;
399+
localeData[""].domain = domain;
400+
wp.i18n.setLocaleData( localeData, domain );
401+
} )( "{$domain}", {$json_translations} );
402+
JS;
403+
404+
$source_url = rawurlencode( "{$id}-js-module-translations" );
405+
$output .= "\n//# sourceURL={$source_url}";
406+
wp_print_inline_script_tag( $output, array( 'id' => "{$id}-js-module-translations" ) );
407+
}
408+
}
409+
331410
/**
332411
* Adds the hooks to print the import map, enqueued script modules and script
333412
* module preloads.
@@ -352,13 +431,23 @@ public function add_hooks() {
352431
*/
353432
add_action( 'wp_head', array( $this, 'print_head_enqueued_script_modules' ) );
354433
}
355-
add_action( 'wp_footer', array( $this, 'print_enqueued_script_modules' ) );
356434
add_action( $position, array( $this, 'print_script_module_preloads' ) );
357435

358436
add_action( 'admin_print_footer_scripts', array( $this, 'print_import_map' ), 9 );
437+
438+
add_action( 'wp_footer', array( $this, 'print_enqueued_script_modules' ) );
359439
add_action( 'admin_print_footer_scripts', array( $this, 'print_enqueued_script_modules' ) );
360440
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_preloads' ) );
361441

442+
/*
443+
* Print translations after classic scripts like wp-i18n are loaded (at
444+
* priority 10 via _wp_footer_scripts), but before the script modules
445+
* execute. Script modules with type="module" are deferred by default,
446+
* so inline translation scripts at priority 11 will execute before them.
447+
*/
448+
add_action( 'wp_footer', array( $this, 'print_script_module_translations' ), 21 );
449+
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_translations' ), 11 );
450+
362451
add_action( 'wp_footer', array( $this, 'print_script_module_data' ) );
363452
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_data' ) );
364453
add_action( 'wp_footer', array( $this, 'print_a11y_script_module_html' ), 20 );
@@ -840,6 +929,26 @@ private function sort_item_dependencies( string $id, array $import_types, array
840929
return true;
841930
}
842931

932+
/**
933+
* Gets the raw source URL for a registered script module.
934+
*
935+
* Returns the source URL without version query string appended.
936+
* This is used by {@see load_script_module_textdomain()} to determine
937+
* the relative path for loading translation files.
938+
*
939+
* @since x.y.z
940+
*
941+
* @param string $id The script module identifier.
942+
* @return string|false The script module source URL, or false if not registered.
943+
*/
944+
public function get_registered_src( string $id ) {
945+
if ( ! isset( $this->registered[ $id ] ) ) {
946+
return false;
947+
}
948+
949+
return $this->registered[ $id ]['src'];
950+
}
951+
843952
/**
844953
* Gets the versioned URL for a script module src.
845954
*

src/wp-includes/l10n.php

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,151 @@ function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
12801280
return load_script_translations( false, $handle, $domain );
12811281
}
12821282

1283+
/**
1284+
* Loads the translation data for a given script module ID and text domain.
1285+
*
1286+
* Works like {@see load_script_textdomain()} but for script modules registered
1287+
* via {@see wp_register_script_module()}.
1288+
*
1289+
* @since x.y.z
1290+
*
1291+
* @param string $id The script module identifier.
1292+
* @param string $domain Optional. Text domain. Default 'default'.
1293+
* @param string $path Optional. The full file path to the directory containing translation files.
1294+
* @return string|false The JSON-encoded translated strings for the given script module and text domain.
1295+
* False if there are none.
1296+
*/
1297+
function load_script_module_textdomain( $id, $domain = 'default', $path = '' ) {
1298+
/** @var WP_Textdomain_Registry $wp_textdomain_registry */
1299+
global $wp_textdomain_registry;
1300+
1301+
$src = wp_script_modules()->get_registered_src( $id );
1302+
1303+
if ( false === $src ) {
1304+
return false;
1305+
}
1306+
1307+
$locale = determine_locale();
1308+
1309+
if ( ! $path ) {
1310+
$path = $wp_textdomain_registry->get( $domain, $locale );
1311+
}
1312+
1313+
$path = untrailingslashit( $path );
1314+
1315+
// If a path was given and the handle file exists simply return it.
1316+
$file_base = 'default' === $domain ? $locale : $domain . '-' . $locale;
1317+
$handle_filename = $file_base . '-' . $id . '.json';
1318+
1319+
if ( $path ) {
1320+
$translations = load_script_translations( $path . '/' . $handle_filename, $id, $domain );
1321+
1322+
if ( $translations ) {
1323+
return $translations;
1324+
}
1325+
}
1326+
1327+
// Ensure src is an absolute URL for path resolution.
1328+
if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
1329+
$src = site_url( $src );
1330+
}
1331+
1332+
$relative = false;
1333+
$languages_path = WP_LANG_DIR;
1334+
1335+
$src_url = wp_parse_url( $src );
1336+
$content_url = wp_parse_url( content_url() );
1337+
$plugins_url = wp_parse_url( plugins_url() );
1338+
$site_url = wp_parse_url( site_url() );
1339+
$theme_root = get_theme_root();
1340+
1341+
// If the host is the same or it's a relative URL.
1342+
if (
1343+
( ! isset( $content_url['path'] ) || str_starts_with( $src_url['path'], $content_url['path'] ) ) &&
1344+
( ! isset( $src_url['host'] ) || ! isset( $content_url['host'] ) || $src_url['host'] === $content_url['host'] )
1345+
) {
1346+
// Make the src relative the specific plugin or theme.
1347+
if ( isset( $content_url['path'] ) ) {
1348+
$relative = substr( $src_url['path'], strlen( $content_url['path'] ) );
1349+
} else {
1350+
$relative = $src_url['path'];
1351+
}
1352+
$relative = trim( $relative, '/' );
1353+
$relative = explode( '/', $relative );
1354+
1355+
$theme_dir = array_slice( explode( '/', $theme_root ), -1 );
1356+
$dirname = $theme_dir[0] === $relative[0] ? 'themes' : 'plugins';
1357+
1358+
$languages_path = WP_LANG_DIR . '/' . $dirname;
1359+
1360+
$relative = array_slice( $relative, 2 ); // Remove plugins/<plugin name> or themes/<theme name>.
1361+
$relative = implode( '/', $relative );
1362+
} elseif (
1363+
( ! isset( $plugins_url['path'] ) || str_starts_with( $src_url['path'], $plugins_url['path'] ) ) &&
1364+
( ! isset( $src_url['host'] ) || ! isset( $plugins_url['host'] ) || $src_url['host'] === $plugins_url['host'] )
1365+
) {
1366+
// Make the src relative the specific plugin.
1367+
if ( isset( $plugins_url['path'] ) ) {
1368+
$relative = substr( $src_url['path'], strlen( $plugins_url['path'] ) );
1369+
} else {
1370+
$relative = $src_url['path'];
1371+
}
1372+
$relative = trim( $relative, '/' );
1373+
$relative = explode( '/', $relative );
1374+
1375+
$languages_path = WP_LANG_DIR . '/plugins';
1376+
1377+
$relative = array_slice( $relative, 1 ); // Remove <plugin name>.
1378+
$relative = implode( '/', $relative );
1379+
} elseif ( ! isset( $src_url['host'] ) || ! isset( $site_url['host'] ) || $src_url['host'] === $site_url['host'] ) {
1380+
if ( ! isset( $site_url['path'] ) ) {
1381+
$relative = trim( $src_url['path'], '/' );
1382+
} elseif ( str_starts_with( $src_url['path'], trailingslashit( $site_url['path'] ) ) ) {
1383+
// Make the src relative to the WP root.
1384+
$relative = substr( $src_url['path'], strlen( $site_url['path'] ) );
1385+
$relative = trim( $relative, '/' );
1386+
}
1387+
}
1388+
1389+
/**
1390+
* Filters the relative path of script module source used for finding translation files.
1391+
*
1392+
* @since x.y.z
1393+
*
1394+
* @param string|false $relative The relative path of the script module source. False if it could not be determined.
1395+
* @param string $src The full source URL of the script module.
1396+
*/
1397+
$relative = apply_filters( 'load_script_module_textdomain_relative_path', $relative, $src );
1398+
1399+
// If the source is not from WP.
1400+
if ( false === $relative ) {
1401+
return load_script_translations( false, $id, $domain );
1402+
}
1403+
1404+
// Translations are always based on the unminified filename.
1405+
if ( str_ends_with( $relative, '.min.js' ) ) {
1406+
$relative = substr( $relative, 0, -7 ) . '.js';
1407+
}
1408+
1409+
$md5_filename = $file_base . '-' . md5( $relative ) . '.json';
1410+
1411+
if ( $path ) {
1412+
$translations = load_script_translations( $path . '/' . $md5_filename, $id, $domain );
1413+
1414+
if ( $translations ) {
1415+
return $translations;
1416+
}
1417+
}
1418+
1419+
$translations = load_script_translations( $languages_path . '/' . $md5_filename, $id, $domain );
1420+
1421+
if ( $translations ) {
1422+
return $translations;
1423+
}
1424+
1425+
return load_script_translations( false, $id, $domain );
1426+
}
1427+
12831428
/**
12841429
* Loads the translation data for the given script handle and text domain.
12851430
*

src/wp-includes/script-modules.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ function wp_deregister_script_module( string $id ) {
138138
wp_script_modules()->deregister( $id );
139139
}
140140

141+
/**
142+
* Sets translated strings for a script module.
143+
*
144+
* Works similar to {@see wp_set_script_translations()} but for script modules
145+
* registered via {@see wp_register_script_module()}.
146+
*
147+
* @since x.y.z
148+
*
149+
* @see WP_Script_Modules::set_translations()
150+
*
151+
* @param string $id The identifier of the script module.
152+
* @param string $domain Optional. Text domain. Default 'default'.
153+
* @param string $path Optional. The full file path to the directory containing translation files.
154+
* @return bool True if the text domain was successfully localized, false otherwise.
155+
*/
156+
function wp_set_script_module_translations( string $id, string $domain = 'default', string $path = '' ): bool {
157+
return wp_script_modules()->set_translations( $id, $domain, $path );
158+
}
159+
141160
/**
142161
* Registers all the default WordPress Script Modules.
143162
*
@@ -197,6 +216,11 @@ function wp_default_script_modules() {
197216
$path = includes_url( "js/dist/script-modules/{$file_name}" );
198217
$module_deps = $script_module_data['module_dependencies'] ?? array();
199218
wp_register_script_module( $script_module_id, $path, $module_deps, $script_module_data['version'], $args );
219+
220+
// Set up translations for script modules that use wp-i18n.
221+
if ( isset( $script_module_data['dependencies'] ) && in_array( 'wp-i18n', $script_module_data['dependencies'], true ) ) {
222+
wp_set_script_module_translations( $script_module_id, 'default' );
223+
}
200224
}
201225

202226
wp_register_script_module(

0 commit comments

Comments
 (0)