Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.95% covered (warning)
89.95%
188 / 209
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Assets
89.95% covered (warning)
89.95%
188 / 209
66.67% covered (warning)
66.67%
10 / 15
129.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 instance
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 add_async_script
n/a
0 / 0
n/a
0 / 0
1
 script_add_async
n/a
0 / 0
n/a
0 / 0
3
 enqueue_async_script
n/a
0 / 0
n/a
0 / 0
1
 get_file_url_for_environment
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 add_resource_hint
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 staticize_subdomain
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 normalize_path
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
16
 register_script
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
1 / 1
23
 enqueue_script
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 ensure_package_bootstrap
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 wp_default_scripts_hook
93.94% covered (success)
93.94%
31 / 33
0.00% covered (danger)
0.00%
0 / 1
19.08
 alias_textdomain
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 alias_textdomains_from_file
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 init_domain_map_hooks
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
3.58
 filter_gettext
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 filter_ngettext
n/a
0 / 0
n/a
0 / 0
3
 filter_gettext_with_context
n/a
0 / 0
n/a
0 / 0
2
 filter_ngettext_with_context
n/a
0 / 0
n/a
0 / 0
3
 filter_load_script_translation_file
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2/**
3 * Jetpack Assets package.
4 *
5 * @package  automattic/jetpack-assets
6 */
7
8namespace Automattic\Jetpack;
9
10use Automattic\Jetpack\Assets\Semver;
11use Automattic\Jetpack\Assets\Shared_Stores_Assets;
12use Automattic\Jetpack\Constants as Jetpack_Constants;
13use InvalidArgumentException;
14
15/**
16 * Class Assets
17 */
18class Assets {
19    /**
20     * Holds all the scripts handles that should be loaded in a deferred fashion.
21     *
22     * @var array
23     */
24    private $defer_script_handles = array();
25
26    /**
27     * The singleton instance of this class.
28     *
29     * @var Assets
30     */
31    protected static $instance;
32
33    /**
34     * The registered textdomain mappings.
35     *
36     * @var array `array( mapped_domain => array( string target_domain, string target_type, string semver ) )`.
37     */
38    private static $domain_map = array();
39
40    /**
41     * The registered package paths, by textdomain.
42     *
43     * Separate from `$domain_map` because the two answer different questions:
44     * the map says which domain a package's strings are translated under, while
45     * this says where the package's files live — the prefix WordPress hashes to
46     * name a JS translation file. A package whose textdomain is already its
47     * plugin's has nothing to alias but still needs the path.
48     *
49     * Note the entries are keyed by domain, not by script: `downloadI18n()`
50     * prepends a domain's prefix to every bundle path looked up under it. That
51     * is only ever one package's path, so a plugin must not both bundle a
52     * package whose textdomain is the plugin's own and load its own
53     * `wp-jp-i18n-loader` bundles under that same domain — the package's prefix
54     * would be applied to the plugin's catalogs too, and they would all 404. No
55     * plugin does both today. One that needs to should give the package a
56     * distinct textdomain, the way `jetpack-backup-pkg` and
57     * `jetpack-videopress-pkg` do.
58     *
59     * @var array `array( domain => array( string semver, string path_prefix ) )`.
60     */
61    private static $domain_paths = array();
62
63    /**
64     * Constructor.
65     *
66     * Static-only class, so nothing here.
67     */
68    private function __construct() {}
69
70    // ////////////////////
71    // region Async script loading
72
73    /**
74     * Get the singleton instance of the class.
75     *
76     * @return Assets
77     */
78    public static function instance() {
79        if ( ! isset( self::$instance ) ) {
80            self::$instance = new Assets();
81        }
82
83        return self::$instance;
84    }
85
86    /**
87     * A public method for adding the async script.
88     *
89     * @deprecated Since 2.1.0, the `strategy` feature should be used instead, with the "defer" setting.
90     *
91     * @param string $script_handle Script handle.
92     */
93    public static function add_async_script( $script_handle ) {
94        _deprecated_function( __METHOD__, '2.1.0' );
95
96        wp_script_add_data( $script_handle, 'strategy', 'defer' );
97    }
98
99    /**
100     * Add an async attribute to scripts that can be loaded deferred.
101     * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script
102     *
103     * @deprecated Since 2.1.0, the `strategy` feature should be used instead.
104     *
105     * @param string $tag    The <script> tag for the enqueued script.
106     * @param string $handle The script's registered handle.
107     */
108    public function script_add_async( $tag, $handle ) {
109        _deprecated_function( __METHOD__, '2.1.0' );
110        if ( empty( $this->defer_script_handles ) ) {
111            return $tag;
112        }
113
114        if ( in_array( $handle, $this->defer_script_handles, true ) ) {
115            // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
116            return preg_replace( '/<script( [^>]*)? src=/i', '<script defer$1 src=', $tag );
117        }
118
119        return $tag;
120    }
121
122    /**
123     * A helper function that lets you enqueue scripts in an async fashion.
124     *
125     * @deprecated Since 2.1.0 - use the strategy feature instead.
126     *
127     * @param string $handle        Name of the script. Should be unique.
128     * @param string $min_path      Minimized script path.
129     * @param string $non_min_path  Full Script path.
130     * @param array  $deps           Array of script dependencies.
131     * @param bool   $ver             The script version.
132     * @param bool   $in_footer       Should the script be included in the footer.
133     */
134    public static function enqueue_async_script( $handle, $min_path, $non_min_path, $deps = array(), $ver = false, $in_footer = true ) {
135        _deprecated_function( __METHOD__, '2.1.0' );
136        wp_enqueue_script( $handle, self::get_file_url_for_environment( $min_path, $non_min_path ), $deps, $ver, $in_footer );
137        wp_script_add_data( $handle, 'strategy', 'defer' );
138    }
139
140    // endregion .
141
142    // ////////////////////
143    // region Utils
144
145    /**
146     * Given a minified path, and a non-minified path, will return
147     * a minified or non-minified file URL based on whether SCRIPT_DEBUG is set and truthy.
148     *
149     * If $package_path is provided, then the minified or non-minified file URL will be generated
150     * relative to the root package directory.
151     *
152     * Both `$min_base` and `$non_min_base` can be either full URLs, or are expected to be relative to the
153     * root Jetpack directory.
154     *
155     * @param string $min_path     minified path.
156     * @param string $non_min_path non-minified path.
157     * @param string $package_path Optional. A full path to a file inside a package directory
158     *                             The URL will be relative to its directory. Default empty.
159     *                             Typically this is done by passing __FILE__ as the argument.
160     *
161     * @return string The URL to the file
162     * @since 1.0.3
163     * @since-jetpack 5.6.0
164     */
165    public static function get_file_url_for_environment( $min_path, $non_min_path, $package_path = '' ) {
166        $path = ( Jetpack_Constants::is_defined( 'SCRIPT_DEBUG' ) && Jetpack_Constants::get_constant( 'SCRIPT_DEBUG' ) )
167            ? $non_min_path
168            : $min_path;
169
170        /*
171         * If the path is actually a full URL, keep that.
172         * We look for a host value, since enqueues are sometimes without a scheme.
173         */
174        $file_parts = wp_parse_url( $path );
175        if ( ! empty( $file_parts['host'] ) ) {
176            $url = $path;
177        } else {
178            $plugin_path = empty( $package_path ) ? Jetpack_Constants::get_constant( 'JETPACK__PLUGIN_FILE' ) : $package_path;
179
180            $url = plugins_url( $path, $plugin_path );
181        }
182
183        /**
184         * Filters the URL for a file passed through the get_file_url_for_environment function.
185         *
186         * @since 1.0.3
187         *
188         * @package assets
189         *
190         * @param string $url The URL to the file.
191         * @param string $min_path The minified path.
192         * @param string $non_min_path The non-minified path.
193         */
194        return apply_filters( 'jetpack_get_file_for_environment', $url, $min_path, $non_min_path );
195    }
196
197    /**
198     * Passes an array of URLs to wp_resource_hints.
199     *
200     * @since 1.5.0
201     *
202     * @param string|array $urls URLs to hint.
203     * @param string       $type One of the supported resource types: dns-prefetch (default), preconnect, prefetch, or prerender.
204     */
205    public static function add_resource_hint( $urls, $type = 'dns-prefetch' ) {
206        add_filter(
207            'wp_resource_hints',
208            function ( $hints, $resource_type ) use ( $urls, $type ) {
209                if ( $resource_type === $type ) {
210                    // Type casting to array required since the function accepts a single string.
211                    foreach ( (array) $urls as $url ) {
212                        $hints[] = $url;
213                    }
214                }
215                return $hints;
216            },
217            10,
218            2
219        );
220    }
221
222    /**
223     * Serve a WordPress.com static resource via a randomized wp.com subdomain.
224     *
225     * @since 1.9.0
226     *
227     * @param string $url WordPress.com static resource URL.
228     *
229     * @return string $url
230     */
231    public static function staticize_subdomain( $url ) {
232        // Extract hostname from URL.
233        $host = wp_parse_url( $url, PHP_URL_HOST );
234
235        // Explode hostname on '.'.
236        $exploded_host = explode( '.', $host );
237
238        // Retrieve the name and TLD.
239        if ( count( $exploded_host ) > 1 ) {
240            $name = $exploded_host[ count( $exploded_host ) - 2 ];
241            $tld  = $exploded_host[ count( $exploded_host ) - 1 ];
242            // Rebuild domain excluding subdomains.
243            $domain = $name . '.' . $tld;
244        } else {
245            $domain = $host;
246        }
247        // Array of Automattic domains.
248        $domains_allowed = array( 'wordpress.com', 'wp.com' );
249
250        // Return $url if not an Automattic domain.
251        if ( ! in_array( $domain, $domains_allowed, true ) ) {
252            return $url;
253        }
254
255        if ( \is_ssl() ) {
256            return preg_replace( '|https?://[^/]++/|', 'https://s-ssl.wordpress.com/', $url );
257        }
258
259        /*
260         * Generate a random subdomain id by taking the modulus of the crc32 value of the URL.
261         * Valid values are 0, 1, and 2.
262         */
263        $static_counter = abs( crc32( basename( $url ) ) % 3 );
264
265        return preg_replace( '|://[^/]+?/|', "://s$static_counter.wp.com/", $url );
266    }
267
268    /**
269     * Resolve '.' and '..' components in a path or URL.
270     *
271     * @since 1.12.0
272     * @param string $path Path or URL.
273     * @return string Normalized path or URL.
274     */
275    public static function normalize_path( $path ) {
276        $parts = wp_parse_url( $path );
277        if ( ! isset( $parts['path'] ) ) {
278            return $path;
279        }
280
281        $ret  = '';
282        $ret .= isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '';
283        if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
284            $ret .= $parts['user'] ?? '';
285            $ret .= isset( $parts['pass'] ) ? ':' . $parts['pass'] : '';
286            $ret .= '@';
287        }
288        $ret .= $parts['host'] ?? '';
289        $ret .= isset( $parts['port'] ) ? ':' . $parts['port'] : '';
290
291        $pp = explode( '/', $parts['path'] );
292        if ( '' === $pp[0] ) {
293            $ret .= '/';
294            array_shift( $pp );
295        }
296        $i = 0;
297        while ( $i < count( $pp ) ) { // phpcs:ignore Squiz.PHP.DisallowSizeFunctionsInLoops.Found
298            if ( '' === $pp[ $i ] || '.' === $pp[ $i ] || 0 === $i && '..' === $pp[ $i ] ) {
299                array_splice( $pp, $i, 1 );
300            } elseif ( '..' === $pp[ $i ] ) {
301                array_splice( $pp, --$i, 2 );
302            } else {
303                ++$i;
304            }
305        }
306        $ret .= implode( '/', $pp );
307
308        $ret .= isset( $parts['query'] ) ? '?' . $parts['query'] : '';
309        $ret .= isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : '';
310
311        return $ret;
312    }
313
314    // endregion .
315
316    // ////////////////////
317    // region Webpack-built script registration
318
319    /**
320     * Register a Webpack-built script.
321     *
322     * Our Webpack-built scripts tend to need a bunch of boilerplate:
323     *  - A call to `Assets::get_file_url_for_environment()` for possible debugging.
324     *  - A call to `wp_register_style()` for extracted CSS, possibly with detection of RTL.
325     *  - Loading of dependencies and version provided by `@wordpress/dependency-extraction-webpack-plugin`.
326     *  - Avoiding WPCom's broken minifier.
327     *
328     * This wrapper handles all of that.
329     *
330     * @since 1.12.0
331     * @since 2.1.0 Add a new `strategy` option to leverage WP >= 6.3 script strategy feature. The `async` option is deprecated.
332     * @param string $handle      Name of the script. Should be unique across both scripts and styles.
333     * @param string $path        Minimized script path.
334     * @param string $relative_to File that `$path` is relative to. Pass `__FILE__`.
335     * @param array  $options     Additional options:
336     *  - `asset_path`:       (string|null) `.asset.php` to load. Default is to base it on `$path`.
337     *  - `async`:            (bool) Set true to register the script as deferred, like `Assets::enqueue_async_script()`. Deprecated in favor of `strategy`.
338     *  - `css_dependencies`: (string[]) Additional style dependencies to queue.
339     *  - `css_path`:         (string|null) `.css` to load. Default is to base it on `$path`.
340     *  - `dependencies`:     (string[]) Additional script dependencies to queue.
341     *  - `enqueue`:          (bool) Set true to enqueue the script immediately.
342     *  - `in_footer`:        (bool) Set true to register script for the footer.
343     *  - `media`:            (string) Media for the css file. Default 'all'.
344     *  - `minify`:           (bool|null) Set true to pass `minify=true` in the query string, or `null` to suppress the normal `minify=false`.
345     *  - `nonmin_path`:      (string) Non-minified script path.
346     *  - `strategy`:         (string) Specify a script strategy to use, eg. `defer` or `async`. Default is `""`.
347     *  - `textdomain`:       (string) Text domain for the script. Required if the script depends on wp-i18n.
348     *  - `version`:          (string) Override the version from the `asset_path` file.
349     * @phan-param array{asset_path?:?string,async?:bool,css_dependencies?:string[],css_path?:?string,dependencies?:string[],enqueue?:bool,in_footer?:bool,media?:string,minify?:?bool,nonmin_path?:string,strategy?:string,textdomain?:string,version?:string} $options
350     * @throws \InvalidArgumentException If arguments are invalid.
351     */
352    public static function register_script( $handle, $path, $relative_to, array $options = array() ) {
353        if ( substr( $path, -3 ) !== '.js' ) {
354            throw new \InvalidArgumentException( '$path must end in ".js"' );
355        }
356
357        if ( isset( $options['async'] ) ) {
358            _deprecated_argument( __METHOD__, '2.1.0', 'The `async` option is deprecated in favor of `strategy`' );
359        }
360
361        $dir      = dirname( $relative_to );
362        $base     = substr( $path, 0, -3 );
363        $options += array(
364            'asset_path'       => "$base.asset.php",
365            'async'            => false,
366            'css_dependencies' => array(),
367            'css_path'         => "$base.css",
368            'dependencies'     => array(),
369            'enqueue'          => false,
370            'in_footer'        => false,
371            'media'            => 'all',
372            'minify'           => false,
373            'strategy'         => '',
374            'textdomain'       => null,
375        );
376        '@phan-var array{asset_path:?string,async:bool,css_dependencies:string[],css_path:?string,dependencies:string[],enqueue:bool,in_footer:bool,media:string,minify:?bool,nonmin_path?:string,strategy:string,textdomain:string,version?:string} $options'; // Phan gets confused by the array addition.
377
378        if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && substr( $options['css_path'], -4 ) !== '.css' ) {
379            throw new \InvalidArgumentException( '$options[\'css_path\'] must end in ".css"' );
380        }
381
382        if ( isset( $options['nonmin_path'] ) ) {
383            $url = self::get_file_url_for_environment( $path, $options['nonmin_path'], $relative_to );
384        } else {
385            $url = plugins_url( $path, $relative_to );
386        }
387        $url = self::normalize_path( $url );
388        if ( null !== $options['minify'] ) {
389            $url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
390        }
391
392        if ( $options['asset_path'] && file_exists( "$dir/{$options['asset_path']}" ) ) {
393            $asset                       = require "$dir/{$options['asset_path']}"; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
394            $options['dependencies']     = array_merge( $asset['dependencies'], $options['dependencies'] );
395            $options['css_dependencies'] = array_merge(
396                array_filter(
397                    $asset['dependencies'],
398                    function ( $d ) {
399                        return wp_style_is( $d, 'registered' );
400                    }
401                ),
402                $options['css_dependencies']
403            );
404            $ver                         = $options['version'] ?? $asset['version'];
405        } else {
406            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
407            $ver = $options['version'] ?? @filemtime( "$dir/$path" );
408        }
409
410        if ( $options['async'] && '' === $options['strategy'] ) { // Handle the deprecated `async` option
411            $options['strategy'] = 'defer';
412        }
413        wp_register_script(
414            $handle,
415            $url,
416            $options['dependencies'],
417            $ver,
418            array(
419                'in_footer' => $options['in_footer'],
420                'strategy'  => $options['strategy'],
421            )
422        );
423
424        if ( $options['textdomain'] ) {
425            // phpcs:ignore Jetpack.Functions.I18n.DomainNotLiteral
426            wp_set_script_translations( $handle, $options['textdomain'] );
427        } elseif ( in_array( 'wp-i18n', $options['dependencies'], true ) ) {
428            _doing_it_wrong(
429                __METHOD__,
430                /* translators: %s is the script handle. */
431                esc_html( sprintf( __( 'Script "%s" depends on wp-i18n but does not specify "textdomain"', 'jetpack-assets' ), $handle ) ),
432                ''
433            );
434        }
435
436        if ( is_string( $options['css_path'] ) && $options['css_path'] !== '' && file_exists( "$dir/{$options['css_path']}" ) ) {
437            $csspath = $options['css_path'];
438            if ( is_rtl() ) {
439                $rtlcsspath = substr( $csspath, 0, -4 ) . '.rtl.css';
440                if ( file_exists( "$dir/$rtlcsspath" ) ) {
441                    $csspath = $rtlcsspath;
442                }
443            }
444
445            $url = self::normalize_path( plugins_url( $csspath, $relative_to ) );
446            if ( null !== $options['minify'] ) {
447                $url = add_query_arg( 'minify', $options['minify'] ? 'true' : 'false', $url );
448            }
449            wp_register_style( $handle, $url, $options['css_dependencies'], $ver, $options['media'] );
450            wp_script_add_data( $handle, 'Jetpack::Assets::hascss', true );
451        } else {
452            wp_script_add_data( $handle, 'Jetpack::Assets::hascss', false );
453        }
454
455        if ( $options['enqueue'] ) {
456            self::enqueue_script( $handle );
457        }
458    }
459
460    /**
461     * Enqueue a script registered with `Assets::register_script`.
462     *
463     * @since 1.12.0
464     * @param string $handle       Name of the script. Should be unique across both scripts and styles.
465     */
466    public static function enqueue_script( $handle ) {
467        wp_enqueue_script( $handle );
468        if ( wp_scripts()->get_data( $handle, 'Jetpack::Assets::hascss' ) ) {
469            wp_enqueue_style( $handle );
470        }
471    }
472
473    /**
474     * Re-hook the bootstraps an older copy's `actions.php` did not know about. See JETPACK-2649.
475     *
476     * Static callables only: `add_action()` dedupes those, but closures and object callables would
477     * register twice. Callers must run before `wp_loaded`.
478     *
479     * @access private
480     * @since 5.0.5
481     */
482    public static function ensure_package_bootstrap() {
483        Shared_Stores_Assets::configure();
484    }
485
486    /**
487     * 'wp_default_scripts' action handler.
488     *
489     * This registers the `wp-jp-i18n-loader` script for use by Webpack bundles built with
490     * `@automattic/i18n-loader-webpack-plugin`.
491     *
492     * @since 1.14.0
493     * @param \WP_Scripts $wp_scripts WP_Scripts instance.
494     */
495    public static function wp_default_scripts_hook( $wp_scripts ) {
496        $data = array(
497            'baseUrl'     => false,
498            'locale'      => determine_locale(),
499            'domainMap'   => array(),
500            'domainPaths' => array(),
501        );
502
503        $lang_dir    = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
504        $content_dir = Jetpack_Constants::get_constant( 'WP_CONTENT_DIR' );
505        $abspath     = Jetpack_Constants::get_constant( 'ABSPATH' );
506
507        // Note: str_starts_with() is not used here, as wp-includes/compat.php may not be loaded at this point.
508        if ( strpos( $lang_dir, $content_dir ) === 0 ) {
509            $data['baseUrl'] = content_url( substr( trailingslashit( $lang_dir ), strlen( trailingslashit( $content_dir ) ) ) );
510        } elseif ( strpos( $lang_dir, $abspath ) === 0 ) {
511            $data['baseUrl'] = site_url( substr( trailingslashit( $lang_dir ), strlen( untrailingslashit( $abspath ) ) ) );
512        }
513
514        foreach ( self::$domain_map as $from => list( $to, $type ) ) {
515            $data['domainMap'][ $from ] = ( 'core' === $type ? '' : "{$type}/" ) . $to;
516        }
517        foreach ( self::$domain_paths as $from => list( , $path ) ) {
518            if ( '' !== $path ) {
519                $data['domainPaths'][ $from ] = trailingslashit( $path );
520            }
521        }
522
523        /**
524         * Filters the i18n state data for use by Webpack bundles built with
525         * `@automattic/i18n-loader-webpack-plugin`.
526         *
527         * @since 1.14.0
528         * @package assets
529         * @param array $data The state data to generate. Expected fields are:
530         *  - `baseUrl`: (string|false) The URL to the languages directory. False if no URL could be determined.
531         *  - `locale`: (string) The locale for the page.
532         *  - `domainMap`: (string[]) A mapping from Composer package textdomains to the corresponding
533         *    `plugins/textdomain` or `themes/textdomain` (or core `textdomain`, but that's unlikely).
534         *  - `domainPaths`: (string[]) A mapping from Composer package textdomains to the corresponding package
535         *     paths.
536         */
537        $data = apply_filters( 'jetpack_i18n_state', $data );
538
539        // Can't use self::register_script(), this action is called too early.
540        if ( file_exists( __DIR__ . '/../build/i18n-loader.asset.php' ) ) {
541            $path  = '../build/i18n-loader.js';
542            $asset = require __DIR__ . '/../build/i18n-loader.asset.php';
543        } else {
544            $path  = 'js/i18n-loader.js';
545            $asset = array(
546                'dependencies' => array( 'wp-i18n' ),
547                'version'      => filemtime( __DIR__ . "/$path" ),
548            );
549        }
550        $url = self::normalize_path( plugins_url( $path, __FILE__ ) );
551        $url = add_query_arg( 'minify', 'true', $url );
552
553        $handle = 'wp-jp-i18n-loader';
554
555        $wp_scripts->add( $handle, $url, $asset['dependencies'], $asset['version'] );
556
557        // Ensure the script is loaded in the footer and deferred.
558        $wp_scripts->add_data( $handle, 'group', 1 );
559
560        if ( ! is_array( $data ) ||
561            ! isset( $data['baseUrl'] ) || ! ( is_string( $data['baseUrl'] ) || false === $data['baseUrl'] ) ||
562            ! isset( $data['locale'] ) || ! is_string( $data['locale'] ) ||
563            ! isset( $data['domainMap'] ) || ! is_array( $data['domainMap'] ) ||
564            ! isset( $data['domainPaths'] ) || ! is_array( $data['domainPaths'] )
565        ) {
566            $wp_scripts->add_inline_script( $handle, 'console.warn( "I18n state deleted by jetpack_i18n_state hook" );' );
567        } elseif ( ! $data['baseUrl'] ) {
568            $wp_scripts->add_inline_script( $handle, 'console.warn( "Failed to determine languages base URL. Is WP_LANG_DIR in the WordPress root?" );' );
569        } else {
570            $data['domainMap']   = (object) $data['domainMap']; // Ensure it becomes a json object.
571            $data['domainPaths'] = (object) $data['domainPaths']; // Ensure it becomes a json object.
572            $wp_scripts->add_inline_script( $handle, 'wp.jpI18nLoader.state = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';' );
573        }
574
575        // Deprecated state module: Depend on wp-i18n to ensure global `wp` exists and because anything needing this will need that too.
576        $wp_scripts->add( 'wp-jp-i18n-state', false, array( 'wp-deprecated', $handle ) );
577        $wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.deprecated( "wp-jp-i18n-state", { alternative: "wp-jp-i18n-loader" } );' );
578        $wp_scripts->add_inline_script( 'wp-jp-i18n-state', 'wp.jpI18nState = wp.jpI18nLoader.state;' );
579    }
580
581    // endregion .
582
583    // ////////////////////
584    // region Textdomain aliasing
585
586    /**
587     * Register a textdomain alias.
588     *
589     * Composer packages included in plugins will likely not use the textdomain of the plugin, while
590     * WordPress's i18n infrastructure will include the translations in the plugin's domain. This
591     * allows for mapping the package's domain to the plugin's.
592     *
593     * Since multiple plugins may use the same package, we include the package's version here so
594     * as to choose the most recent translations (which are most likely to match the package
595     * selected by jetpack-autoloader).
596     *
597     * @since 1.15.0
598     * @param string $from Domain to alias.
599     * @param string $to Domain to alias it to.
600     * @param string $totype What is the target of the alias: 'plugins', 'themes', or 'core'.
601     * @param string $ver Version of the `$from` domain.
602     * @param string $path Path to prepend when lazy-loading from JavaScript.
603     * @throws InvalidArgumentException If arguments are invalid.
604     */
605    public static function alias_textdomain( $from, $to, $totype, $ver, $path = '' ) {
606        if ( ! in_array( $totype, array( 'plugins', 'themes', 'core' ), true ) ) {
607            throw new InvalidArgumentException( 'Type must be "plugins", "themes", or "core"' );
608        }
609
610        if (
611            did_action( 'wp_default_scripts' ) &&
612            // Don't complain during plugin activation.
613            ! defined( 'WP_SANDBOX_SCRAPING' )
614        ) {
615            _doing_it_wrong(
616                __METHOD__,
617                sprintf(
618                    /* translators: 1: wp_default_scripts. 2: Name of the domain being aliased. */
619                    esc_html__( 'Textdomain aliases should be registered before the %1$s hook. This notice was triggered by the %2$s domain.', 'jetpack-assets' ),
620                    '<code>wp_default_scripts</code>',
621                    '<code>' . esc_html( $from ) . '</code>'
622                ),
623                ''
624            );
625        }
626
627        // Where the package lives is needed for JS translation files whether or
628        // not its domain is aliased, so it is recorded before the self-alias
629        // check below.
630        if (
631            empty( self::$domain_paths[ $from ] ) ||
632            Semver::compare( $ver, self::$domain_paths[ $from ][0] ) > 0
633        ) {
634            self::$domain_paths[ $from ] = array( $ver, $path );
635        }
636
637        // A self-alias would make filter_gettext() re-translate into the same
638        // domain, recursing infinitely on any untranslated string (a package
639        // textdomain can collide with its containing plugin's slug).
640        if ( $from === $to ) {
641            return;
642        }
643
644        if ( empty( self::$domain_map[ $from ] ) ) {
645            self::init_domain_map_hooks( $from, array() === self::$domain_map );
646            self::$domain_map[ $from ] = array( $to, $totype, $ver );
647        } elseif ( Semver::compare( $ver, self::$domain_map[ $from ][2] ) > 0 ) {
648            self::$domain_map[ $from ] = array( $to, $totype, $ver );
649        }
650    }
651
652    /**
653     * Register textdomain aliases from a mapping file.
654     *
655     * The mapping file is simply a PHP file that returns an array
656     * with the following properties:
657     *  - 'domain': String, `$to`
658     *  - 'type': String, `$totype`
659     *  - 'packages': Array, mapping `$from` to `array( 'path' => $path, 'ver' => $ver )` (or to the string `$ver` for back compat).
660     *  - 'paths': Array, same shape, for packages whose textdomain is already
661     *    `$to`. Those must not be aliased — that would recurse — but their
662     *    paths are still needed to locate their JavaScript translations.
663     *
664     * @since 1.15.0
665     * @param string $file Mapping file.
666     */
667    public static function alias_textdomains_from_file( $file ) {
668        $data = require $file;
669        foreach ( $data['packages'] as $from => $fromdata ) {
670            if ( ! is_array( $fromdata ) ) {
671                $fromdata = array(
672                    'path' => '',
673                    'ver'  => $fromdata,
674                );
675            }
676            self::alias_textdomain( $from, $data['domain'], $data['type'], $fromdata['ver'], $fromdata['path'] );
677        }
678        // Aliasing a domain to itself is a no-op that `alias_textdomain()`
679        // declines, leaving just the path registration these entries are for.
680        foreach ( $data['paths'] ?? array() as $from => $fromdata ) {
681            self::alias_textdomain( $from, $from, $data['type'], $fromdata['ver'], $fromdata['path'] );
682        }
683    }
684
685    /**
686     * Register the hooks for textdomain aliasing.
687     *
688     * @param string $domain Domain to alias.
689     * @param bool   $firstcall If this is the first call.
690     */
691    private static function init_domain_map_hooks( $domain, $firstcall ) {
692        // If WordPress's plugin API is available already, use it. If not,
693        // drop data into `$wp_filter` for `WP_Hook::build_preinitialized_hooks()`.
694        if ( function_exists( 'add_filter' ) ) {
695            $add_filter = 'add_filter';
696        } else {
697            $add_filter = function ( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) {
698                global $wp_filter;
699                // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
700                $wp_filter[ $hook_name ][ $priority ][] = array(
701                    'accepted_args' => $accepted_args,
702                    'function'      => $callback,
703                );
704            };
705        }
706
707        $add_filter( "gettext_{$domain}", array( self::class, 'filter_gettext' ), 10, 3 );
708        $add_filter( "ngettext_{$domain}", array( self::class, 'filter_ngettext' ), 10, 5 );
709        $add_filter( "gettext_with_context_{$domain}", array( self::class, 'filter_gettext_with_context' ), 10, 4 );
710        $add_filter( "ngettext_with_context_{$domain}", array( self::class, 'filter_ngettext_with_context' ), 10, 6 );
711        if ( $firstcall ) {
712            $add_filter( 'load_script_translation_file', array( self::class, 'filter_load_script_translation_file' ), 10, 3 );
713        }
714    }
715
716    /**
717     * Filter for `gettext`.
718     *
719     * @since 1.15.0
720     * @param string $translation Translated text.
721     * @param string $text Text to translate.
722     * @param string $domain Text domain.
723     * @return string Translated text.
724     */
725    public static function filter_gettext( $translation, $text, $domain ) {
726        if ( $translation === $text ) {
727            // phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
728            $newtext = __( $text, self::$domain_map[ $domain ][0] );
729            if ( $newtext !== $text ) {
730                return $newtext;
731            }
732        }
733        return $translation;
734    }
735
736    /**
737     * Filter for `ngettext`.
738     *
739     * @since 1.15.0
740     * @param string $translation Translated text.
741     * @param string $single The text to be used if the number is singular.
742     * @param string $plural The text to be used if the number is plural.
743     * @param int    $number The number to compare against to use either the singular or plural form.
744     * @param string $domain Text domain.
745     * @return string Translated text.
746     */
747    public static function filter_ngettext( $translation, $single, $plural, $number, $domain ) {
748        if ( $translation === $single || $translation === $plural ) {
749            // phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
750            $translation = _n( $single, $plural, $number, self::$domain_map[ $domain ][0] );
751        }
752        return $translation;
753    }
754
755    /**
756     * Filter for `gettext_with_context`.
757     *
758     * @since 1.15.0
759     * @param string $translation Translated text.
760     * @param string $text Text to translate.
761     * @param string $context Context information for the translators.
762     * @param string $domain Text domain.
763     * @return string Translated text.
764     */
765    public static function filter_gettext_with_context( $translation, $text, $context, $domain ) {
766        if ( $translation === $text ) {
767            // phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
768            $translation = _x( $text, $context, self::$domain_map[ $domain ][0] );
769        }
770        return $translation;
771    }
772
773    /**
774     * Filter for `ngettext_with_context`.
775     *
776     * @since 1.15.0
777     * @param string $translation Translated text.
778     * @param string $single The text to be used if the number is singular.
779     * @param string $plural The text to be used if the number is plural.
780     * @param int    $number The number to compare against to use either the singular or plural form.
781     * @param string $context Context information for the translators.
782     * @param string $domain Text domain.
783     * @return string Translated text.
784     */
785    public static function filter_ngettext_with_context( $translation, $single, $plural, $number, $context, $domain ) {
786        if ( $translation === $single || $translation === $plural ) {
787            // phpcs:ignore WordPress.WP.I18n -- This is a filter hook to map the text domains from our Composer packages to the domain for a containing plugin. See https://wp.me/p2gHKz-oRh#problem-6-text-domains-in-composer-packages
788            $translation = _nx( $single, $plural, $number, $context, self::$domain_map[ $domain ][0] );
789        }
790        return $translation;
791    }
792
793    /**
794     * Filter for `load_script_translation_file`.
795     *
796     * @since 1.15.0
797     * @param string|false $file Path to the translation file to load. False if there isn't one.
798     * @param string       $handle Name of the script to register a translation domain to.
799     * @param string       $domain The text domain.
800     */
801    public static function filter_load_script_translation_file( $file, $handle, $domain ) {
802        if ( false !== $file && isset( self::$domain_map[ $domain ] ) && ! is_readable( $file ) ) {
803            // Determine the part of the filename after the domain.
804            $suffix = basename( $file );
805            $l      = strlen( $domain );
806            if ( substr( $suffix, 0, $l ) !== $domain || '-' !== $suffix[ $l ] ) {
807                return $file;
808            }
809            $suffix   = substr( $suffix, $l );
810            $lang_dir = Jetpack_Constants::get_constant( 'WP_LANG_DIR' );
811
812            // Look for replacement files.
813            list( $newdomain, $type ) = self::$domain_map[ $domain ];
814            $newfile                  = $lang_dir . ( 'core' === $type ? '/' : "/{$type}/" ) . $newdomain . $suffix;
815            if ( is_readable( $newfile ) ) {
816                return $newfile;
817            }
818        }
819        return $file;
820    }
821
822    // endregion .
823}
824
825// Enable section folding in vim:
826// vim: foldmarker=//\ region,//\ endregion foldmethod=marker
827// .