Skip to content

Commit 93d77a2

Browse files
committed
I18N: Add translation support for script modules.
Add automatic translation loading for script modules (ES modules), so strings using `__()` and friends from `@wordpress/i18n` can be translated at runtime. This brings classic script i18n parity to script modules registered via `wp_register_script_module()`, which previously had no way to load translation data, leaving strings untranslated on screens like Connectors and Fonts that are built as script modules. At the `admin_print_footer_scripts` and `wp_footer` actions, every enqueued script module and its dependencies are walked, the translation chunk is loaded for each, and an inline `<script>` calls `wp.i18n.setLocaleData()` so translations are available before deferred modules execute. Note there is currently a runtime dependency on the `wp-i18n` classic script, which is printed just-in-time if not already enqueued. This coupling is to be removed in a future release. Public API: * `WP_Script_Modules::set_translations()` stores the text domain (and optional path) per registered module to override the text domain and path. A global `wp_set_script_module_translations()` function is added as a wrapper around `wp_script_modules()->set_translations()`. * `WP_Script_Modules::get_registered()` obtains a registered module's data. See #60597. * `WP_Script_Modules::print_script_module_translations()` emits inline `wp.i18n.setLocaleData()` calls after classic scripts load but before modules execute. * `load_script_module_textdomain()` loads the translation data for a given script module ID and text domain. * The existing `load_script_textdomain_relative_path` filter gains a third `$is_module` parameter so callers can distinguish classic-script and script-module lookups when resolving translation paths. PHPStan types are also added in `WP_Script_Modules`. See #64238. Developed in #11543 Props manzoorwanijk, westonruter, jsnajdr, jonsurrell, mukesh27, peterwilsoncc, 369work, desrosj, sabernhardt, nilambar, jorgefilipecosta, malayladu. See #64238, #60597. Fixes #65015. git-svn-id: https://develop.svn.wordpress.org/trunk@62278 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 8270db8 commit 93d77a2

4 files changed

Lines changed: 509 additions & 14 deletions

