Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
16.12% covered (danger)
16.12%
84 / 521
4.44% covered (danger)
4.44%
2 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Mu_Wpcom
16.15% covered (danger)
16.15%
84 / 520
4.44% covered (danger)
4.44%
2 / 45
20799.59
0.00% covered (danger)
0.00%
0 / 1
 init
73.77% covered (warning)
73.77%
45 / 61
0.00% covered (danger)
0.00%
0 / 1
17.54
 load_wpcom_simple_jetpack_ai
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 schedule_translation_updates
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_update_translations
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
420
 clear_translation_destination
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 get_all_active_locales
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 load_features
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
56
 load_wpcom_user_features
16.28% covered (danger)
16.28%
7 / 43
0.00% covered (danger)
0.00%
0 / 1
68.68
 load_wpcom_sites_features
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 load_etk_features_flags
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 load_etk_features
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 load_newspack_blocks
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 load_coming_soon
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
90
 load_launchpad
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_rest_api_endpoints
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 load_jetpack_mu_wpcom_settings
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 load_map_block_settings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 load_newsletter_categories_settings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 unbind_focusout_on_wp_admin_bar_menu_toggle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 should_disable_comment_experience
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
90
 load_verbum_comments
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 load_jetpack_comments_routes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_verbum_comments_admin
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_verbum_moderate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_backup
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_scan
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_my_jetpack
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_simple_odyssey_stats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_premium_analytics_wordads_section
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_premium_analytics_videopress_widgets
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_premium_analytics_stats_settings_fields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_custom_css
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 load_wpcom_random_redirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_social_links
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 set_wpcom_blog_id_script_data
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 enable_gutenberg_react_19_experiment
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
90
 enable_woocommerce_block_editor_unified_assets
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 has_react_19_incompatible_gutenberg
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 enable_extensible_site_editor_experiment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 has_react_19_incompatible_extension
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 is_react_19_incompatible_version
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 add_jetpack_script_data_for_p2
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 log2logstash
71.43% covered (warning)
71.43%
15 / 21
0.00% covered (danger)
0.00%
0 / 1
9.49
 resolve_logstash_blog_id
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
5.93
 queue_logstash_http
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
7.10
1<?php
2/**
3 * Enhances your site with features powered by WordPress.com
4 * This package is intended for internal use on WordPress.com sites only (simple and Atomic).
5 * Internal PT Reference: p9dueE-6jY-p2
6 *
7 * @package automattic/jetpack-mu-wpcom
8 */
9
10namespace Automattic\Jetpack;
11
12define( 'WPCOM_ADMIN_BAR_UNIFICATION', true );
13/**
14 * Jetpack_Mu_Wpcom main class.
15 */
16class Jetpack_Mu_Wpcom {
17    const PACKAGE_VERSION = '6.11.0';
18    const PKG_DIR         = __DIR__ . '/../';
19    const BASE_DIR        = __DIR__ . '/';
20    const BASE_FILE       = __FILE__;
21
22    // Gutenberg plugin releases known to break with React 19.
23    const REACT_19_INCOMPATIBLE_GUTENBERG = array( '23.9.0' );
24
25    /*
26     * Themes (by template slug) and plugins (by basename) known to break with React 19,
27     * each mapped to the release that fixed it. Null means no fixed release exists yet.
28     */
29    const REACT_19_INCOMPATIBLE_THEMES  = array(
30        'divi'     => null,
31        'woodmart' => '8.6.1',
32    );
33    const REACT_19_INCOMPATIBLE_PLUGINS = array(
34        'wp-table-builder/wp-table-builder.php'          => null,
35        'ultimate-blocks/ultimate-blocks.php'            => '3.6.0',
36        'beehive-analytics/beehive-analytics.php'        => null,
37        'xspeed/xspeed.php'                              => null,
38        'classified-listing/classified-listing.php'      => null,
39        'advanced-coupons-for-woocommerce-free/advanced-coupons-for-woocommerce-free.php' => null,
40        'advanced-coupons-for-woocommerce/advanced-coupons-for-woocommerce.php' => null,
41        'sb-analytics/sb-analytics-pro.php'              => null,
42        'brave-popup-builder/index.php'                  => null,
43        'bravepopup-pro/index.php'                       => null,
44        'astra-sites/astra-sites.php'                    => null,
45        'llms-full-txt-generator/llms-txt-generator.php' => null,
46        'wp-post-author/aft-wp-post-author.php'          => null,
47        'adminify/adminify.php'                          => null,
48        'meetinghub/meetinghub.php'                      => null,
49        'mail-mint/mail-mint.php'                        => null,
50        'wp-letsencrypt-ssl-pro/wp-letsencrypt.php'      => null,
51        'astra-pro-sites/astra-pro-sites.php'            => null,
52        'echo-rewards/echo-rewards.php'                  => null,
53        'forminator/forminator.php'                      => null,
54        'iron-security/iron-security.php'                => null,
55        'stackable-ultimate-gutenberg-blocks/plugin.php' => null,
56    );
57
58    /**
59     * Initialize the class.
60     */
61    public static function init() {
62        if ( did_action( 'jetpack_mu_wpcom_initialized' ) ) {
63            return;
64        }
65
66        // Shared code for src/features.
67        require_once self::PKG_DIR . 'src/common/index.php'; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
68        require_once __DIR__ . '/common/fatal-error-signature.php';
69        require_once __DIR__ . '/utils.php';
70
71        // Atomic only — Simple sites can't install plugins. The confirmation
72        // probe wires its `pre_option_active_plugins` filter at mu-plugin
73        // time, before WP loads active plugins.
74        if ( Constants::is_true( 'IS_ATOMIC' ) ) {
75            require_once __DIR__ . '/features/plugin-conflicts-guardian/probe-confirm-bootstrap.php';
76        }
77
78        /*
79         * Feature flag overrides answer the jetpack-feature-flags resolution
80         * filter, and a flag can be checked well before plugins_loaded, so this
81         * is wired here at mu-plugin time rather than from load_features().
82         * Registering the filter reads nothing — the option is only touched when
83         * a flag is actually resolved.
84         *
85         * The overrides themselves are site-wide, but the screen that sets them
86         * is Automatticians-only internal tooling and gates itself accordingly.
87         */
88        require_once __DIR__ . '/features/wpcom-feature-flags/class-wpcom-feature-flags.php';
89        \Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Feature_Flags::init();
90
91        // Load features that don't need any special loading considerations.
92        add_action( 'plugins_loaded', array( __CLASS__, 'load_features' ) );
93
94        // Load features that only apply to WordPress.com-connected users.
95        add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_user_features' ) );
96        add_action( 'plugins_loaded', array( __CLASS__, 'load_etk_features' ) );
97
98        // Load features that only apply to WordPress.com sites, regardless of whether the users are connected.
99        add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_sites_features' ) );
100
101        // Load ETK features flag to turn off the features in the ETK plugin.
102        // It needs higher priority than the ETK plugin.
103        add_action( 'plugins_loaded', array( __CLASS__, 'load_etk_features_flags' ), 0 );
104
105        /*
106         * Please double-check whether you really need to load your feature separately.
107         * Chances are you can just add it to the `load_features` method.
108         */
109        add_action( 'plugins_loaded', array( __CLASS__, 'load_launchpad' ), 0 );
110        add_action( 'plugins_loaded', array( __CLASS__, 'load_coming_soon' ) );
111        add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_rest_api_endpoints' ) );
112        add_action( 'plugins_loaded', array( __CLASS__, 'load_newspack_blocks' ) );
113
114        // Jetpack builds cut before Jetpack Comments went on by default only load it when this returns true. Remove once Atomic is past them.
115        add_filter( 'jetpack_comments_new_hotness', '__return_true' );
116
117        // These features run only on simple sites.
118        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
119            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_simple_jetpack_ai' ) );
120            add_action( 'plugins_loaded', array( __CLASS__, 'load_verbum_comments' ) );
121            add_action( 'plugins_loaded', array( __CLASS__, 'load_jetpack_comments_routes' ) );
122            add_action( 'plugins_loaded', array( __CLASS__, 'load_verbum_moderate' ) );
123            add_action( 'wp_loaded', array( __CLASS__, 'load_verbum_comments_admin' ) );
124            add_action( 'admin_menu', array( __CLASS__, 'load_wpcom_simple_odyssey_stats' ) );
125            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_random_redirect' ) );
126            add_action( 'plugins_loaded', array( __CLASS__, 'load_premium_analytics_stats_settings_fields' ) );
127
128            // Not on admin_menu: wpcom checks this function exists before then.
129            require_once __DIR__ . '/features/wpcom-simple-odyssey-stats/odyssey-stats-replaced.php';
130        }
131
132        // The Backup and Protect pages serve both platforms: they offer the transfer on Simple
133        // and the plan upgrade on WoA, and step aside once the feature is live.
134        if ( ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || Constants::is_true( 'IS_ATOMIC' ) ) {
135            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_backup' ) );
136            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_scan' ) );
137        }
138
139        // A lightweight My Jetpack page for Simple, which steps aside wherever the real My Jetpack registers.
140        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
141            add_action( 'plugins_loaded', array( __CLASS__, 'load_wpcom_my_jetpack' ) );
142        }
143
144        // At mu-plugin scope, because the Jetpack plugin can resolve these filters before plugins_loaded loads the pages.
145        if ( Constants::is_true( 'IS_ATOMIC' ) ) {
146            add_filter( 'jetpack_backup_dashboard_enabled', array( \Automattic\Jetpack\Jetpack_Mu_Wpcom\WPCOM_Backup::class, 'filter_jetpack_backup_dashboard' ) );
147            add_filter( 'jetpack_get_available_modules', array( \Automattic\Jetpack\Jetpack_Mu_Wpcom\WPCOM_Scan::class, 'filter_available_modules' ) );
148            add_filter( 'jetpack_feature_upgrade', array( \Automattic\Jetpack\Jetpack_Mu_Wpcom\WPCOM_Feature_Upgrades::class, 'filter_feature_upgrade' ), 10, 3 );
149            // WoA runs a platform firewall, which is why Jetpack's stays off there.
150            add_filter( 'jetpack_protect_firewall_provided_by_host', '__return_true' );
151            // WoA sites should list threats, but not announce them.
152            add_filter( 'jetpack_protect_threat_notifications_enabled', '__return_false' );
153        }
154
155        // These features run only on atomic sites.
156        if ( defined( 'IS_ATOMIC' ) && IS_ATOMIC ) {
157            add_action( 'plugins_loaded', array( __CLASS__, 'load_custom_css' ) );
158            add_action( 'init', array( __CLASS__, 'schedule_translation_updates' ) );
159            // Match WPCOM site dashboard functionality for WordPress.com sites.
160            add_filter( 'jetpack_protect_threat_ignoring_enabled', '__return_false' );
161        }
162
163        // Premium Analytics offers the Ads tab on Simple and Atomic sites whose plan includes WordAds and that have it on.
164        add_action( 'plugins_loaded', array( __CLASS__, 'load_premium_analytics_wordads_section' ) );
165
166        // Premium Analytics seeds the Top videos widget wherever the plan includes VideoPress, on Simple and Atomic.
167        add_action( 'plugins_loaded', array( __CLASS__, 'load_premium_analytics_videopress_widgets' ) );
168
169        // Unified navigation fix for changes in WordPress 6.2.
170        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'unbind_focusout_on_wp_admin_bar_menu_toggle' ) );
171
172        // Load the Map block settings.
173        add_action( 'enqueue_block_assets', array( __CLASS__, 'load_jetpack_mu_wpcom_settings' ), 999 );
174
175        // Load the Map block settings.
176        add_action( 'enqueue_block_assets', array( __CLASS__, 'load_map_block_settings' ), 999 );
177
178        // Load the Newsletter category settings.
179        add_action( 'enqueue_block_assets', array( __CLASS__, 'load_newsletter_categories_settings' ), 999 );
180
181        // Load the Social Links feature.
182        add_action( 'init', array( __CLASS__, 'load_social_links' ), 30 );
183
184        // Filter to ensure JetpackScriptData.site.host and is_wpcom_platform is set, to ensure Jetpack blocks work as expected via P2.
185        add_filter( 'jetpack_public_js_script_data', array( __CLASS__, 'add_jetpack_script_data_for_p2' ), 10, 1 );
186
187        // Filter to populate JetpackScriptData.site.wpcom.blog_id with the actual WP.com blog ID.
188        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'set_wpcom_blog_id_script_data' ), 10, 1 );
189
190        // Enable the `gutenberg-react-19` Gutenberg experiment on selected sites.
191        add_filter( 'option_gutenberg-experiments', array( __CLASS__, 'enable_gutenberg_react_19_experiment' ) );
192        add_filter( 'default_option_gutenberg-experiments', array( __CLASS__, 'enable_gutenberg_react_19_experiment' ) );
193
194        // Allow blog stickers to override the WooCommerce unified block editor assets option.
195        add_filter( 'option_woocommerce_feature_block_editor_unified_assets_enabled', array( __CLASS__, 'enable_woocommerce_block_editor_unified_assets' ) );
196        add_filter( 'default_option_woocommerce_feature_block_editor_unified_assets_enabled', array( __CLASS__, 'enable_woocommerce_block_editor_unified_assets' ) );
197
198        if ( wpcom_has_blog_sticker( 'gutenberg-extensible-site-editor', get_wpcom_blog_id() ) ) {
199            add_filter( 'option_gutenberg-experiments', array( __CLASS__, 'enable_extensible_site_editor_experiment' ) );
200
201            // Priority 20 because register_setting() hooks core's filter_default_option() at 10,
202            // and that callback discards the value it is handed and returns the registered default.
203            add_filter( 'default_option_gutenberg-experiments', array( __CLASS__, 'enable_extensible_site_editor_experiment' ), 20 );
204        }
205
206        /**
207         * Runs right after the Jetpack_Mu_Wpcom package is initialized.
208         *
209         * @since 0.1.2
210         */
211        do_action( 'jetpack_mu_wpcom_initialized' );
212    }
213
214    /**
215     * Load the Jetpack AI Hub integration on WordPress.com Simple sites.
216     */
217    public static function load_wpcom_simple_jetpack_ai() {
218        /**
219         * Filters whether WordPress.com Simple should register the Jetpack AI Hub.
220         *
221         * @since 6.11.0
222         *
223         * @param bool $load Whether to load the AI Hub integration.
224         */
225        if ( ! apply_filters( 'jetpack_mu_wpcom_load_jetpack_ai_hub', false ) ) {
226            return;
227        }
228
229        require_once __DIR__ . '/features/jetpack-ai-hub/jetpack-ai-hub.php';
230    }
231
232    /**
233     * Schedules translation updates for Jetpack MU WPCOM.
234     *
235     * This function sets up the necessary cron jobs to ensure that translation files
236     * are regularly updated.
237     *
238     * @return void
239     */
240    public static function schedule_translation_updates() {
241        add_action( 'wpcomsh_translation_update', array( __CLASS__, 'maybe_update_translations' ) );
242
243        if ( ! wp_next_scheduled( 'wpcomsh_translation_update' ) ) {
244            wp_schedule_event( time(), 'twicedaily', 'wpcomsh_translation_update' );
245        }
246    }
247
248    /**
249     * Fetches and installs Jetpack-mu-wpcom package translations when needed.
250     */
251    public static function maybe_update_translations() {
252        global $wp_filesystem;
253        if ( ! $wp_filesystem ) {
254            require_once ABSPATH . 'wp-admin/includes/file.php';
255            WP_Filesystem();
256        }
257
258        $locales = self::get_all_active_locales();
259        if ( empty( $locales ) ) {
260            return;
261        }
262
263        $plugins_request_data              = array();
264        $plugin_language_pack_destinations = array(
265            'jetpack-mu-wpcom' => WP_LANG_DIR . '/mu-plugins/',
266            'wpcomsh'          => WP_LANG_DIR . '/mu-plugins/',
267        );
268
269        foreach ( array_keys( $plugin_language_pack_destinations ) as $plugin_slug ) {
270            $plugins_request_data[ $plugin_slug ] = array( 'version' => 'latest' );
271        }
272
273        $response = wp_remote_post(
274            'https://translate.wordpress.com/api/translations-updates/wpcom/plugins',
275            array(
276                'body'    => wp_json_encode(
277                    array(
278                        'locales' => $locales,
279                        'plugins' => $plugins_request_data,
280                    ),
281                    JSON_UNESCAPED_SLASHES
282                ),
283                'headers' => array( 'Content-Type' => 'application/json' ),
284                'timeout' => 10,
285            )
286        );
287
288        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
289            return;
290        }
291
292        $data = json_decode( wp_remote_retrieve_body( $response ), true );
293
294        // API error, api returned but something was wrong.
295        if ( array_key_exists( 'success', $data ) && false === $data['success'] ) {
296            return;
297        }
298
299        if ( ! is_array( $data ) || ! is_array( $data['data'] ) ) {
300            return;
301        }
302
303        foreach ( $data['data'] as $plugin_name => $language_packs ) {
304            if ( ! isset( $plugin_language_pack_destinations[ $plugin_name ] ) ) {
305                continue;
306            }
307
308            $destination = $plugin_language_pack_destinations[ $plugin_name ];
309
310            foreach ( $language_packs as $translation ) {
311                $locale        = $translation['wp_locale'] ?? '';
312                $package_url   = $translation['package'] ?? '';
313                $last_modified = $translation['last_modified'] ?? '';
314
315                if ( ! $locale || ! $package_url || ! $last_modified ) {
316                    continue;
317                }
318
319                $local_po_file = "{$destination}/$plugin_name-{$locale}.po";
320                if ( file_exists( $local_po_file ) ) {
321                    $local_po_data                       = wp_get_pomo_file_data( $local_po_file );
322                    $installed_translation_revision_time = new \DateTime( $local_po_data['PO-Revision-Date'] );
323                    $new_translation_revision_time       = new \DateTime( $last_modified );
324
325                    // Skip if translation language pack is not newer than what is installed already.
326                    if ( $new_translation_revision_time <= $installed_translation_revision_time ) {
327                        continue;
328                    }
329                }
330
331                $translation_zip_file = download_url( $package_url );
332                if ( is_wp_error( $translation_zip_file ) ) {
333                    continue;
334                }
335
336                static::clear_translation_destination( $destination, $plugin_name, $locale );
337
338                $unzip_result = unzip_file( $translation_zip_file, $destination );
339                if ( is_wp_error( $unzip_result ) ) {
340                    wp_delete_file( $translation_zip_file );
341                    continue;
342                }
343
344                wp_delete_file( $translation_zip_file );
345
346            }
347        }
348    }
349
350    /**
351     * Clears the translation destination by deleting existing translation files.
352     *
353     * @param string $local_destination The local destination path.
354     * @param string $plugin_slug The plugin slug.
355     * @param string $locale The locale.
356     */
357    public static function clear_translation_destination( $local_destination, $plugin_slug, $locale ) {
358        global $wp_filesystem;
359
360        if ( ! $wp_filesystem ) {
361            require_once ABSPATH . 'wp-admin/includes/file.php';
362            WP_Filesystem();
363        }
364
365        $files = array(
366            "{$local_destination}{$plugin_slug}-{$locale}.po",
367            "{$local_destination}{$plugin_slug}-{$locale}.mo",
368            "{$local_destination}{$plugin_slug}-{$locale}.l10n.php",
369        );
370
371        $json_files = glob( "{$local_destination}{$plugin_slug}-{$locale}-*.json" );
372        if ( $json_files ) {
373            $files = array_merge( $files, $json_files );
374        }
375
376        foreach ( $files as $file ) {
377            if ( $wp_filesystem->exists( $file ) ) {
378                $wp_filesystem->delete( $file );
379            }
380        }
381    }
382
383    /**
384     * Retrieves all active locales for the site.
385     */
386    public static function get_all_active_locales() {
387        $locales = array( get_locale() );
388
389        $available_languages = get_available_languages();
390        if ( ! empty( $available_languages ) ) {
391            $locales = array_merge( $locales, $available_languages );
392        }
393        return array_values( array_unique( $locales ) );
394    }
395
396    /**
397     * Load features that don't need any special loading considerations.
398     */
399    public static function load_features() {
400        \Automattic\Jetpack\ExPlat::init();
401
402        // Please keep the features in alphabetical order.
403        require_once __DIR__ . '/features/100-year-plan/enhanced-ownership.php';
404        require_once __DIR__ . '/features/100-year-plan/locked-mode.php';
405        require_once __DIR__ . '/features/admin-color-schemes/admin-color-schemes.php';
406        require_once __DIR__ . '/features/ai-launchpad/ai-launchpad.php';
407        require_once __DIR__ . '/features/block-patterns/block-patterns.php';
408        require_once __DIR__ . '/features/blog-privacy/blog-privacy.php';
409        require_once __DIR__ . '/features/cloudflare-analytics/cloudflare-analytics.php';
410        require_once __DIR__ . '/features/code-editor/class-code-editor.php';
411        require_once __DIR__ . '/features/wpcom-blocks/code/class-code-block.php';
412        require_once __DIR__ . '/features/css-monkey-patches/index.php';
413        require_once __DIR__ . '/features/editor-polyfills/editor-polyfills.php';
414        require_once __DIR__ . '/features/editor-welcome-guides/editor-welcome-guides.php';
415        if ( Constants::is_true( 'IS_ATOMIC' ) ) {
416            require_once __DIR__ . '/features/editor-welcome-guides/editor-welcome-guides-atomic.php';
417        }
418        require_once __DIR__ . '/features/error-reporting/error-reporting.php';
419        require_once __DIR__ . '/features/first-posts-stream/first-posts-stream-helpers.php';
420        require_once __DIR__ . '/features/font-smoothing-antialiased/font-smoothing-antialiased.php';
421        require_once __DIR__ . '/features/google-analytics/google-analytics.php';
422        require_once __DIR__ . '/features/holiday-snow/class-holiday-snow.php';
423        require_once __DIR__ . '/features/launch-button/index.php';
424        require_once __DIR__ . '/features/layout-grid-usage-tracking/layout-grid-usage-tracking.php';
425        require_once __DIR__ . '/features/logo-tool/logo-tool.php';
426        require_once __DIR__ . '/features/marketplace-products-updater/class-marketplace-products-updater.php';
427        require_once __DIR__ . '/features/media/heif-support.php';
428        if ( Constants::is_true( 'IS_ATOMIC' ) ) {
429            require_once __DIR__ . '/features/plugin-conflicts-guardian/plugin-conflicts-guardian.php';
430        }
431        require_once __DIR__ . '/features/post-categories/quick-actions.php';
432        require_once __DIR__ . '/features/post-like-from-email/post-like-from-email.php';
433        require_once __DIR__ . '/features/podcast-feed-credit/podcast-feed-credit.php';
434        require_once __DIR__ . '/features/site-editor-dashboard-link/site-editor-dashboard-link.php';
435        require_once __DIR__ . '/features/wpcom-attachment-pages/wpcom-attachment-pages.php';
436        require_once __DIR__ . '/features/wpcom-block-editor/class-jetpack-wpcom-block-editor.php';
437        require_once __DIR__ . '/features/wpcom-block-editor/functions.editor-type.php';
438        require_once __DIR__ . '/features/wpcom-dashboard/class-wpcom-dashboard.php';
439        require_once __DIR__ . '/features/wpcom-dashboard-tracks/wpcom-dashboard-tracks.php';
440        require_once __DIR__ . '/features/wpcom-logout/wpcom-logout.php';
441        require_once __DIR__ . '/features/wpcom-themes/wpcom-theme-fixes.php';
442        require_once __DIR__ . '/features/wpcom-post-list/wpcom-post-types-tracking.php';
443        require_once __DIR__ . '/features/wpcom-unified-admin-page-view/wpcom-unified-admin-page-view.php';
444        require_once __DIR__ . '/features/wpcom-widgets/wpcom-widgets.php';
445        require_once __DIR__ . '/features/wpcom-wpadmin-page-view/wpcom-wpadmin-page-view.php';
446        if ( Constants::is_true( 'IS_ATOMIC' ) ) {
447            require_once __DIR__ . '/features/wpme-oembed/wpme-oembed.php';
448        }
449
450        require_once __DIR__ . '/features/write/write.php';
451
452        /*
453         * Temporarily disable client-side media processing.
454         *
455         * Client-side media processing enables cross-origin isolation (COEP/COOP headers)
456         * which can break authenticated API requests. This should be removed once client-side
457         * media processing is compatible with Dotcom's infrastructure.
458         *
459         * @see gutenberg_set_up_cross_origin_isolation() in Gutenberg's lib/media/load.php
460         * @see https://a8c.slack.com/archives/CBTN58FTJ/p1771950744814189
461         */
462        add_filter( 'wp_client_side_media_processing_enabled', '__return_false' );
463
464        // Initializers, if needed.
465        $activity_log_event_class = 'Automattic\\Jetpack\\Sync\\Activity_Log_Event';
466        if ( class_exists( $activity_log_event_class ) ) {
467            $activity_log_event_class::init();
468        }
469
470        \Marketplace_Products_Updater::init();
471        \Automattic\Jetpack\Code_Editor::setup();
472        \Automattic\Jetpack\Code_Block::setup();
473        \Automattic\Jetpack\Classic_Theme_Helper\Main::init();
474        \Automattic\Jetpack\Classic_Theme_Helper\Featured_Content::setup();
475
476        \Automattic\Jetpack\Jetpack_Mu_Wpcom\AI_Launchpad::init();
477        \Automattic\Jetpack\Jetpack_Mu_Wpcom\Holiday_Snow::init();
478        \Automattic\Jetpack\Jetpack_Mu_Wpcom\Wpcom_Dashboard::init();
479
480        // The front-end Action Bar lives in the jetpack-newsletter package, which mu-wpcom does not
481        // composer-require; the class comes from the sibling Jetpack autoloader. Not in
482        // load_wpcom_user_features(): logged-out visitors are the bar's main audience.
483        if ( class_exists( '\Automattic\Jetpack\Newsletter\Action_Bar' ) ) {
484            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
485            \Automattic\Jetpack\Newsletter\Action_Bar::init();
486        }
487
488        // Gets autoloaded from the Scheduled_Updates package.
489        if ( class_exists( 'Automattic\Jetpack\Scheduled_Updates' ) ) {
490            Scheduled_Updates::init();
491        }
492    }
493
494    /**
495     * Load features that only apply to WordPress.com-connected users.
496     */
497    public static function load_wpcom_user_features() {
498        // To avoid potential collisions with ETK.
499        if ( ! class_exists( 'A8C\FSE\Help_Center' ) && class_exists( \Automattic\Jetpack\Help_Center\Help_Center::class ) ) {
500            add_action( 'init', array( \Automattic\Jetpack\Help_Center\Help_Center::class, 'init' ), 10, 0 );
501        }
502
503        // Every admin, not only WordPress.com users: one who cannot renew, such as
504        // the client of an agency-managed site, is told whose plan it is, and the
505        // legacy notice the feature replaces stands down only once this has loaded.
506        require_once __DIR__ . '/features/expiry-notices/expiry-notices.php';
507
508        // Its handlers check the user at event time: API and CLI requests set it after plugins_loaded.
509        require_once __DIR__ . '/features/wpcom-themes/wpcom-theme-tracking.php';
510
511        if ( ! is_wpcom_user() ) {
512            require_once __DIR__ . '/features/replace-site-visibility/hide-site-visibility.php';
513            return;
514        }
515        if ( ! class_exists( 'A8C\FSE\Survicate' ) ) {
516            require_once __DIR__ . '/features/survicate/class-survicate.php';
517        }
518        require_once __DIR__ . '/features/ai-assistant-banner/ai-assistant-banner.php';
519        require_once __DIR__ . '/features/html-block-restricted-tags/html-block-restricted-tags.php';
520        require_once __DIR__ . '/features/pages/pages.php';
521        require_once __DIR__ . '/features/replace-site-visibility/replace-site-visibility.php';
522        require_once __DIR__ . '/features/stats/stats.php';
523        require_once __DIR__ . '/features/wpcom-admin-bar/wpcom-admin-bar.php';
524        require_once __DIR__ . '/features/wpcom-admin-interface/wpcom-admin-interface.php';
525        require_once __DIR__ . '/features/wpcom-admin-menu/wpcom-admin-menu.php';
526        require_once __DIR__ . '/features/wpcom-comments/wpcom-comments.php';
527        require_once __DIR__ . '/features/wpcom-dashboard-widgets/wpcom-dashboard-widgets.php';
528        require_once __DIR__ . '/features/wpcom-imports/wpcom-imports.php';
529        require_once __DIR__ . '/features/wpcom-locale/sync-locale-from-calypso-to-atomic.php';
530        require_once __DIR__ . '/features/wpcom-media/wpcom-media-url-upload.php';
531        require_once __DIR__ . '/features/wpcom-media/wpcom-export-media-files.php';
532        require_once __DIR__ . '/features/wpcom-options-general/options-general.php';
533        require_once __DIR__ . '/features/wpcom-plugins/wpcom-plugins.php';
534        require_once __DIR__ . '/features/wpcom-plugins/wpcom-marketplace-tab.php';
535        require_once __DIR__ . '/features/wpcom-plugins/wpcom-marketplace-cards.php';
536        require_once __DIR__ . '/features/wpcom-profile-settings/profile-settings-link-to-wpcom.php';
537        require_once __DIR__ . '/features/wpcom-profile-settings/profile-settings-notices.php';
538        require_once __DIR__ . '/features/wpcom-sidebar-notice/wpcom-sidebar-notice.php';
539        require_once __DIR__ . '/features/wpcom-themes/wpcom-themes.php';
540        require_once __DIR__ . '/features/wpcom-themes/wpcom-themes-tab.php';
541        require_once __DIR__ . '/features/wpcom-user-edit/wpcom-user-edit.php';
542        require_once __DIR__ . '/features/wpcom-videopress/wpcom-videopress.php';
543
544        // Initialize Newsletter Settings so hooks like the Reading page notice
545        // are registered on Simple sites (where load-jetpack.php doesn't run).
546        // Guarded with class_exists since mu-wpcom no longer composer-requires
547        // the jetpack-newsletter package: the class is provided by the standalone
548        // Jetpack plugin on Atomic, or by the wpcom platform's bundled Jetpack
549        // source on Simple.
550        if ( class_exists( '\Automattic\Jetpack\Newsletter\Settings' ) ) {
551            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
552            \Automattic\Jetpack\Newsletter\Settings::init();
553        }
554
555        // Register the Daily Writing Prompt dashboard widget, which now lives in
556        // the jetpack-newsletter package. Guarded with class_exists for the same
557        // reason as Settings above: mu-wpcom doesn't composer-require the package.
558        if ( class_exists( '\Automattic\Jetpack\Newsletter\Writing_Prompt_Widget' ) ) {
559            // @phan-suppress-next-line PhanUndeclaredClassMethod -- class_exists guarded above; provided by sibling autoloader.
560            \Automattic\Jetpack\Newsletter\Writing_Prompt_Widget::init();
561        }
562
563        // Only load the Masterbar features on WoA sites.
564        if ( class_exists( '\Automattic\Jetpack\Status\Host' ) && ( new \Automattic\Jetpack\Status\Host() )->is_woa_site() ) {
565            // This is temporary. After we cleanup Masterbar on WPCOM we should load Masterbar for Simple sites too.
566            \Automattic\Jetpack\Masterbar\Main::init();
567        }
568
569        if ( class_exists( 'Automattic\Jetpack\Agents_Manager\Agents_Manager' ) ) {
570            \Automattic\Jetpack\Agents_Manager\Agents_Manager::init();
571            require_once __DIR__ . '/features/wpcom-agents-manager/wpcom-agents-manager.php';
572        }
573    }
574
575    /**
576     * Load features that only apply to WordPress.com sites, regardless of whether the users are connected.
577     */
578    public static function load_wpcom_sites_features() {
579        if ( is_fully_managed_agency_site() ) {
580            return;
581        }
582
583        require_once __DIR__ . '/features/gutenberg-rtc/gutenberg-rtc.php';
584        require_once __DIR__ . '/features/wpcom-contact-form-flags/wpcom-contact-form-flags.php';
585    }
586
587    /**
588     * Define the flags to turn off features in the ETK plugin.
589     * Can be removed once the feature no longer exists in the ETK plugin.
590     */
591    public static function load_etk_features_flags() {
592        // Don't load on agency sites.
593        if ( is_fully_managed_agency_site() ) {
594            return;
595        }
596
597        // Don't load if the user is not a wpcom user on WP Admin.
598        // The features is still required on the frontend page regardless of the user.
599        if ( is_admin() && ! is_wpcom_user() ) {
600            return;
601        }
602
603        define( 'MU_WPCOM_COBLOCKS_GALLERY', true );
604        define( 'MU_WPCOM_CUSTOM_LINE_HEIGHT', true );
605        define( 'MU_WPCOM_BLOCK_INSERTER_MODIFICATIONS', true );
606        define( 'MU_WPCOM_HOMEPAGE_TITLE_HIDDEN', true );
607        define( 'MU_WPCOM_JETPACK_GLOBAL_STYLES', true );
608        define( 'A8C_USE_FONT_SMOOTHING_ANTIALIASED', false );
609        define( 'MU_WPCOM_MAILERLITE_WIDGET', true );
610        define( 'MU_WPCOM_OVERRIDE_PREVIEW_BUTTON_URL', true );
611        define( 'MU_WPCOM_PARAGRAPH_BLOCK', true );
612        define( 'MU_WPCOM_STARTER_PAGE_TEMPLATES', true );
613        define( 'MU_WPCOM_TAGS_EDUCATION', true );
614        define( 'MU_WPCOM_BLOCK_DESCRIPTION_LINKS', true );
615        define( 'MU_WPCOM_BLOCK_EDITOR_NUX', true );
616        define( 'MU_WPCOM_POSTS_LIST_BLOCK', true );
617        define( 'MU_WPCOM_JETPACK_COUNTDOWN_BLOCK', true );
618        define( 'MU_WPCOM_JETPACK_TIMELINE_BLOCK', true );
619        define( 'MU_WPCOM_DOCUMENTATION_LINKS', true );
620        define( 'MU_WPCOM_GLOBAL_STYLES', true );
621        define( 'MU_WPCOM_FSE', true );
622        define( 'MU_WPCOM_TEMPLATE_INSERTER', true );
623        define( 'MU_WPCOM_WHATS_NEW', true );
624    }
625
626    /**
627     * Load ETK features.
628     * Can be moved back to load_features() once the feature no longer exists in the ETK plugin.
629     */
630    public static function load_etk_features() {
631        // Don't load on agency sites.
632        if ( is_fully_managed_agency_site() ) {
633            return;
634        }
635
636        // Don't load if the user is not a wpcom user on WP Admin.
637        // The features is still required on the frontend page regardless of the user.
638        if ( is_admin() && ! is_wpcom_user() ) {
639            return;
640        }
641
642        require_once __DIR__ . '/features/jetpack-global-styles/class-global-styles.php';
643        require_once __DIR__ . '/features/mailerlite/subscriber-popup.php';
644        require_once __DIR__ . '/features/wpcom-fse/wpcom-fse.php';
645
646        /**
647         * Load features for the editor and the frontend pages.
648         */
649        global $pagenow;
650        $allowed_pages = array( 'post.php', 'post-new.php', 'site-editor.php' );
651        if ( ( isset( $pagenow ) && in_array( $pagenow, $allowed_pages, true ) ) || ! is_admin() ) {
652            require_once __DIR__ . '/features/block-editor/custom-line-height.php';
653            require_once __DIR__ . '/features/block-inserter-modifications/block-inserter-modifications.php';
654            require_once __DIR__ . '/features/hide-homepage-title/hide-homepage-title.php';
655            require_once __DIR__ . '/features/override-preview-button-url/override-preview-button-url.php';
656            require_once __DIR__ . '/features/paragraph-block-placeholder/paragraph-block-placeholder.php';
657            require_once __DIR__ . '/features/tags-education/tags-education.php';
658            require_once __DIR__ . '/features/wpcom-block-description-links/wpcom-block-description-links.php';
659            require_once __DIR__ . '/features/wpcom-block-editor-nux/class-wpcom-block-editor-nux.php';
660            require_once __DIR__ . '/features/wpcom-blocks/a8c-posts-list/a8c-posts-list.php';
661            require_once __DIR__ . '/features/wpcom-blocks/event-countdown/event-countdown.php';
662            require_once __DIR__ . '/features/wpcom-blocks/timeline/timeline.php';
663            require_once __DIR__ . '/features/wpcom-documentation-links/wpcom-documentation-links.php';
664            require_once __DIR__ . '/features/wpcom-global-styles/index.php';
665            require_once __DIR__ . '/features/wpcom-legacy-fse/wpcom-legacy-fse.php';
666        } elseif ( isset( $pagenow ) && 'customize.php' === $pagenow ) {
667            // Load wpcom-global-styles on the customizer so access to additional css can be checked there.
668            require_once __DIR__ . '/features/wpcom-global-styles/index.php';
669        }
670    }
671
672    /**
673     * Load the newspack blocks feature for the editor and the frontend pages.
674     */
675    public static function load_newspack_blocks() {
676        /**
677         * Avoid potential collisions with newspack-blocks plugin.
678         */
679        if ( class_exists( '\Newspack_Blocks', false ) ) {
680            return;
681        }
682
683        global $pagenow;
684        $allowed_pages = array( 'post.php', 'post-new.php', 'site-editor.php' );
685        if ( ( isset( $pagenow ) && in_array( $pagenow, $allowed_pages, true ) ) || ! is_admin() ) {
686            define( 'MU_WPCOM_NEWSPACK_BLOCKS', true );
687            require_once __DIR__ . '/features/newspack-blocks/index.php';
688        }
689    }
690
691    /**
692     * Load the Coming Soon feature.
693     */
694    public static function load_coming_soon() {
695        /**
696         * On WoA sites, users may be using non-symlinked older versions of the FSE plugin.
697         * If they are, check the active version to avoid redeclaration errors.
698         */
699        if ( ! function_exists( 'is_plugin_active' ) ) {
700            require_once ABSPATH . 'wp-admin/includes/plugin.php';
701        }
702
703        /**
704         * Explicitly pass $markup = false in get_plugin_data to avoid indirectly calling wptexturize that could cause unintended side effects.
705         * See: https://developer.wordpress.org/reference/functions/get_plugin_data/
706         */
707        $fse_plugin                 = 'full-site-editing/full-site-editing-plugin.php';
708        $fse_plugin_path            = WP_PLUGIN_DIR . '/' . $fse_plugin;
709        $invalid_fse_version_active =
710            file_exists( $fse_plugin_path ) &&
711            is_file( $fse_plugin_path ) &&
712            is_plugin_active( $fse_plugin ) &&
713            version_compare( get_plugin_data( $fse_plugin_path, false )['Version'], '3.56084', '<' );
714
715        if ( $invalid_fse_version_active ) {
716            return;
717        }
718
719        if (
720            ( defined( 'WPCOM_PUBLIC_COMING_SOON' ) && WPCOM_PUBLIC_COMING_SOON ) ||
721            apply_filters( 'a8c_enable_public_coming_soon', false )
722        ) {
723            require_once __DIR__ . '/features/coming-soon/coming-soon.php';
724        }
725    }
726
727    /**
728     * Load the Launchpad feature.
729     */
730    public static function load_launchpad() {
731        require_once __DIR__ . '/features/launchpad/launchpad.php';
732    }
733
734    /**
735     * Load WP REST API plugins for wpcom.
736     */
737    public static function load_wpcom_rest_api_endpoints() {
738        if ( ! function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
739            return;
740        }
741
742        // We don't use `wpcom_rest_api_v2_load_plugin_files` because it operates inconsisently.
743        $plugins = glob( __DIR__ . '/features/wpcom-endpoints/*.php' );
744
745        if ( ! is_array( $plugins ) ) {
746            return;
747        }
748
749        foreach ( array_filter( $plugins, 'is_file' ) as $plugin ) {
750            require_once $plugin;
751        }
752    }
753
754    /**
755     * Adds a global variable containing the config of the plugin to the window object.
756     */
757    public static function load_jetpack_mu_wpcom_settings() {
758        $handle = 'jetpack-mu-wpcom-settings';
759
760        // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NotInFooter
761        wp_register_script(
762            $handle,
763            false,
764            array(),
765            true
766        );
767
768        $data = wp_json_encode(
769            array(
770                'assetsUrl' => plugins_url( 'build/', self::BASE_FILE ),
771            ),
772            JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
773        );
774
775        wp_add_inline_script(
776            $handle,
777            "var JETPACK_MU_WPCOM_SETTINGS = $data;",
778            'before'
779        );
780
781        wp_enqueue_script( $handle );
782    }
783
784    /**
785     * Adds a global variable containing the map provider in a map_block_settings object to the window object.
786     */
787    public static function load_map_block_settings() {
788        if (
789            ! function_exists( 'get_current_screen' )
790            || \get_current_screen() === null
791        ) {
792            return;
793        }
794
795        // Return early if we are not in the block editor.
796        if ( ! wp_should_load_block_editor_scripts_and_styles() ) {
797            return;
798        }
799
800        $map_provider = apply_filters( 'wpcom_map_block_map_provider', 'mapbox' );
801        wp_localize_script( 'jetpack-blocks-editor', 'Jetpack_Maps', array( 'provider' => $map_provider ) );
802    }
803
804    /**
805     * Adds a global variable containing where the newsletter categories should be shown.
806     */
807    public static function load_newsletter_categories_settings() {
808        if (
809            ! function_exists( 'get_current_screen' )
810            || \get_current_screen() === null
811        ) {
812            return;
813        }
814
815        // Return early if we are not in the block editor.
816        if ( ! wp_should_load_block_editor_scripts_and_styles() ) {
817            return;
818        }
819
820        $newsletter_categories_location = apply_filters( 'wpcom_newsletter_categories_location', 'block' );
821        wp_localize_script( 'jetpack-blocks-editor', 'Jetpack_Subscriptions', array( 'newsletter_categories_location' => $newsletter_categories_location ) );
822    }
823
824    /**
825     * Unbinds focusout event handler on #wp-admin-bar-menu-toggle introduced in WordPress 6.2.
826     *
827     * The focusout event handler is preventing the unified navigation from being closed on mobile.
828     */
829    public static function unbind_focusout_on_wp_admin_bar_menu_toggle() {
830        wp_add_inline_script( 'common', '(function($){ $(document).on("wp-responsive-activate", function(){ $(".is-nav-unification #wp-admin-bar-menu-toggle, .is-nav-unification #adminmenumain").off("focusout"); } ); }(jQuery) );' );
831    }
832
833    /**
834     * Determine whether to disable the comment experience.
835     *
836     * @param int $blog_id The blog ID.
837     * @return boolean
838     */
839    private static function should_disable_comment_experience( $blog_id ) {
840        $path_wp_for_teams = WP_CONTENT_DIR . '/lib/wpforteams/functions.php';
841
842        if ( file_exists( $path_wp_for_teams ) ) {
843            require_once $path_wp_for_teams;
844        }
845
846        // This covers both P2 and P2020 themes.
847        $is_p2     = str_contains( get_stylesheet(), 'pub/p2' ) || function_exists( '\WPForTeams\is_wpforteams_site' ) && is_wpforteams_site( $blog_id );
848        $is_forums = str_contains( get_stylesheet(), 'a8c/supportforums' ); // Not in /forums.
849
850        $verbum_option_enabled = get_blog_option( $blog_id, 'enable_verbum_commenting', true );
851
852        if ( empty( $verbum_option_enabled ) ) {
853            return true;
854        }
855
856        // Don't load any comment experience in the Reader, GlotPress, wp-admin, or P2.
857        return ( 1 === $blog_id || TRANSLATE_BLOG_ID === $blog_id || is_admin() || $is_p2 || $is_forums );
858    }
859
860    /**
861     * Load Verbum Comments.
862     */
863    public static function load_verbum_comments() {
864        // public-api boots on its own blog, so decide once the request has switched to its target.
865        if ( 'public-api.wordpress.com' === wp_parse_url( home_url(), PHP_URL_HOST ) ) {
866            // Now, for reads across sites that never switch: avatars only touch comments with our stored photo.
867            \Automattic\Jetpack\Comments\Avatars::init();
868            add_action( 'rest_api_switched_to_blog', array( __CLASS__, 'load_verbum_comments' ) );
869            return;
870        }
871
872        if ( class_exists( 'Verbum_Comments' ) ) {
873            return;
874        } else {
875            $blog_id = get_current_blog_id();
876            // Jetpack loads Verbum though an iframe from jetpack.wordpress.com.
877            // So we need to check the GET request for the blogid.
878            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
879            if ( isset( $_GET['blogid'] ) ) {
880                $blog_id = intval( $_GET['blogid'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
881            }
882            if ( self::should_disable_comment_experience( $blog_id ) ) {
883                return;
884            }
885
886            if ( \Automattic\Jetpack\Comments\Comments::is_enabled() ) {
887                \Automattic\Jetpack\Comments\Comments::init();
888                return;
889            }
890
891            require_once __DIR__ . '/features/verbum-comments/class-verbum-comments.php';
892            new \Automattic\Jetpack\Verbum_Comments();
893        }
894    }
895
896    /**
897     * Register Jetpack Comments' browser-facing routes ahead of the comment
898     * experience gates. admin-ajax is is_admin(), so load_verbum_comments() skips it,
899     * and on public-api it waits for the blog switch, which comes after rest_api_init.
900     * Runs on every request, blog 1 and P2s included, on purpose: the routes
901     * gate themselves on Comments::is_enabled(), as Posts_To_Podcast_Endpoint does.
902     */
903    public static function load_jetpack_comments_routes() {
904        \Automattic\Jetpack\Comments\Checkpoint_Endpoint::init();
905        \Automattic\Jetpack\Comments\Embeds::init();
906    }
907
908    /**
909     * Load Verbum Comments Settings.
910     */
911    public static function load_verbum_comments_admin() {
912        require_once __DIR__ . '/features/verbum-comments/assets/class-verbum-admin.php';
913        new \Automattic\Jetpack\Verbum_Admin();
914    }
915
916    /**
917     * Load Verbum Moderate.
918     */
919    public static function load_verbum_moderate() {
920        require_once __DIR__ . '/features/verbum-comments/assets/class-verbum-moderate.php';
921        new \Automattic\Jetpack\Verbum_Moderate();
922    }
923
924    /**
925     * Load the Backup page on WordPress.com Simple and WoA sites.
926     *
927     * The file hooks its own `init`, where the plan lookup it gates on is ready.
928     */
929    public static function load_wpcom_backup() {
930        require_once __DIR__ . '/features/wpcom-backup/wpcom-backup.php';
931    }
932
933    /**
934     * Load the Protect page, which offers Scan, on WordPress.com Simple and WoA sites.
935     *
936     * The file hooks its own `init`, where the plan lookup it gates on is ready.
937     */
938    public static function load_wpcom_scan() {
939        require_once __DIR__ . '/features/wpcom-scan/wpcom-scan.php';
940    }
941
942    /**
943     * Load the lightweight My Jetpack page on WordPress.com Simple sites.
944     */
945    public static function load_wpcom_my_jetpack() {
946        require_once __DIR__ . '/features/wpcom-my-jetpack/wpcom-my-jetpack.php';
947    }
948
949    /**
950     * Load Odyssey Stats in Simple sites.
951     */
952    public static function load_wpcom_simple_odyssey_stats() {
953        require_once __DIR__ . '/features/wpcom-simple-odyssey-stats/wpcom-simple-odyssey-stats.php';
954    }
955
956    /**
957     * Register the Ads tab of the Premium Analytics dashboard where the plan includes WordAds and it is on.
958     *
959     * Hooks the dashboard's registry action, which only fires once the package boots, so this
960     * is inert on a site without the dashboard.
961     *
962     * @since 6.11.0
963     */
964    public static function load_premium_analytics_wordads_section() {
965        require_once __DIR__ . '/features/premium-analytics/wordads-section.php';
966    }
967
968    /**
969     * Register the Top videos widget of the Premium Analytics dashboard by plan feature.
970     *
971     * Same seam as the Ads tab: inert on a site without the dashboard.
972     *
973     * @since 6.11.0
974     */
975    public static function load_premium_analytics_videopress_widgets() {
976        require_once __DIR__ . '/features/premium-analytics/videopress-widgets.php';
977    }
978
979    /**
980     * Limit the Premium Analytics Settings tab on Simple to the Stats settings WordPress.com honors there.
981     *
982     * @since $$next-version$$
983     */
984    public static function load_premium_analytics_stats_settings_fields() {
985        require_once __DIR__ . '/features/premium-analytics/stats-settings-fields.php';
986    }
987
988    /**
989     * Load the Jetpack Custom CSS feature.
990     */
991    public static function load_custom_css() {
992        require_once __DIR__ . '/features/custom-css/custom-css/preprocessors.php';
993        require_once __DIR__ . '/features/custom-css/custom-css.php';
994    }
995
996    /**
997     * Load the Random Redirect feature.
998     */
999    public static function load_wpcom_random_redirect() {
1000        require_once __DIR__ . '/features/random-redirect/random-redirect.php';
1001    }
1002
1003    /**
1004     * Load the Social Links feature.
1005     */
1006    public static function load_social_links() {
1007        if ( class_exists( 'Automattic\Jetpack\Classic_Theme_Helper\Social_Links' ) ) {
1008            new \Automattic\Jetpack\Classic_Theme_Helper\Social_Links();
1009        }
1010    }
1011
1012    /**
1013     * Populate JetpackScriptData.site.wpcom.blog_id with the actual WP.com blog ID.
1014     *
1015     * @param array $data The script data.
1016     * @return array
1017     */
1018    public static function set_wpcom_blog_id_script_data( $data ) {
1019        $blog_id = get_wpcom_blog_id();
1020        if ( $blog_id ) {
1021            $data['site']['wpcom']['blog_id'] = $blog_id;
1022        }
1023        return $data;
1024    }
1025
1026    /**
1027     * Add `gutenberg-react-19` to the list of enabled Gutenberg experiments.
1028     *
1029     * The `disable-gutenberg-react-19` blog sticker force-disables the experiment,
1030     * the `gutenberg-react-19` sticker opts the site in. With neither sticker,
1031     * the experiment is enabled on a certain percentage of Atomic sites,
1032     * known incompatible plugins and themes are excluded.
1033     *
1034     * @param mixed $experiments The current value of the gutenberg-experiments option.
1035     * @return mixed Original option value or the filtered experiments.
1036     */
1037    public static function enable_gutenberg_react_19_experiment( $experiments ) {
1038        if ( self::has_react_19_incompatible_gutenberg() ) {
1039            return $experiments;
1040        }
1041
1042        $blog_id = get_wpcom_blog_id();
1043
1044        if ( wpcom_has_blog_sticker( 'disable-gutenberg-react-19', $blog_id ) ) {
1045            return $experiments;
1046        }
1047
1048        $is_enabled = wpcom_has_blog_sticker( 'gutenberg-react-19', $blog_id );
1049
1050        if ( ! $is_enabled && function_exists( 'wpcomsh_get_atomic_site_id' ) ) {
1051            $site_id = wpcomsh_get_atomic_site_id();
1052
1053            // Don't enable if the site ID is unknown (zero).
1054            if ( ! $site_id ) {
1055                $is_enabled = false;
1056            } elseif ( self::has_react_19_incompatible_extension() ) {
1057                $is_enabled = false;
1058            } else {
1059                $current_segment = 70; // Segment of Atomic sites in the experiment, in %.
1060                $site_segment    = $site_id % 100;
1061
1062                /*
1063                 * Sites whose Atomic site ID ends in digits < $current_segment are in the segment.
1064                 * Must stay in sync with the error reporting rollout in wpcomsh
1065                 * (wpcomsh_enable_error_reporting_for_react_19), so that every site in
1066                 * the experiment also reports JS errors.
1067                 */
1068                $is_enabled = $site_segment < $current_segment;
1069            }
1070        }
1071
1072        if ( ! $is_enabled ) {
1073            return $experiments;
1074        }
1075
1076        if ( ! is_array( $experiments ) ) {
1077            $experiments = array();
1078        }
1079
1080        $experiments['gutenberg-react-19'] = true;
1081        return $experiments;
1082    }
1083
1084    /**
1085     * Override the WooCommerce unified block editor assets option with blog stickers.
1086     *
1087     * The `wc-disable-block-editor-unified-assets` blog sticker force-disables the feature,
1088     * while the `wc-block-editor-unified-assets` sticker opts the site in.
1089     *
1090     * @since 6.11.0
1091     *
1092     * @param mixed $enabled The current feature option value.
1093     * @return mixed Original option value, `yes` when enabled, or `no` when force-disabled.
1094     */
1095    public static function enable_woocommerce_block_editor_unified_assets( $enabled ) {
1096        $blog_id = get_wpcom_blog_id();
1097
1098        if ( wpcom_has_blog_sticker( 'wc-disable-block-editor-unified-assets', $blog_id ) ) {
1099            return 'no';
1100        }
1101
1102        if ( wpcom_has_blog_sticker( 'wc-block-editor-unified-assets', $blog_id ) ) {
1103            return 'yes';
1104        }
1105
1106        // phpcs:disable Squiz.PHP.CommentedOutCode.Found,Squiz.Commenting.BlockComment.NoCapital -- Preserve the rollout for future activation.
1107
1108        /*
1109        if ( function_exists( 'wpcomsh_get_atomic_site_id' ) ) {
1110            $site_id = wpcomsh_get_atomic_site_id();
1111
1112            if ( $site_id ) {
1113                $current_segment = 1; // Segment of Atomic sites in the experiment, in %.
1114
1115                if ( $site_id % 100 < $current_segment ) {
1116                    return 'yes';
1117                }
1118            }
1119        }
1120        */
1121        // phpcs:enable
1122
1123        return $enabled;
1124    }
1125
1126    /**
1127     * Whether the site runs a Gutenberg plugin release that's known to break with React 19.
1128     *
1129     * Sites without the plugin run core's bundled Gutenberg, which the list doesn't cover.
1130     *
1131     * @return bool
1132     */
1133    private static function has_react_19_incompatible_gutenberg() {
1134        if ( ! defined( 'GUTENBERG_VERSION' ) ) {
1135            return false;
1136        }
1137
1138        // Match on the release number alone, so prereleases like 23.9.0-rc.1 count as 23.9.0.
1139        if ( ! preg_match( '/^(\d+(?:\.\d+)*)/', (string) GUTENBERG_VERSION, $matches ) ) {
1140            return false;
1141        }
1142
1143        return in_array( $matches[1], self::REACT_19_INCOMPATIBLE_GUTENBERG, true );
1144    }
1145
1146    /**
1147     * Add `gutenberg-extensible-site-editor` to the list of enabled Gutenberg experiments.
1148     *
1149     * Only registered on sites holding the sticker, so this does not re-check it.
1150     *
1151     * @param mixed $experiments The current value of the gutenberg-experiments option.
1152     * @return array The experiments, with the extensible site editor enabled.
1153     */
1154    public static function enable_extensible_site_editor_experiment( $experiments ) {
1155        if ( ! is_array( $experiments ) ) {
1156            $experiments = array();
1157        }
1158
1159        $experiments['gutenberg-extensible-site-editor'] = true;
1160        return $experiments;
1161    }
1162
1163    /**
1164     * Whether the site runs an extension that's known to break with React 19.
1165     *
1166     * @return bool
1167     */
1168    private static function has_react_19_incompatible_extension() {
1169        // Outside wp-admin the plugin check is unavailable, and the experiment isn't needed.
1170        if ( ! function_exists( 'is_plugin_active' ) ) {
1171            return true;
1172        }
1173
1174        $template = strtolower( get_template() );
1175        if ( array_key_exists( $template, self::REACT_19_INCOMPATIBLE_THEMES ) ) {
1176            $theme_version = (string) wp_get_theme( get_template() )->get( 'Version' );
1177
1178            if ( self::is_react_19_incompatible_version( $theme_version, self::REACT_19_INCOMPATIBLE_THEMES[ $template ] ) ) {
1179                return true;
1180            }
1181        }
1182
1183        foreach ( self::REACT_19_INCOMPATIBLE_PLUGINS as $plugin_file => $fixed_in ) {
1184            if ( ! is_plugin_active( $plugin_file ) ) {
1185                continue;
1186            }
1187
1188            $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin_file, false, false );
1189
1190            if ( self::is_react_19_incompatible_version( (string) $plugin_data['Version'], $fixed_in ) ) {
1191                return true;
1192            }
1193        }
1194
1195        return false;
1196    }
1197
1198    /**
1199     * Whether an installed version predates the release that fixed its React 19 incompatibility.
1200     *
1201     * An unreadable version counts as incompatible, so a missing header can't opt a site in.
1202     *
1203     * @param string      $version  The installed version.
1204     * @param string|null $fixed_in The release that fixed the incompatibility, or null if there is none.
1205     * @return bool
1206     */
1207    private static function is_react_19_incompatible_version( $version, $fixed_in ) {
1208        if ( null === $fixed_in || '' === $version ) {
1209            return true;
1210        }
1211
1212        return version_compare( $version, $fixed_in, '<' );
1213    }
1214
1215    /**
1216     * Add Jetpack script data with host information on P2
1217     *
1218     * @param array $data - The Jetpack script data.
1219     * @return array - The modified Jetpack script data.
1220     */
1221    public static function add_jetpack_script_data_for_p2( $data ) {
1222        if (
1223        str_contains( get_stylesheet(), 'pub/p2' ) ||
1224        ( function_exists( '\WPForTeams\is_wpforteams_site' ) && is_wpforteams_site( get_current_blog_id() ) )
1225        ) {
1226            $host = new \Automattic\Jetpack\Status\Host();
1227            if ( ! isset( $data['site']['host'] ) ) {
1228                $data['site']['host'] = $host->get_known_host_guess();
1229            }
1230            if ( ! isset( $data['site']['is_wpcom_platform'] ) ) {
1231                $data['site']['is_wpcom_platform'] = $host->is_wpcom_platform();
1232            }
1233        }
1234        return $data;
1235    }
1236
1237    /**
1238     * Emit an event to the wpcom logstash cluster.
1239     *
1240     * Uses the in-process `log2logstash()` on WP.com Simple, and falls back to
1241     * the public-api `/rest/v1.1/logstash` endpoint (fire-and-forget) on
1242     * Atomic, where `log2logstash()` isn't available.
1243     *
1244     * Best-effort: a logging failure must never escalate into a fatal for the caller.
1245     *
1246     * @param string $feature Logstash `feature` bucket; should start with the `atomic_` prefix (e.g. "atomic_plugin_conflicts_guardian").
1247     * @param string $message Event message slug.
1248     * @param array  $extra   Event-specific properties; JSON-encoded into the `extra` field.
1249     * @return void
1250     */
1251    public static function log2logstash( $feature, $message, array $extra = array() ) {
1252        // Resolve the dispatch path once per request — on upgrade flows this
1253        // can be called several times in a row (one per plugin) and the path
1254        // doesn't change mid-request.
1255        static $dispatch = null;
1256        if ( null === $dispatch ) {
1257            try {
1258                if ( ! function_exists( 'log2logstash' ) ) {
1259                    $log2logstash_path = WP_CONTENT_DIR . '/lib/log2logstash/log2logstash.php';
1260                    if ( is_readable( $log2logstash_path ) ) {
1261                        require_once $log2logstash_path;
1262                    }
1263                }
1264            } catch ( \Throwable $e ) { // require_once can still throw (parse error / top-level fatal in the included file); fall through to the HTTP dispatch.
1265                unset( $e );
1266            }
1267            $dispatch = function_exists( 'log2logstash' ) ? 'native' : 'http';
1268        }
1269
1270        try {
1271            $payload = array(
1272                'blog_id' => self::resolve_logstash_blog_id(),
1273                'feature' => (string) $feature,
1274                'message' => (string) $message,
1275                'extra'   => wp_json_encode( $extra, JSON_UNESCAPED_SLASHES ),
1276            );
1277
1278            if ( 'native' === $dispatch ) {
1279                log2logstash( $payload );
1280                return;
1281            }
1282
1283            // Defer the HTTP POST to shutdown. Dispatching inline as a
1284            // non-blocking request loses the event when the caller `exit`s
1285            // or `wp_safe_redirect`s right after (e.g. the activation-guard
1286            // block path), because the cURL handle is torn down before the
1287            // TLS handshake completes. Draining at shutdown with a blocking
1288            // POST guarantees delivery without adding latency to the
1289            // user-visible response.
1290            self::queue_logstash_http( $payload );
1291        } catch ( \Throwable $e ) { // best-effort: a logging failure must never escalate into a fatal for the caller.
1292            unset( $e );
1293        }
1294    }
1295
1296    /**
1297     * Resolve the WP.com blog ID for a logstash record.
1298     *
1299     * `get_wpcom_blog_id()` falls back to `get_current_blog_id()` on Atomic
1300     * when `jetpack_options['id']` isn't readable — that returns `1` on a
1301     * single-site install, which is a valid-looking but wrong WP.com blog ID
1302     * and makes log records impossible to attribute. Emit `0` instead when
1303     * the real WP.com blog ID is unknown, so the gap is obvious in Kibana.
1304     *
1305     * @return int WP.com blog ID, or 0 when it can't be determined.
1306     */
1307    private static function resolve_logstash_blog_id() {
1308        // WP.com Simple: the current blog ID *is* the WP.com blog ID.
1309        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1310            return (int) get_current_blog_id();
1311        }
1312        // Atomic / connected Jetpack: the WP.com blog ID lives in the
1313        // `jetpack_options` option. Read it directly (no `Jetpack_Options`
1314        // dependency) and return 0 — never the local blog ID — when absent.
1315        $jetpack_options = get_option( 'jetpack_options' );
1316        if ( is_array( $jetpack_options ) && ! empty( $jetpack_options['id'] ) ) {
1317            return (int) $jetpack_options['id'];
1318        }
1319        return 0;
1320    }
1321
1322    /**
1323     * Append a logstash payload to the shutdown drain queue, registering
1324     * the drain hook on first enqueue. See `log2logstash()` for why
1325     * dispatch is deferred.
1326     *
1327     * @param array $payload Logstash record (`blog_id`, `feature`, `message`, `extra`).
1328     * @return void
1329     */
1330    private static function queue_logstash_http( array $payload ) {
1331        static $queue = null;
1332        if ( null === $queue ) {
1333            $queue = array();
1334            register_shutdown_function(
1335                static function () use ( &$queue ) {
1336                    foreach ( $queue as $entry ) {
1337                        try {
1338                            wp_remote_post(
1339                                'https://public-api.wordpress.com/rest/v1.1/logstash',
1340                                array(
1341                                    'body'    => array( 'params' => wp_json_encode( $entry, JSON_UNESCAPED_SLASHES ) ),
1342                                    'timeout' => 5,
1343                                )
1344                            );
1345                        } catch ( \Throwable $e ) { // best-effort: a logging failure must never escalate into a fatal at shutdown.
1346                            unset( $e );
1347                        }
1348                    }
1349                    $queue = array();
1350                }
1351            );
1352        }
1353        $queue[] = $payload;
1354    }
1355}