<?php
/**
* Script Modules API: Polyfill for script module translations.
*
* Provides translation support for script modules on WordPress versions
* that do not yet include this functionality in Core.
*
* @package gutenberg
* @since X.X.X
*/
/**
* Gets the raw source URL for a registered script module.
*
* Uses WP_Script_Modules::get_registered() if available (WP 7.0+),
* otherwise falls back to reflection to access the private registered array.
*
* @since X.X.X
*
* @param string $id The script module identifier.
* @return string|null The script module source URL, or null if not registered.
*/
function gutenberg_get_script_module_src( string $id ): ?string {
$script_modules = wp_script_modules();
if ( method_exists( $script_modules, 'get_registered' ) ) {
$module = $script_modules->get_registered( $id );
return null === $module ? null : ( $module['src'] ?? null );
}
// Fallback for WP versions without get_registered().
$reflection = new ReflectionClass( $script_modules );
$prop = $reflection->getProperty( 'registered' );
if ( PHP_VERSION_ID < 80100 ) {
$prop->setAccessible( true );
}
$registered = $prop->getValue( $script_modules );
return $registered[ $id ]['src'] ?? null;
}
/**
* Prints translations for all enqueued script modules.
*
* Auto-detects the text domain for each enqueued module from its source URL.
*
* @since X.X.X
*/
function gutenberg_print_script_module_translations() {
$script_modules = wp_script_modules();
$queue = $script_modules->get_queue();
if ( empty( $queue ) ) {
return;
}
// Collect enqueued modules and their static/dynamic dependencies.
$module_ids = array();
$reflection = new ReflectionClass( $script_modules );
if ( $reflection->hasMethod( 'get_sorted_dependencies' ) ) {
$method = $reflection->getMethod( 'get_sorted_dependencies' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}
$module_ids = $method->invoke( $script_modules, $queue );
} else {
$module_ids = $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 ) {
$json_translations = load_script_module_textdomain( $id );
if ( ! $json_translations ) {
continue;
}
$output = sprintf(
'( %s )( %s, %s );',
$set_locale_data_js_function,
wp_json_encode( 'default' ),
$json_translations
);
$source_url = rawurlencode( "wp-script-module-translation-data-{$id}" );
$output .= "\n//# sourceURL={$source_url}";
// 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' => "wp-script-module-translation-data-{$id}" ) );
}
}
// Print translations after classic scripts are loaded (priority 10) but before modules execute.
add_action( 'wp_footer', 'gutenberg_print_script_module_translations', 21 );
add_action( 'admin_print_footer_scripts', 'gutenberg_print_script_module_translations', 11 );
if ( ! function_exists( 'load_script_module_textdomain' ) ) {
/**
* Loads the translation data for a given script module ID and text domain.
*
* Works like load_script_textdomain() but for script modules registered
* via wp_register_script_module().
*
* @since X.X.X
*
* @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 = '' ) {
global $wp_textdomain_registry;
$src = gutenberg_get_script_module_src( $id );
if ( null === $src ) {
return false;
}
$locale = determine_locale();
if ( ! $path ) {
$path = $wp_textdomain_registry->get( $domain, $locale );
}
$path = untrailingslashit( $path );
$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;
}
}
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 (
( ! 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'] )
) {
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 );
$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'] )
) {
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 );
$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'] ) ) ) {
$relative = substr( $src_url['path'], strlen( $site_url['path'] ) );
$relative = trim( $relative, '/' );
}
}
/** This filter is documented in wp-includes/l10n.php */
$relative = apply_filters( 'load_script_textdomain_relative_path', $relative, $src, true );
if ( false === $relative ) {
return load_script_translations( false, $id, $domain );
}
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 );
}
}