Skip to content

Commit 2bb7f3f

Browse files
manzoorwanijkwestonruterjsnajdr
authored
I18N: Polyfill script module translations for WordPress < 7.0 (#77214)
* I18N: Add translation support for script modules Add polyfill for `wp_set_script_module_translations()` and update build templates to call it for script modules that depend on `wp-i18n`. Script modules have no mechanism to load i18n translation data, leaving strings in ES modules untranslated regardless of site language. This affects admin pages built as script modules like Connectors and Fonts. Changes: - Add polyfill in lib/compat/wordpress-7.1/script-modules.php with wp_set_script_module_translations(), load_script_module_textdomain(), and gutenberg_print_script_module_translations() - Update routes-registration.php.template to set translations for route content and route modules that use wp-i18n - Update module-registration.php.template to set translations for @wordpress/* script module packages that use wp-i18n All calls guarded with function_exists() for forward compatibility with the Core implementation. See https://core.trac.wordpress.org/ticket/65015. * Add backport changelog entry for script module translations Links WordPress/wordpress-develop#11543 to this PR. * Retarget script module translations compat to WordPress 7.0. Move the script modules translation polyfill from the wordpress-7.1 compat directory to wordpress-7.0 so the fix targets the 7.0 release, which is where the new admin pages (Connectors, Fonts) using script modules were introduced. Also moves the backport changelog entry accordingly. See https://core.trac.wordpress.org/ticket/65015. * Script Modules: Align null return type in translation polyfill Update gutenberg_get_script_module_src() and its callers in the script module translation polyfill to return/check for null instead of false, matching the ?string return type of the Core WP_Script_Modules::get_registered_src() method. * Script Modules: Pass $is_module=true to load_script_textdomain_relative_path filter Pass the new $is_module argument (introduced in WordPress 7.0) to the load_script_textdomain_relative_path filter from the script module translation polyfill, so callers of the filter can distinguish script modules from classic scripts. * Script Modules: Sync translation polyfill with upstream Core changes Mirror two upstream changes from the Core PR: 1. Rename get_registered_src() to get_registered() — Core renamed this method and changed it to return the full module data array instead of just the src. Update gutenberg_get_script_module_src() to call the new method and read $module['src'] from the returned array, keeping the reflection fallback for older WP versions. 2. Adopt the ES6 setLocaleData JS function and sprintf-based output format introduced in Core for print_script_module_translations(), so the polyfill stays consistent with Core. * Script Modules: Auto-detect translations for script modules Sync with upstream Core change that removes the explicit wp_set_script_module_translations() registration requirement: - Drop the wp_set_script_module_translations() calls in routes-registration.php.template and module-registration.php.template. Core now iterates all enqueued modules and auto-loads translations from the default text domain. - Rework gutenberg_print_script_module_translations() in the polyfill to do the same auto-iteration. Keep wp_set_script_module_translations() as an explicit override API for modules using non-default text domains or custom translation paths. * Script Modules: Align polyfill override storage with Core field names Update the script module translation polyfill to use 'textdomain' and 'translations_path' as the field names inside its override storage, matching the names adopted on the Core side (and used by WP_Dependency for classic scripts). Internal change only; the wp_set_script_module_translations() signature is unchanged. * Script Modules: Namespace translation inline script IDs. * Script Modules: Drop redundant translations_ prefix from polyfill path key. * Script Modules: Hoist locale-data JS heredoc out of the translations loop. * Script Modules: Print wp-i18n just-in-time before translation inline scripts. * Script Modules: Simplify polyfill translation override lookup with null-coalescing. * Script Modules: Guard polyfill setLocaleData call against missing wp.i18n. * Script Modules: Force-print wp-i18n before translation inline scripts in polyfill. * Script Modules: Slim polyfill to translation printing only. Co-authored-by: manzoorwanijk <[email protected]> Co-authored-by: westonruter <[email protected]> Co-authored-by: jsnajdr <[email protected]>
1 parent 616b958 commit 2bb7f3f

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

backport-changelog/7.0/11543.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
https://github.com/WordPress/wordpress-develop/pull/11543
2+
3+
* https://github.com/WordPress/gutenberg/pull/77214
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<?php
2+
/**
3+
* Script Modules API: Polyfill for script module translations.
4+
*
5+
* Provides translation support for script modules on WordPress versions
6+
* that do not yet include this functionality in Core.
7+
*
8+
* @package gutenberg
9+
* @since X.X.X
10+
*/
11+
12+
/**
13+
* Gets the raw source URL for a registered script module.
14+
*
15+
* Uses WP_Script_Modules::get_registered() if available (WP 7.0+),
16+
* otherwise falls back to reflection to access the private registered array.
17+
*
18+
* @since X.X.X
19+
*
20+
* @param string $id The script module identifier.
21+
* @return string|null The script module source URL, or null if not registered.
22+
*/
23+
function gutenberg_get_script_module_src( string $id ): ?string {
24+
$script_modules = wp_script_modules();
25+
26+
if ( method_exists( $script_modules, 'get_registered' ) ) {
27+
$module = $script_modules->get_registered( $id );
28+
return null === $module ? null : ( $module['src'] ?? null );
29+
}
30+
31+
// Fallback for WP versions without get_registered().
32+
$reflection = new ReflectionClass( $script_modules );
33+
$prop = $reflection->getProperty( 'registered' );
34+
$prop->setAccessible( true );
35+
$registered = $prop->getValue( $script_modules );
36+
37+
return $registered[ $id ]['src'] ?? null;
38+
}
39+
40+
/**
41+
* Prints translations for all enqueued script modules.
42+
*
43+
* Auto-detects the text domain for each enqueued module from its source URL.
44+
*
45+
* @since X.X.X
46+
*/
47+
function gutenberg_print_script_module_translations() {
48+
$script_modules = wp_script_modules();
49+
$queue = $script_modules->get_queue();
50+
if ( empty( $queue ) ) {
51+
return;
52+
}
53+
54+
// Collect enqueued modules and their static/dynamic dependencies.
55+
$module_ids = array();
56+
$reflection = new ReflectionClass( $script_modules );
57+
if ( $reflection->hasMethod( 'get_sorted_dependencies' ) ) {
58+
$method = $reflection->getMethod( 'get_sorted_dependencies' );
59+
$method->setAccessible( true );
60+
$module_ids = $method->invoke( $script_modules, $queue );
61+
} else {
62+
$module_ids = $queue;
63+
}
64+
65+
$set_locale_data_js_function = <<<'JS'
66+
( domain, translations ) => {
67+
const localeData = translations.locale_data[ domain ] || translations.locale_data.messages;
68+
localeData[""].domain = domain;
69+
wp.i18n.setLocaleData( localeData, domain );
70+
}
71+
JS;
72+
73+
foreach ( $module_ids as $id ) {
74+
$json_translations = load_script_module_textdomain( $id );
75+
if ( ! $json_translations ) {
76+
continue;
77+
}
78+
79+
$output = sprintf(
80+
'( %s )( %s, %s );',
81+
$set_locale_data_js_function,
82+
wp_json_encode( 'default' ),
83+
$json_translations
84+
);
85+
$source_url = rawurlencode( "wp-script-module-translation-data-{$id}" );
86+
$output .= "\n//# sourceURL={$source_url}";
87+
88+
// Ensure wp-i18n is printed; the inline script below relies on wp.i18n.setLocaleData().
89+
if ( ! wp_script_is( 'wp-i18n', 'done' ) ) {
90+
wp_scripts()->do_items( array( 'wp-i18n' ) );
91+
}
92+
93+
wp_print_inline_script_tag( $output, array( 'id' => "wp-script-module-translation-data-{$id}" ) );
94+
}
95+
}
96+
97+
// Print translations after classic scripts are loaded (priority 10) but before modules execute.
98+
add_action( 'wp_footer', 'gutenberg_print_script_module_translations', 21 );
99+
add_action( 'admin_print_footer_scripts', 'gutenberg_print_script_module_translations', 11 );
100+
101+
if ( ! function_exists( 'load_script_module_textdomain' ) ) {
102+
/**
103+
* Loads the translation data for a given script module ID and text domain.
104+
*
105+
* Works like load_script_textdomain() but for script modules registered
106+
* via wp_register_script_module().
107+
*
108+
* @since X.X.X
109+
*
110+
* @param string $id The script module identifier.
111+
* @param string $domain Optional. Text domain. Default 'default'.
112+
* @param string $path Optional. The full file path to the directory containing translation files.
113+
* @return string|false The JSON-encoded translated strings for the given script module and text domain.
114+
* False if there are none.
115+
*/
116+
function load_script_module_textdomain( $id, $domain = 'default', $path = '' ) {
117+
global $wp_textdomain_registry;
118+
119+
$src = gutenberg_get_script_module_src( $id );
120+
121+
if ( null === $src ) {
122+
return false;
123+
}
124+
125+
$locale = determine_locale();
126+
127+
if ( ! $path ) {
128+
$path = $wp_textdomain_registry->get( $domain, $locale );
129+
}
130+
131+
$path = untrailingslashit( $path );
132+
133+
$file_base = 'default' === $domain ? $locale : $domain . '-' . $locale;
134+
$handle_filename = $file_base . '-' . $id . '.json';
135+
136+
if ( $path ) {
137+
$translations = load_script_translations( $path . '/' . $handle_filename, $id, $domain );
138+
if ( $translations ) {
139+
return $translations;
140+
}
141+
}
142+
143+
if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
144+
$src = site_url( $src );
145+
}
146+
147+
$relative = false;
148+
$languages_path = WP_LANG_DIR;
149+
150+
$src_url = wp_parse_url( $src );
151+
$content_url = wp_parse_url( content_url() );
152+
$plugins_url = wp_parse_url( plugins_url() );
153+
$site_url = wp_parse_url( site_url() );
154+
$theme_root = get_theme_root();
155+
156+
if (
157+
( ! isset( $content_url['path'] ) || str_starts_with( $src_url['path'], $content_url['path'] ) ) &&
158+
( ! isset( $src_url['host'] ) || ! isset( $content_url['host'] ) || $src_url['host'] === $content_url['host'] )
159+
) {
160+
if ( isset( $content_url['path'] ) ) {
161+
$relative = substr( $src_url['path'], strlen( $content_url['path'] ) );
162+
} else {
163+
$relative = $src_url['path'];
164+
}
165+
$relative = trim( $relative, '/' );
166+
$relative = explode( '/', $relative );
167+
168+
$theme_dir = array_slice( explode( '/', $theme_root ), -1 );
169+
$dirname = $theme_dir[0] === $relative[0] ? 'themes' : 'plugins';
170+
171+
$languages_path = WP_LANG_DIR . '/' . $dirname;
172+
$relative = array_slice( $relative, 2 );
173+
$relative = implode( '/', $relative );
174+
} elseif (
175+
( ! isset( $plugins_url['path'] ) || str_starts_with( $src_url['path'], $plugins_url['path'] ) ) &&
176+
( ! isset( $src_url['host'] ) || ! isset( $plugins_url['host'] ) || $src_url['host'] === $plugins_url['host'] )
177+
) {
178+
if ( isset( $plugins_url['path'] ) ) {
179+
$relative = substr( $src_url['path'], strlen( $plugins_url['path'] ) );
180+
} else {
181+
$relative = $src_url['path'];
182+
}
183+
$relative = trim( $relative, '/' );
184+
$relative = explode( '/', $relative );
185+
186+
$languages_path = WP_LANG_DIR . '/plugins';
187+
$relative = array_slice( $relative, 1 );
188+
$relative = implode( '/', $relative );
189+
} elseif ( ! isset( $src_url['host'] ) || ! isset( $site_url['host'] ) || $src_url['host'] === $site_url['host'] ) {
190+
if ( ! isset( $site_url['path'] ) ) {
191+
$relative = trim( $src_url['path'], '/' );
192+
} elseif ( str_starts_with( $src_url['path'], trailingslashit( $site_url['path'] ) ) ) {
193+
$relative = substr( $src_url['path'], strlen( $site_url['path'] ) );
194+
$relative = trim( $relative, '/' );
195+
}
196+
}
197+
198+
/** This filter is documented in wp-includes/l10n.php */
199+
$relative = apply_filters( 'load_script_textdomain_relative_path', $relative, $src, true );
200+
201+
if ( false === $relative ) {
202+
return load_script_translations( false, $id, $domain );
203+
}
204+
205+
if ( str_ends_with( $relative, '.min.js' ) ) {
206+
$relative = substr( $relative, 0, -7 ) . '.js';
207+
}
208+
209+
$md5_filename = $file_base . '-' . md5( $relative ) . '.json';
210+
211+
if ( $path ) {
212+
$translations = load_script_translations( $path . '/' . $md5_filename, $id, $domain );
213+
if ( $translations ) {
214+
return $translations;
215+
}
216+
}
217+
218+
$translations = load_script_translations( $languages_path . '/' . $md5_filename, $id, $domain );
219+
if ( $translations ) {
220+
return $translations;
221+
}
222+
223+
return load_script_translations( false, $id, $domain );
224+
}
225+
}

lib/load.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ function gutenberg_is_experiment_enabled( $name ) {
120120
require __DIR__ . '/compat/wordpress-7.0/media.php';
121121
require __DIR__ . '/compat/wordpress-7.0/command-palette.php';
122122
require __DIR__ . '/compat/wordpress-7.0/meta-box-rtc-compat.php';
123+
require __DIR__ . '/compat/wordpress-7.0/script-modules.php';
123124

124125
// Experimental features.
125126
require __DIR__ . '/experimental/block-editor-settings-mobile.php';

0 commit comments

Comments
 (0)