Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.60% covered (success)
97.60%
325 / 333
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Offline_Mode_Features
98.19% covered (success)
98.19%
325 / 331
70.59% covered (warning)
70.59%
12 / 17
41
0.00% covered (danger)
0.00%
0 / 1
 get_dashboard_data
98.28% covered (success)
98.28%
57 / 58
0.00% covered (danger)
0.00%
0 / 1
5
 get_recommended_modules
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 get_partial_features
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
2
 get_always_available_features
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 get_requires_connection_features
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
7
 allow_partial_module_in_offline_mode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_groups
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 is_boost_available
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
6.09
 get_offline_module_features
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 get_limited_offline_modules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 get_requires_connection_non_module_features
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 get_requires_connection_feature
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 get_requires_connection_description
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 get_requires_connection_name
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_documentation_url
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
 get_module_name_fallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_module_group
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Offline Mode feature registry.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Redirect;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * Provides feature data for the Jetpack Offline Mode dashboard.
16 */
17class Jetpack_Offline_Mode_Features {
18    const TYPE_MODULE              = 'module';
19    const TYPE_PARTIAL             = 'partial';
20    const TYPE_ALWAYS_AVAILABLE    = 'always_available';
21    const TYPE_REQUIRES_CONNECTION = 'requires_connection';
22
23    /**
24     * Get dashboard data for the Offline Mode screen.
25     *
26     * @return array
27     */
28    public static function get_dashboard_data() {
29        $features = array_merge(
30            self::get_offline_module_features(),
31            array_values( self::get_partial_features() ),
32            array_values( self::get_always_available_features() )
33        );
34
35        $group_order = array_flip( array_keys( self::get_groups() ) );
36
37        usort(
38            $features,
39            function ( $a, $b ) use ( $group_order ) {
40                $a_group = $group_order[ $a['group'] ] ?? PHP_INT_MAX;
41                $b_group = $group_order[ $b['group'] ] ?? PHP_INT_MAX;
42
43                if ( $a['group'] === $b['group'] ) {
44                    return strcasecmp( $a['name'], $b['name'] );
45                }
46
47                if ( $a_group === $b_group ) {
48                    return strcasecmp( $a['group'], $b['group'] );
49                }
50
51                return ( $a_group < $b_group ) ? -1 : 1;
52            }
53        );
54
55        return array(
56            'features'            => $features,
57            'groups'              => self::get_groups(),
58            'recommended'         => self::get_recommended_modules(),
59            'requires_connection' => self::get_requires_connection_features(),
60            'counts'              => array(
61                'offline_safe'     => count(
62                    array_filter(
63                        $features,
64                        function ( $feature ) {
65                            return self::TYPE_MODULE === $feature['type'];
66                        }
67                    )
68                ),
69                'enabled'          => count(
70                    array_filter(
71                        $features,
72                        function ( $feature ) {
73                            return ! empty( $feature['active'] ) && ! empty( $feature['toggleable'] );
74                        }
75                    )
76                ),
77                'partial'          => count(
78                    array_filter(
79                        $features,
80                        function ( $feature ) {
81                            return self::TYPE_PARTIAL === $feature['type'];
82                        }
83                    )
84                ),
85                'always_available' => count(
86                    array_filter(
87                        $features,
88                        function ( $feature ) {
89                            return self::TYPE_ALWAYS_AVAILABLE === $feature['type'];
90                        }
91                    )
92                ),
93            ),
94        );
95    }
96
97    /**
98     * Get recommended module slugs for local development.
99     *
100     * @return array
101     */
102    public static function get_recommended_modules() {
103        return array(
104            'contact-form',
105            'blocks',
106            'shortcodes',
107            'tiled-gallery',
108            'carousel',
109            'widgets',
110            'widget-visibility',
111            'markdown',
112            'copy-post',
113            'sharedaddy',
114            'sitemaps',
115            'seo-tools',
116        );
117    }
118
119    /**
120     * Get curated partial offline features.
121     *
122     * @return array
123     */
124    public static function get_partial_features() {
125        $module = Jetpack::get_module( 'subscriptions' );
126
127        $features = array(
128            'newsletter' => array(
129                'slug'              => 'newsletter',
130                'module'            => 'subscriptions',
131                'name'              => __( 'Newsletter', 'jetpack' ),
132                'description'       => $module['description'] ?? __( 'Grow your subscriber list and deliver your content directly to their email inbox.', 'jetpack' ),
133                'type'              => self::TYPE_PARTIAL,
134                'group'             => 'audience',
135                'active'            => Jetpack::is_module_active( 'subscriptions' ),
136                'available'         => true,
137                'recommended'       => false,
138                'toggleable'        => true,
139                'limitation'        => __( 'Local editor, theme, and plugin integration can be enabled. Email delivery, subscriber sync, and WordPress.com-backed flows still require a connection.', 'jetpack' ),
140                'underlying_module' => 'subscriptions',
141                'documentation_url' => self::get_documentation_url( 'newsletter' ),
142            ),
143        );
144
145        if ( self::is_boost_available() ) {
146            $features['boost'] = array(
147                'slug'              => 'boost',
148                'module'            => '',
149                'name'              => __( 'Boost', 'jetpack' ),
150                'description'       => __( 'Use Jetpack Boost performance tools that can run on a local site.', 'jetpack' ),
151                'type'              => self::TYPE_PARTIAL,
152                'group'             => 'performance',
153                'active'            => true,
154                'available'         => true,
155                'recommended'       => false,
156                'toggleable'        => false,
157                'limitation'        => __( 'Image Guide, Concatenate CSS, Concatenate JS, Defer Non-Essential JavaScript, Page Cache, Speculation Rules, and manual Critical CSS generation can run locally. Speed scores, Image CDN, LCP analysis, Cloud CSS, and cloud-backed history require a publicly reachable site and/or WordPress.com services.', 'jetpack' ),
158                'underlying_module' => '',
159                'documentation_url' => self::get_documentation_url( 'boost' ),
160            );
161        }
162
163        return $features;
164    }
165
166    /**
167     * Get local features that are loaded in Offline Mode without module toggles.
168     *
169     * @return array
170     */
171    public static function get_always_available_features() {
172        return array(
173            'theme-tools' => array(
174                'slug'              => 'theme-tools',
175                'module'            => '',
176                'name'              => __( 'Theme tools', 'jetpack' ),
177                'description'       => __( 'Use Jetpack theme integrations that are loaded automatically for local development.', 'jetpack' ),
178                'type'              => self::TYPE_ALWAYS_AVAILABLE,
179                'group'             => 'theme',
180                'active'            => true,
181                'available'         => true,
182                'recommended'       => false,
183                'toggleable'        => false,
184                'limitation'        => __( 'Responsive videos, featured content, social menus, breadcrumbs, site logo tools, and content options are loaded automatically when Jetpack is in Offline Mode.', 'jetpack' ),
185                'underlying_module' => '',
186                'documentation_url' => self::get_documentation_url( 'theme-tools' ),
187            ),
188        );
189    }
190
191    /**
192     * Get connection-required features for the informational section.
193     *
194     * @return array
195     */
196    public static function get_requires_connection_features() {
197        $features        = array();
198        $partial_modules = wp_list_pluck( self::get_partial_features(), 'module' );
199
200        foreach ( Jetpack::get_available_modules( false, false, true, null ) as $module_slug ) {
201            if ( in_array( $module_slug, $partial_modules, true ) ) {
202                continue;
203            }
204
205            $module = Jetpack::get_module( $module_slug );
206            if ( ! $module ) {
207                continue;
208            }
209
210            $module_name              = isset( $module['name'] ) && '' !== $module['name'] ? $module['name'] : self::get_module_name_fallback( $module_slug );
211            $feature_name             = self::get_requires_connection_name( $module_slug, $module_name );
212            $features[ $module_slug ] = array(
213                'slug'              => $module_slug,
214                'module'            => $module_slug,
215                'name'              => $feature_name,
216                'description'       => self::get_requires_connection_description( $module_slug, $module['description'] ?? $module_name ),
217                'type'              => self::TYPE_REQUIRES_CONNECTION,
218                'group'             => self::get_module_group( $module_slug ),
219                'active'            => Jetpack::is_module_active( $module_slug ),
220                'available'         => false,
221                'recommended'       => false,
222                'toggleable'        => false,
223                'limitation'        => __( 'This feature requires a WordPress.com connection and is unavailable in Offline Mode.', 'jetpack' ),
224                'underlying_module' => $module_slug,
225                'documentation_url' => self::get_documentation_url( $module_slug ),
226            );
227        }
228
229        foreach ( self::get_requires_connection_non_module_features() as $feature ) {
230            $features[ $feature['slug'] ] = $feature;
231        }
232
233        uasort(
234            $features,
235            function ( $a, $b ) {
236                return strcasecmp( $a['name'], $b['name'] );
237            }
238        );
239
240        return array_values( $features );
241    }
242
243    /**
244     * Allow curated partial modules to activate and load in Offline Mode.
245     *
246     * @param bool   $allow        Whether the module is already allowed.
247     * @param string $module       Module slug.
248     * @param array  $_module_data Module metadata.
249     * @return bool
250     */
251    public static function allow_partial_module_in_offline_mode( $allow, $module, $_module_data = array() ) {
252        unset( $_module_data );
253
254        if ( $allow ) {
255            return true;
256        }
257
258        $partial_modules = wp_list_pluck( self::get_partial_features(), 'module' );
259        return in_array( $module, $partial_modules, true );
260    }
261
262    /**
263     * Get dashboard groups.
264     *
265     * @return array
266     */
267    public static function get_groups() {
268        return array(
269            'content'     => __( 'Content and editor', 'jetpack' ),
270            'audience'    => __( 'Audience and engagement', 'jetpack' ),
271            'media'       => __( 'Media', 'jetpack' ),
272            'performance' => __( 'Performance', 'jetpack' ),
273            'traffic'     => __( 'Traffic and discovery', 'jetpack' ),
274            'theme'       => __( 'Theme enhancements', 'jetpack' ),
275            'other'       => __( 'Other local features', 'jetpack' ),
276        );
277    }
278
279    /**
280     * Check whether Jetpack Boost is active or loaded.
281     *
282     * @return bool
283     */
284    private static function is_boost_available() {
285        if ( defined( 'JETPACK_BOOST_VERSION' ) || class_exists( 'Automattic\Jetpack_Boost\Jetpack_Boost' ) ) {
286            return true;
287        }
288
289        $boost_plugins = array(
290            'boost/jetpack-boost.php',
291            'jetpack-boost/jetpack-boost.php',
292            'jetpack-boost-dev/jetpack-boost.php',
293        );
294
295        $active_plugins = (array) get_option( 'active_plugins', array() );
296        if ( array_intersect( $boost_plugins, $active_plugins ) ) {
297            return true;
298        }
299
300        $network_active_plugins = (array) get_site_option( 'active_sitewide_plugins', array() );
301        foreach ( $boost_plugins as $boost_plugin ) {
302            if ( isset( $network_active_plugins[ $boost_plugin ] ) ) {
303                return true;
304            }
305        }
306
307        return false;
308    }
309
310    /**
311     * Get fully offline module feature data.
312     *
313     * @return array
314     */
315    private static function get_offline_module_features() {
316        $features            = array();
317        $recommended_modules = self::get_recommended_modules();
318        $limited_modules     = self::get_limited_offline_modules();
319
320        foreach ( Jetpack::get_available_modules( false, false, false, null ) as $module_slug ) {
321            $module = Jetpack::get_module( $module_slug );
322            if ( ! $module ) {
323                continue;
324            }
325
326            $module_name = isset( $module['name'] ) && '' !== $module['name'] ? $module['name'] : self::get_module_name_fallback( $module_slug );
327            $limitation  = $limited_modules[ $module_slug ] ?? '';
328
329            $features[] = array(
330                'slug'              => $module_slug,
331                'module'            => $module_slug,
332                'name'              => $module_name,
333                'description'       => $module['description'] ?? $module_name,
334                'type'              => $limitation ? self::TYPE_PARTIAL : self::TYPE_MODULE,
335                'group'             => self::get_module_group( $module_slug ),
336                'active'            => Jetpack::is_module_active( $module_slug ),
337                'available'         => true,
338                'recommended'       => in_array( $module_slug, $recommended_modules, true ),
339                'toggleable'        => true,
340                'limitation'        => $limitation,
341                'underlying_module' => $module_slug,
342                'documentation_url' => self::get_documentation_url( $module_slug ),
343            );
344        }
345
346        return $features;
347    }
348
349    /**
350     * Get connection-free modules with mixed offline support.
351     *
352     * @return array
353     */
354    private static function get_limited_offline_modules() {
355        return array(
356            'blocks'     => __( 'The Blocks module loads local editor support in Offline Mode. Blocks and editor tools that require WordPress.com services, such as AI, stats, likes, social publishing, payments, Instagram, and related content, remain unavailable.', 'jetpack' ),
357            'shortcodes' => __( 'Most shortcode embeds can be tested locally. Instagram and Twitter oEmbed proxy helpers require a WordPress.com connection and are not loaded in Offline Mode.', 'jetpack' ),
358            'widgets'    => __( 'Most Jetpack widgets can be tested locally. Widgets that depend on Stats, followers, community data, or WordPress.com APIs are unavailable in Offline Mode.', 'jetpack' ),
359        );
360    }
361
362    /**
363     * Get connection-required non-module features.
364     *
365     * @return array
366     */
367    private static function get_requires_connection_non_module_features() {
368        return array(
369            'activity-log' => self::get_requires_connection_feature(
370                'activity-log',
371                __( 'Activity Log', 'jetpack' ),
372                __( 'Requires a WordPress.com connection to collect, sync, and display site activity history.', 'jetpack' ),
373                'security'
374            ),
375            'jetpack-ai'   => self::get_requires_connection_feature(
376                'jetpack-ai',
377                __( 'Jetpack AI', 'jetpack' ),
378                __( 'Requires a WordPress.com connection to generate, process, and manage AI content.', 'jetpack' ),
379                'content'
380            ),
381            'payments'     => self::get_requires_connection_feature(
382                'payments',
383                __( 'Payments and paid content', 'jetpack' ),
384                __( 'Requires a WordPress.com connection for payment accounts, paid plans, subscriber authentication, and checkout flows.', 'jetpack' ),
385                'audience'
386            ),
387            'scan'         => self::get_requires_connection_feature(
388                'scan',
389                __( 'Jetpack Scan', 'jetpack' ),
390                __( 'Requires a WordPress.com connection to scan site files and receive security results.', 'jetpack' ),
391                'security'
392            ),
393        );
394    }
395
396    /**
397     * Build a connection-required non-module feature entry.
398     *
399     * @param string $slug        Feature slug.
400     * @param string $name        Feature name.
401     * @param string $description Feature description.
402     * @param string $group       Feature group.
403     * @return array
404     */
405    private static function get_requires_connection_feature( $slug, $name, $description, $group ) {
406        return array(
407            'slug'              => $slug,
408            'module'            => '',
409            'name'              => $name,
410            'description'       => $description,
411            'type'              => self::TYPE_REQUIRES_CONNECTION,
412            'group'             => $group,
413            'active'            => false,
414            'available'         => false,
415            'recommended'       => false,
416            'toggleable'        => false,
417            'limitation'        => __( 'This feature requires a WordPress.com connection and is unavailable in Offline Mode.', 'jetpack' ),
418            'underlying_module' => '',
419            'documentation_url' => self::get_documentation_url( $slug ),
420        );
421    }
422
423    /**
424     * Get connection-required description for a module.
425     *
426     * @param string $module              Module slug.
427     * @param string $fallback_description Module description.
428     * @return string
429     */
430    private static function get_requires_connection_description( $module, $fallback_description ) {
431        $descriptions = array(
432            'comments'              => __( 'Requires a WordPress.com connection for the enhanced Jetpack comment form and its WordPress.com API-backed flows.', 'jetpack' ),
433            'comment-likes'         => __( 'Requires a WordPress.com connection so visitors can like individual comments.', 'jetpack' ),
434            'json-api'              => __( 'Requires a WordPress.com connection to expose site data through the WordPress.com REST API.', 'jetpack' ),
435            'monitor'               => __( 'Requires a WordPress.com connection to monitor uptime and send downtime alerts.', 'jetpack' ),
436            'notes'                 => __( 'Requires a connected WordPress.com user to receive notifications across devices.', 'jetpack' ),
437            'photon'                => __( 'Requires a WordPress.com connection to resize, optimize, and serve images through Jetpack Image CDN.', 'jetpack' ),
438            'post-by-email'         => __( 'Requires a connected WordPress.com user to generate and use a private posting email address.', 'jetpack' ),
439            'protect'               => __( 'Requires a WordPress.com connection to check login attempts against Jetpack protection services.', 'jetpack' ),
440            'publicize'             => __( 'Requires a connected WordPress.com user and social accounts to publish posts to social networks.', 'jetpack' ),
441            'related-posts'         => __( 'Requires a WordPress.com connection to index content and calculate related posts.', 'jetpack' ),
442            'search'                => __( 'Requires a WordPress.com connection to index site content for Jetpack Search.', 'jetpack' ),
443            'shortlinks'            => __( 'Requires a WordPress.com connection to create and resolve WP.me shortlinks.', 'jetpack' ),
444            'sso'                   => __( 'Requires a connected WordPress.com user for WordPress.com Secure Sign On.', 'jetpack' ),
445            'stats'                 => __( 'Requires a WordPress.com connection to collect and display site traffic data.', 'jetpack' ),
446            'vaultpress'            => __( 'Requires a WordPress.com connection to store backups and manage restores.', 'jetpack' ),
447            'videopress'            => __( 'Requires a WordPress.com connection to upload, process, host, and serve videos.', 'jetpack' ),
448            'waf'                   => __( 'Requires a WordPress.com connection to manage Jetpack firewall rules and updates.', 'jetpack' ),
449            'woocommerce-analytics' => __( 'Requires a WordPress.com connection to sync and display WooCommerce analytics data.', 'jetpack' ),
450            'wordads'               => __( 'Requires a WordPress.com connection to configure ads, consent management, and revenue reporting.', 'jetpack' ),
451        );
452
453        return $descriptions[ $module ] ?? sprintf(
454            /* translators: %s: Jetpack feature description. */
455            __( '%s This feature requires a WordPress.com connection and is unavailable in Offline Mode.', 'jetpack' ),
456            $fallback_description
457        );
458    }
459
460    /**
461     * Get display name for a connection-required module.
462     *
463     * @param string $module        Module slug.
464     * @param string $fallback_name Module name.
465     * @return string
466     */
467    private static function get_requires_connection_name( $module, $fallback_name ) {
468        $names = array(
469            'comments' => __( 'Jetpack Comments', 'jetpack' ),
470        );
471
472        return $names[ $module ] ?? $fallback_name;
473    }
474
475    /**
476     * Get the Jetpack Redirect URL for a feature's documentation.
477     *
478     * @param string $feature Feature or module slug.
479     * @return string
480     */
481    private static function get_documentation_url( $feature ) {
482        $redirect_sources = array(
483            'blocks'               => 'jetpack-support-blocks',
484            'boost'                => 'jetpack-support-boost',
485            'canonical-urls'       => 'jetpack-support-canonical-urls',
486            'carousel'             => 'jetpack-support-carousel',
487            'contact-form'         => 'jetpack-support-contact-form',
488            'copy-post'            => 'jetpack-support-copy-post',
489            'custom-content-types' => 'jetpack-support-custom-content-types',
490            'google-fonts'         => 'jetpack-support-google-fonts',
491            'gravatar-hovercards'  => 'jetpack-support-gravatar-hovercards',
492            'infinite-scroll'      => 'jetpack-support-infinite-scroll',
493            'jetpack-ai'           => 'jetpack-ai',
494            'latex'                => 'jetpack-support-beautiful-math-with-latex',
495            'markdown'             => 'jetpack-support-markdown',
496            'newsletter'           => 'https://jetpack.com/support/newsletter',
497            'payments'             => 'jetpack-support-payments',
498            'photon-cdn'           => 'jetpack-support-asset-cdn',
499            'post-list'            => 'jetpack-support-post-list',
500            'scan'                 => 'jetpack-support-scan',
501            'sharedaddy'           => 'jetpack-support-sharing',
502            'shortcodes'           => 'jetpack-support-shortcode-embeds',
503            'seo-tools'            => 'jetpack-support-seo-tools',
504            'sitemaps'             => 'jetpack-support-sitemaps',
505            'theme-tools'          => 'jetpack-support-theme-tools',
506            'tiled-gallery'        => 'jetpack-support-tiled-galleries',
507            'verification-tools'   => 'jetpack-support-site-verification-tools',
508            'widget-visibility'    => 'jetpack-support-widget-visibility',
509            'widgets'              => 'jetpack-support-extra-sidebar-widgets',
510            'wpcom-reader'         => 'jetpack-support-reader',
511        );
512
513        $source = $redirect_sources[ $feature ] ?? 'jetpack-support-' . $feature;
514        return Redirect::get_url( $source );
515    }
516
517    /**
518     * Get a readable module name from a module slug.
519     *
520     * @param string $module Module slug.
521     * @return string
522     */
523    private static function get_module_name_fallback( $module ) {
524        return ucwords( str_replace( '-', ' ', $module ) );
525    }
526
527    /**
528     * Map module slugs to developer dashboard groups.
529     *
530     * @param string $module Module slug.
531     * @return string
532     */
533    private static function get_module_group( $module ) {
534        $groups = array(
535            'content'  => array( 'blocks', 'contact-form', 'copy-post', 'custom-content-types', 'latex', 'markdown', 'shortcodes' ),
536            'audience' => array( 'gravatar-hovercards', 'sharedaddy', 'wpcom-reader' ),
537            'media'    => array( 'carousel', 'photon-cdn', 'tiled-gallery' ),
538            'traffic'  => array( 'canonical-urls', 'seo-tools', 'sitemaps', 'verification-tools' ),
539            'theme'    => array( 'google-fonts', 'infinite-scroll', 'post-list', 'widget-visibility', 'widgets' ),
540        );
541
542        foreach ( $groups as $group => $modules ) {
543            if ( in_array( $module, $modules, true ) ) {
544                return $group;
545            }
546        }
547
548        return 'other';
549    }
550}