Skip to content
104 changes: 104 additions & 0 deletions src/wp-includes/class-wp-script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,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 $translations = array();
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.

A PHP 7.4 feature we can use.

Suggested change
private $translations = array();
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 +339,74 @@ 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 ] ) ) {
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;
}

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

Comment on lines +396 to +403
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.

Here's an alternative I tested which fixes the issue with indenting the closing HEREDOC/NOWDOC identifier. The issue is that the contents need to be indented the same, and PHP strips out the indentation from the string:

Suggested change
$output = <<<JS
( ( domain, translations ) => {
const localeData = translations.locale_data[ domain ] || translations.locale_data.messages;
localeData[""].domain = domain;
wp.i18n.setLocaleData( localeData, domain );
} )( "{$domain}", {$json_translations} );
JS;
$set_local_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_local_data_js_function,
wp_json_encode( $domain ),
$json_translations
);

This is also improved to use a NOWDOC instead of HEREDOC, which is better since it avoids accidental interpolation. Plugin Check, for example, disallows NOWDOC but not HEREDOC: WordPress/plugin-check#1036

This also addresses various static analysis issues flagged by PHPStorm, and it's much easier to read.

Before:

Image

After:

Image

$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 +438,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 @@ -840,6 +928,22 @@ private function sort_item_dependencies( string $id, array $import_types, array
return true;
}

/**
* Gets the raw source URL for a registered script module.
*
* Returns the source URL without version query string appended.
* This is used by {@see load_script_module_textdomain()} to determine
* the relative path for loading translation files.
*
* @since 7.0.0
*
* @param string $id The script module identifier.
* @return string|null The script module source URL, or null if not registered.
*/
public function get_registered_src( string $id ): ?string {
return $this->registered[ $id ]['src'] ?? null;
}

/**
* Gets the versioned URL for a script module src.
*
Expand Down
145 changes: 145 additions & 0 deletions src/wp-includes/l10n.php
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,151 @@ function load_script_textdomain( $handle, $domain = 'default', $path = '' ) {
return load_script_translations( false, $handle, $domain );
}

/**
* 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
*
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
*
*
* @global WP_Textdomain_Registry $wp_textdomain_registry WordPress Textdomain Registry.
*

* @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( $id, $domain = 'default', $path = '' ) {
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
function load_script_module_textdomain( $id, $domain = 'default', $path = '' ) {
function load_script_module_textdomain( string $id, string $domain = 'default', string $path = '' ) {

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.

Since this function is almost identical with load_script_textdomain(), should we not perhaps instead add an $is_module param to that function which would cause it to treat the $handle as a script module instead? Otherwise, there could still be separate load_script_textdomain() and load_script_module_textdomain() functions, but they could share a common private function that eliminates the duplicating the logic.

/** @var WP_Textdomain_Registry $wp_textdomain_registry */
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.

PhpStorm is flagging this as being redundant:

@var tag specifies the type already inferred from source code

Suggested change
/** @var WP_Textdomain_Registry $wp_textdomain_registry */

global $wp_textdomain_registry;

$src = wp_script_modules()->get_registered_src( $id );

if ( null === $src ) {
return false;
}

$locale = determine_locale();

if ( ! $path ) {
$path = $wp_textdomain_registry->get( $domain, $locale );
}

$path = untrailingslashit( $path );

// If a path was given and the handle file exists simply return it.
$file_base = 'default' === $domain ? $locale : $domain . '-' . $locale;
$handle_filename = $file_base . '-' . $id . '.json';

if ( $path ) {
$translations = load_script_translations( $path . '/' . $handle_filename, $id, $domain );

if ( $translations ) {
return $translations;
}
}

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

$relative = false;
$languages_path = WP_LANG_DIR;

$src_url = wp_parse_url( $src );
$content_url = wp_parse_url( content_url() );
$plugins_url = wp_parse_url( plugins_url() );
$site_url = wp_parse_url( site_url() );
$theme_root = get_theme_root();

// If the host is the same or it's a relative URL.
if (
( ! isset( $content_url['path'] ) || str_starts_with( $src_url['path'], $content_url['path'] ) ) &&
( ! isset( $src_url['host'] ) || ! isset( $content_url['host'] ) || $src_url['host'] === $content_url['host'] )
) {
// Make the src relative the specific plugin or theme.
if ( isset( $content_url['path'] ) ) {
$relative = substr( $src_url['path'], strlen( $content_url['path'] ) );
} else {
$relative = $src_url['path'];
}
$relative = trim( $relative, '/' );
$relative = explode( '/', $relative );

$theme_dir = array_slice( explode( '/', $theme_root ), -1 );
$dirname = $theme_dir[0] === $relative[0] ? 'themes' : 'plugins';

$languages_path = WP_LANG_DIR . '/' . $dirname;

$relative = array_slice( $relative, 2 ); // Remove plugins/<plugin name> or themes/<theme name>.
$relative = implode( '/', $relative );
} elseif (
( ! isset( $plugins_url['path'] ) || str_starts_with( $src_url['path'], $plugins_url['path'] ) ) &&
( ! isset( $src_url['host'] ) || ! isset( $plugins_url['host'] ) || $src_url['host'] === $plugins_url['host'] )
) {
// Make the src relative the specific plugin.
if ( isset( $plugins_url['path'] ) ) {
$relative = substr( $src_url['path'], strlen( $plugins_url['path'] ) );
} else {
$relative = $src_url['path'];
}
$relative = trim( $relative, '/' );
$relative = explode( '/', $relative );

$languages_path = WP_LANG_DIR . '/plugins';

$relative = array_slice( $relative, 1 ); // Remove <plugin name>.
$relative = implode( '/', $relative );
} elseif ( ! isset( $src_url['host'] ) || ! isset( $site_url['host'] ) || $src_url['host'] === $site_url['host'] ) {
if ( ! isset( $site_url['path'] ) ) {
$relative = trim( $src_url['path'], '/' );
} elseif ( str_starts_with( $src_url['path'], trailingslashit( $site_url['path'] ) ) ) {
// Make the src relative to the WP root.
$relative = substr( $src_url['path'], strlen( $site_url['path'] ) );
$relative = trim( $relative, '/' );
}
}

/**
* Filters the relative path of script module source used for finding translation files.
*
* @since 7.0.0
*
* @param string|false $relative The relative path of the script module source. False if it could not be determined.
* @param string $src The full source URL of the script module.
*/
$relative = apply_filters( 'load_script_module_textdomain_relative_path', $relative, $src );

// If the source is not from WP.
if ( false === $relative ) {
return load_script_translations( false, $id, $domain );
}

// Translations are always based on the unminified filename.
if ( str_ends_with( $relative, '.min.js' ) ) {
$relative = substr( $relative, 0, -7 ) . '.js';
}

$md5_filename = $file_base . '-' . md5( $relative ) . '.json';

if ( $path ) {
$translations = load_script_translations( $path . '/' . $md5_filename, $id, $domain );

if ( $translations ) {
return $translations;
}
}

$translations = load_script_translations( $languages_path . '/' . $md5_filename, $id, $domain );

if ( $translations ) {
return $translations;
}

return load_script_translations( false, $id, $domain );
}

/**
* Loads the translation data for the given script handle and text domain.
*
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