Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.91% |
90 / 99 |
|
60.00% |
3 / 5 |
CRAP | |
0.00% |
0 / 1 |
| WP_Build_Polyfills | |
90.91% |
90 / 99 |
|
60.00% |
3 / 5 |
31.72 | |
0.00% |
0 / 1 |
| register | |
74.07% |
20 / 27 |
|
0.00% |
0 / 1 |
10.41 | |||
| get_consumers | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register_scripts | |
100.00% |
49 / 49 |
|
100.00% |
1 / 1 |
13 | |||
| is_gutenberg_version_safe | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| register_modules | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
5.04 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Polyfill registration for Core packages not available or incomplete in older WordPress versions. |
| 4 | * |
| 5 | * Conditionally registers wp-notices, wp-private-apis, wp-theme (classic scripts) and |
| 6 | * `@wordpress/boot`, `@wordpress/route`, `@wordpress/a11y` (script modules) |
| 7 | * ONLY when they are not already provided by Core or Gutenberg. |
| 8 | * |
| 9 | * @package automattic/jetpack-wp-build-polyfills |
| 10 | */ |
| 11 | |
| 12 | namespace Automattic\Jetpack\WP_Build_Polyfills; |
| 13 | |
| 14 | /** |
| 15 | * Registers polyfill scripts and modules for WordPress Core packages. |
| 16 | */ |
| 17 | class WP_Build_Polyfills { |
| 18 | |
| 19 | /** |
| 20 | * Available polyfill handles for classic scripts. |
| 21 | */ |
| 22 | const SCRIPT_HANDLES = array( 'wp-notices', 'wp-private-apis', 'wp-theme', 'wp-views' ); |
| 23 | |
| 24 | /** |
| 25 | * Available polyfill module IDs. |
| 26 | */ |
| 27 | const MODULE_IDS = array( '@wordpress/boot', '@wordpress/route', '@wordpress/a11y' ); |
| 28 | |
| 29 | /** |
| 30 | * Minimum Gutenberg plugin version known to ship a private-apis allowlist |
| 31 | * that includes the dashboard packages used by this package's current build. |
| 32 | */ |
| 33 | const GUTENBERG_PRIVATE_APIS_MIN_VERSION = '23.5.0'; |
| 34 | |
| 35 | /** |
| 36 | * Tracks which polyfills have been requested and by which consumers. |
| 37 | * |
| 38 | * Keys are polyfill handles/module IDs, values are arrays of consumer names. |
| 39 | * |
| 40 | * @var array<string, string[]> |
| 41 | */ |
| 42 | private static $requested = array(); |
| 43 | |
| 44 | /** |
| 45 | * Whether the wp_default_scripts hook has already been added. |
| 46 | * |
| 47 | * @var bool |
| 48 | */ |
| 49 | private static $hooked = false; |
| 50 | |
| 51 | /** |
| 52 | * The WordPress version below which force-replacements are applied. |
| 53 | * When multiple consumers call register() with different thresholds, |
| 54 | * the highest threshold wins (most conservative approach). |
| 55 | * |
| 56 | * @var string |
| 57 | */ |
| 58 | private static $wp_version_threshold = '7.0'; |
| 59 | |
| 60 | /** |
| 61 | * Register polyfill scripts and modules. |
| 62 | * |
| 63 | * Call this early (e.g. during plugin load) — it hooks into wp_default_scripts |
| 64 | * at priority 20 so Core (default) and Gutenberg (priority 10) register first. |
| 65 | * |
| 66 | * When multiple consumers call this method with different thresholds, the |
| 67 | * highest threshold wins (most conservative — polyfills active on more versions). |
| 68 | * |
| 69 | * @param string $consumer A unique identifier for the consumer (e.g. plugin slug). |
| 70 | * @param string[] $polyfills List of polyfill handles/module IDs to register. |
| 71 | * Use class constants SCRIPT_HANDLES and MODULE_IDS for reference. |
| 72 | * @param string $wp_version_threshold The WordPress version below which force-replacements |
| 73 | * are applied. Defaults to '7.0'. |
| 74 | */ |
| 75 | public static function register( $consumer, $polyfills, $wp_version_threshold = '7.0' ) { |
| 76 | foreach ( $polyfills as $handle ) { |
| 77 | if ( ! in_array( $handle, self::SCRIPT_HANDLES, true ) && ! in_array( $handle, self::MODULE_IDS, true ) ) { |
| 78 | continue; |
| 79 | } |
| 80 | if ( ! isset( self::$requested[ $handle ] ) ) { |
| 81 | self::$requested[ $handle ] = array(); |
| 82 | } |
| 83 | if ( ! in_array( $consumer, self::$requested[ $handle ], true ) ) { |
| 84 | self::$requested[ $handle ][] = $consumer; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | if ( version_compare( $wp_version_threshold, self::$wp_version_threshold, '>' ) ) { |
| 89 | self::$wp_version_threshold = $wp_version_threshold; |
| 90 | } |
| 91 | |
| 92 | if ( self::$hooked ) { |
| 93 | return; |
| 94 | } |
| 95 | self::$hooked = true; |
| 96 | |
| 97 | $package_root = dirname( __DIR__ ); |
| 98 | $build_dir = $package_root . '/build'; |
| 99 | $base_file = $package_root . '/composer.json'; |
| 100 | |
| 101 | // `wp_default_scripts` fires once when the WP_Scripts singleton is |
| 102 | // instantiated. If something has already initialized `wp_scripts()` — |
| 103 | // common on admin requests where WP or other plugins register scripts |
| 104 | // before `admin_menu` priority 1 runs — adding this hook here is too |
| 105 | // late and the polyfills never register. Detect that case and run the |
| 106 | // registration synchronously so consumers can rely on the script |
| 107 | // handles and module IDs being available regardless of init order. |
| 108 | if ( did_action( 'wp_default_scripts' ) ) { |
| 109 | self::register_scripts( wp_scripts(), $build_dir, $base_file, self::$wp_version_threshold ); |
| 110 | self::register_modules( $build_dir, $base_file ); |
| 111 | return; |
| 112 | } |
| 113 | |
| 114 | add_action( |
| 115 | 'wp_default_scripts', |
| 116 | function ( $scripts ) use ( $build_dir, $base_file ) { |
| 117 | self::register_scripts( $scripts, $build_dir, $base_file, self::$wp_version_threshold ); |
| 118 | self::register_modules( $build_dir, $base_file ); |
| 119 | }, |
| 120 | 20 |
| 121 | ); |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Get the map of requested polyfills and their consumers. |
| 126 | * |
| 127 | * @return array<string, string[]> Keys are polyfill handles/module IDs, values are consumer names. |
| 128 | */ |
| 129 | public static function get_consumers() { |
| 130 | return self::$requested; |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Register polyfill classic scripts. |
| 135 | * |
| 136 | * @param \WP_Scripts $scripts The WP_Scripts instance. |
| 137 | * @param string $build_dir Absolute path to the build directory. |
| 138 | * @param string $base_file File path for plugins_url() computation. |
| 139 | * @param string $wp_version_threshold WP version below which force-replacements apply. |
| 140 | */ |
| 141 | private static function register_scripts( $scripts, $build_dir, $base_file, $wp_version_threshold ) { |
| 142 | // Force-replace only when Core's bundled scripts are incomplete and |
| 143 | // Gutenberg cannot be trusted to provide a compatible implementation. |
| 144 | $gutenberg_version = defined( 'GUTENBERG_VERSION' ) ? GUTENBERG_VERSION : null; |
| 145 | |
| 146 | $polyfills = array( |
| 147 | 'wp-notices' => array( |
| 148 | 'path' => 'notices', |
| 149 | 'force_threshold' => '7.0', |
| 150 | // Only force-replace on older WP without Gutenberg: older Core |
| 151 | // versions ship notices without SnackbarNotices and InlineNotices |
| 152 | // component exports that @wordpress/boot depends on. |
| 153 | ), |
| 154 | 'wp-private-apis' => array( |
| 155 | 'path' => 'private-apis', |
| 156 | 'force_threshold' => '7.1', |
| 157 | 'gutenberg_min_version' => self::GUTENBERG_PRIVATE_APIS_MIN_VERSION, |
| 158 | // WP 7.0 and older versions ship private-apis with an incomplete |
| 159 | // allowlist that rejects @wordpress/theme, @wordpress/route, and |
| 160 | // newer dashboard packages. Active Gutenberg is only a safe |
| 161 | // substitute once its private-apis allowlist includes those |
| 162 | // dashboard packages too. |
| 163 | ), |
| 164 | 'wp-theme' => array( |
| 165 | 'path' => 'theme', |
| 166 | ), |
| 167 | 'wp-views' => array( |
| 168 | 'path' => 'views', |
| 169 | ), |
| 170 | ); |
| 171 | |
| 172 | foreach ( $polyfills as $handle => $data ) { |
| 173 | if ( ! isset( self::$requested[ $handle ] ) ) { |
| 174 | continue; |
| 175 | } |
| 176 | |
| 177 | $asset_file = $build_dir . '/scripts/' . $data['path'] . '/index.asset.php'; |
| 178 | |
| 179 | if ( ! file_exists( $asset_file ) ) { |
| 180 | continue; |
| 181 | } |
| 182 | |
| 183 | $force_threshold = $data['force_threshold'] ?? null; |
| 184 | if ( null !== $force_threshold && version_compare( $wp_version_threshold, $force_threshold, '>' ) ) { |
| 185 | $force_threshold = $wp_version_threshold; |
| 186 | } |
| 187 | |
| 188 | $force = null !== $force_threshold |
| 189 | && ! self::is_gutenberg_version_safe( $data['gutenberg_min_version'] ?? null, $gutenberg_version ) |
| 190 | && version_compare( $GLOBALS['wp_version'] ?? '0', $force_threshold, '<' ); |
| 191 | |
| 192 | if ( ! $force && $scripts->query( $handle, 'registered' ) ) { |
| 193 | continue; |
| 194 | } |
| 195 | |
| 196 | // Deregister first when forcing replacement of an existing registration. |
| 197 | if ( $force && $scripts->query( $handle, 'registered' ) ) { |
| 198 | $scripts->remove( $handle ); |
| 199 | } |
| 200 | |
| 201 | $asset = require $asset_file; |
| 202 | |
| 203 | $scripts->add( |
| 204 | $handle, |
| 205 | plugins_url( 'build/scripts/' . $data['path'] . '/index.js', $base_file ), |
| 206 | $asset['dependencies'], |
| 207 | $asset['version'] |
| 208 | ); |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Check whether the active Gutenberg plugin can satisfy a forced script. |
| 214 | * |
| 215 | * @param string|null $minimum_version Minimum Gutenberg version required for the script, or null when any active Gutenberg is sufficient. |
| 216 | * @param string|null $gutenberg_version Active Gutenberg version, or null when Gutenberg is inactive. |
| 217 | * @return bool True when Gutenberg is active and new enough. |
| 218 | */ |
| 219 | private static function is_gutenberg_version_safe( $minimum_version, $gutenberg_version ) { |
| 220 | if ( null === $gutenberg_version ) { |
| 221 | return false; |
| 222 | } |
| 223 | |
| 224 | if ( null === $minimum_version ) { |
| 225 | return true; |
| 226 | } |
| 227 | |
| 228 | return version_compare( $gutenberg_version, $minimum_version, '>=' ); |
| 229 | } |
| 230 | |
| 231 | /** |
| 232 | * Register polyfill script modules. |
| 233 | * |
| 234 | * Call to wp_register_script_module() silently ignores duplicate registrations (first wins), |
| 235 | * so no explicit is_registered check is needed. |
| 236 | * |
| 237 | * @param string $build_dir Absolute path to the build directory. |
| 238 | * @param string $base_file File path for plugins_url() computation. |
| 239 | */ |
| 240 | private static function register_modules( $build_dir, $base_file ) { |
| 241 | if ( ! function_exists( 'wp_register_script_module' ) ) { |
| 242 | return; |
| 243 | } |
| 244 | |
| 245 | $modules = array( 'boot', 'route', 'a11y' ); |
| 246 | |
| 247 | foreach ( $modules as $name ) { |
| 248 | $module_id = '@wordpress/' . $name; |
| 249 | |
| 250 | if ( ! isset( self::$requested[ $module_id ] ) ) { |
| 251 | continue; |
| 252 | } |
| 253 | |
| 254 | $asset_file = $build_dir . '/modules/' . $name . '/index.asset.php'; |
| 255 | |
| 256 | if ( ! file_exists( $asset_file ) ) { |
| 257 | continue; |
| 258 | } |
| 259 | |
| 260 | $asset = require $asset_file; |
| 261 | |
| 262 | wp_register_script_module( |
| 263 | $module_id, |
| 264 | plugins_url( 'build/modules/' . $name . '/index.js', $base_file ), |
| 265 | $asset['module_dependencies'] ?? array(), |
| 266 | $asset['version'] |
| 267 | ); |
| 268 | } |
| 269 | } |
| 270 | } |