File tree

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,24 @@
1212
* Core class used to register script modules.
1313
*
1414
* @since 6.5.0
15+
*
16+
* @phpstan-type ScriptModule array{
17+
* src: string,
18+
* version: string|false|null,
19+
* dependencies: array<int, array{ id: string, import: 'static'|'dynamic' }>,
20+
* in_footer: bool,
21+
* fetchpriority: 'auto'|'low'|'high',
22+
* textdomain?: string,
23+
* translations_path?: string,
24+
* }
1525
*/
1626
class WP_Script_Modules {
1727
/**
1828
* Holds the registered script modules, keyed by script module identifier.
1929
*
2030
* @since 6.5.0
2131
* @var array<string, array<string, mixed>>
32+
* @phpstan-var array<string, ScriptModule>
2233
*/
2334
private $registered = array();
2435

@@ -328,6 +339,87 @@ public function deregister( string $id ) {
328339
unset( $this->registered[ $id ] );
329340
}
330341

342+
/**
343+
* Overrides the text domain and path used to load translations for a script module.
344+
*
345+
* This is only needed for modules whose text domain differs from 'default'
346+
* or whose translation files live outside the standard locations, for
347+
* example plugin modules that register their own text domain. Translations
348+
* for modules that use the default domain are loaded automatically by
349+
* {@see WP_Script_Modules::print_script_module_translations()}.
350+
*
351+
* @since 7.0.0
352+
*
353+
* @param string $id The identifier of the script module.
354+
* @param string $domain Optional. Text domain. Default 'default'.
355+
* @param string $path Optional. The full file path to the directory containing translation files.
356+
* @return bool True if the text domain was registered, false if the module is not registered.
357+
*/
358+
public function set_translations( string $id, string $domain = 'default', string $path = '' ): bool {
359+
if ( ! isset( $this->registered[ $id ] ) ) {
360+
return false;
361+
}
362+
363+
$this->registered[ $id ]['textdomain'] = $domain;
364+
$this->registered[ $id ]['translations_path'] = $path;
365+
366+
return true;
367+
}
368+
369+
/**
370+
* Prints translations for all enqueued script modules.
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+
* Auto-detects the text domain and translation path for each module from
377+
* its source URL. Modules whose text domain or path differs from the
378+
* defaults can opt into a specific domain/path via
379+
* {@see WP_Script_Modules::set_translations()}.
380+
*
381+
* @since 7.0.0
382+
*/
383+
public function print_script_module_translations(): void {
384+
// Collect all module IDs that will be on the page (enqueued + their dependencies).
385+
$module_ids = $this->get_sorted_dependencies( $this->queue );
386+
387+
$set_locale_data_js_function = <<<'JS'
388+
( domain, translations ) => {
389+
const localeData = translations.locale_data[ domain ] || translations.locale_data.messages;
390+
localeData[""].domain = domain;
391+
wp.i18n.setLocaleData( localeData, domain );
392+
}
393+
JS;
394+
395+
foreach ( $module_ids as $id ) {
396+
$domain = $this->registered[ $id ]['textdomain'] ?? 'default';
397+
$path = $this->registered[ $id ]['translations_path'] ?? '';
398+
399+
$json_translations = load_script_module_textdomain( $id, $domain, $path );
400+
401+
if ( ! $json_translations ) {
402+
continue;
403+
}
404+
405+
$output = sprintf(
406+
'( %s )( %s, %s );',
407+
$set_locale_data_js_function,
408+
wp_json_encode( $domain, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
409+
$json_translations
410+
);
411+
$script_id = "wp-script-module-translation-data-{$id}";
412+
$output .= "\n//# sourceURL=" . rawurlencode( $script_id );
413+
414+
// Ensure wp-i18n is printed; the inline script below relies on wp.i18n.setLocaleData().
415+
if ( ! wp_script_is( 'wp-i18n', 'done' ) ) {
416+
wp_scripts()->do_items( array( 'wp-i18n' ) );
417+
}
418+
419+
wp_print_inline_script_tag( $output, array( 'id' => $script_id ) );
420+
}
421+
}
422+
331423
/**
332424
* Adds the hooks to print the import map, enqueued script modules and script
333425
* module preloads.
@@ -359,6 +451,15 @@ public function add_hooks() {
359451
add_action( 'admin_print_footer_scripts', array( $this, 'print_enqueued_script_modules' ) );
360452
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_preloads' ) );
361453

454+
/*
455+
* Print translations after classic scripts like wp-i18n are loaded (at
456+
* priority 10 via _wp_footer_scripts), but before the script modules
457+
* execute. Script modules with type="module" are deferred by default,
458+
* so inline translation scripts at priority 11 will execute before them.
459+
*/
460+
add_action( 'wp_footer', array( $this, 'print_script_module_translations' ), 21 );
461+
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_translations' ), 11 );
462+
362463
add_action( 'wp_footer', array( $this, 'print_script_module_data' ) );
363464
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_data' ) );
364465
add_action( 'wp_footer', array( $this, 'print_a11y_script_module_html' ), 20 );
@@ -631,6 +732,7 @@ private function get_import_map(): array {
631732
* @since 6.5.0
632733
*
633734
* @return array<string, array<string, mixed>> Script modules marked for enqueue, keyed by script module identifier.
735+
* @phpstan-return array<string, ScriptModule>
634736
*/
635737
private function get_marked_for_enqueue(): array {
636738
return wp_array_slice_assoc(
@@ -652,6 +754,7 @@ private function get_marked_for_enqueue(): array {
652754
* @param string[] $import_types Optional. Import types of dependencies to retrieve: 'static', 'dynamic', or both.
653755
* Default is both.
654756
* @return array<string, array<string, mixed>> List of dependencies, keyed by script module identifier.
757+
* @phpstan-return array<string, ScriptModule>
655758
*/
656759
private function get_dependencies( array $ids, array $import_types = array( 'static', 'dynamic' ) ): array {
657760
$all_dependencies = array();
@@ -840,6 +943,19 @@ private function sort_item_dependencies( string $id, array $import_types, array
840943
return true;
841944
}
842945

946+
/**
947+
* Gets the data for a registered script module.
948+
*
949+
* @since 7.0.0
950+
*
951+
* @param string $id The script module identifier.
952+
* @return array|null The script module data, or null if not registered.
953+
* @phpstan-return ScriptModule|null
954+
*/
955+
public function get_registered( string $id ): ?array {
956+
return $this->registered[ $id ] ?? null;
957+
}
958+
843959
/**
844960
* Gets the versioned URL for a script module src.
845961
*

src/wp-includes/l10n.php

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,24 +1134,80 @@ function load_child_theme_textdomain( $domain, $path = false ) {
11341134
*
11351135
* @see WP_Scripts::set_translations()
11361136
*
1137-
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
1138-
*
11391137
* @param string $handle Name of the script to register a translation domain to.
11401138
* @param string $domain Optional. Text domain. Default 'default'.
11411139
* @param string $path Optional. The full file path to the directory containing translation files.
11421140
* @return string|false The translated strings in JSON encoding on success,
11431141
* false if the script textdomain could not be loaded.
11441142
*/
11451143
function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
1146-
/** @var WP_Textdomain_Registry $wp_textdomain_registry */
1147-
global $wp_textdomain_registry;
1148-
11491144
$wp_scripts = wp_scripts();
11501145

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

1150+
$src = $wp_scripts->registered[ $handle ]->src;
1151+
1152+
if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && str_starts_with( $src, $wp_scripts->content_url ) ) ) {
1153+
$src = $wp_scripts->base_url . $src;
1154+
}
1155+
1156+
return _load_script_textdomain_from_src( $handle, $src, $domain, $path, false );
1157+
}
1158+
1159+
/**
1160+
* Loads the translation data for a given script module ID and text domain.
1161+
*
1162+
* Works like {@see load_script_textdomain()} but for script modules registered
1163+
* via {@see wp_register_script_module()}.
1164+
*
1165+
* @since 7.0.0
1166+
*
1167+
* @param string $id The script module identifier.
1168+
* @param string $domain Optional. Text domain. Default 'default'.
1169+
* @param string $path Optional. The full file path to the directory containing translation files.
1170+
* @return string|false The JSON-encoded translated strings for the given script module and text domain.
1171+
* False if there are none.
1172+
*/
1173+
function load_script_module_textdomain( string $id, string $domain = 'default', string $path = '' ) {
1174+
$module = wp_script_modules()->get_registered( $id );
1175+
if ( null === $module ) {
1176+
return false;
1177+
}
1178+
$src = $module['src'];
1179+
1180+
// Ensure src is an absolute URL for path resolution.
1181+
if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
1182+
$src = site_url( $src );
1183+
}
1184+
1185+
return _load_script_textdomain_from_src( $id, $src, $domain, $path, true );
1186+
}
1187+
1188+
/**
1189+
* Resolves and loads the translation JSON file for a given script or script module source URL.
1190+
*
1191+
* This is a shared implementation used by {@see load_script_textdomain()} and
1192+
* {@see load_script_module_textdomain()} to avoid duplicating the path
1193+
* resolution and file lookup logic.
1194+
*
1195+
* @since 7.0.0
1196+
* @access private
1197+
*
1198+
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
1199+
*
1200+
* @param string $handle Name of the script or script module identifier to register a translation domain to.
1201+
* @param string $src Absolute source URL of the script or script module.
1202+
* @param string $domain Text domain.
1203+
* @param string $path The full file path to the directory containing translation files,
1204+
* or an empty string to use the default path from the text domain registry.
1205+
* @param bool $is_module Whether the source belongs to a script module (true) or a classic script (false).
1206+
* @return string|false The JSON-encoded translated strings on success, false otherwise.
1207+
*/
1208+
function _load_script_textdomain_from_src( string $handle, string $src, string $domain, string $path, bool $is_module ) {
1209+
global $wp_textdomain_registry;
1210+
11551211
$locale = determine_locale();
11561212

11571213
if ( ! $path ) {
@@ -1172,12 +1228,6 @@ function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
11721228
}
11731229
}
11741230

1175-
$src = $wp_scripts->registered[ $handle ]->src;
1176-
1177-
if ( ! preg_match( '|^(https?:)?//|', $src ) && ! ( $wp_scripts->content_url && str_starts_with( $src, $wp_scripts->content_url ) ) ) {
1178-
$src = $wp_scripts->base_url . $src;
1179-
}
1180-
11811231
$relative = false;
11821232
$languages_path = WP_LANG_DIR;
11831233

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

12541306
// If the source is not from WP.
12551307
if ( false === $relative ) {

src/wp-includes/script-modules.php

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

141+
/**
142+
* Overrides the text domain and path used to load translations for a script module.
143+
*
144+
* Translations for script modules are loaded automatically from the default
145+
* text domain and language directory. Use this function only when a module's
146+
* text domain differs from `'default'` or when translation files live outside
147+
* the standard location, for example plugin modules using their own text domain.
148+
*
149+
* @since 7.0.0
150+
*
151+
* @see WP_Script_Modules::set_translations()
152+
*
153+
* @param string $id The identifier of the script module.
154+
* @param string $domain Optional. Text domain. Default 'default'.
155+
* @param string $path Optional. The full file path to the directory containing translation files.
156+
* @return bool True if the text domain was registered, false if the module is not registered.
157+
*/
158+
function wp_set_script_module_translations( string $id, string $domain = 'default', string $path = '' ): bool {
159+
return wp_script_modules()->set_translations( $id, $domain, $path );
160+
}
161+
141162
/**
142163
* Registers all the default WordPress Script Modules.
143164
*

0 commit comments

Comments
 (0)