PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / jetpack_vendor / automattic / jetpack-assets / src / class-assets.php

class-assets.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at jetpack_vendor/automattic/jetpack-assets/src/class-assets.php

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