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