Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
182 / 195
72.22% covered (warning)
72.22%
13 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Connector
93.33% covered (success)
93.33%
182 / 195
72.22% covered (warning)
72.22%
13 / 18
82.94
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 register_connector
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 enqueue_script_module
94.74% covered (success)
94.74%
36 / 38
0.00% covered (danger)
0.00%
0 / 1
8.01
 get_module_asset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_connector_data
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
9
 add_identity_crisis_data
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
8.02
 is_tracking_allowed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_protected_owner_card_state
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_connection_owner_edit_url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 is_connectors_screen
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_connectors_page_path
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 store_auth_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 consume_auth_error
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_plugin_logos
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_connected_plugin_families
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 get_connector_logo_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 get_inline_connector_logo_url
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_plugin_logo_url
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
1<?php
2/**
3 * Jetpack connector card for the WP core Connectors screen.
4 *
5 * Registers a connector in the WP 7.0+ Connectors registry and enqueues
6 * a script module that provides a custom render function with connection
7 * details (owner, connected plugins, disconnect).
8 *
9 * @package automattic/jetpack-connection
10 */
11
12namespace Automattic\Jetpack\Connection;
13
14use Automattic\Jetpack\Assets;
15use Automattic\Jetpack\Identity_Crisis;
16use Automattic\Jetpack\IdentityCrisis\UI;
17use Automattic\Jetpack\Modules;
18use Automattic\Jetpack\Status;
19use Automattic\Jetpack\Status\Host;
20use Automattic\Jetpack\Terms_Of_Service;
21use Automattic\Jetpack\Tracking;
22
23/**
24 * Jetpack connector card handler.
25 *
26 * @since 8.2.0
27 */
28class Jetpack_Connector {
29
30    /**
31     * Whether the connector has been initialized.
32     *
33     * @var bool
34     */
35    private static $initialized = false;
36
37    /**
38     * Script module identifier.
39     *
40     * @var string
41     */
42    const MODULE_ID = '@automattic/jetpack-connection-connectors';
43
44    /**
45     * Path of the card's built script module relative to this file, without the extension.
46     *
47     * @since 9.11.0
48     *
49     * @var string
50     */
51    const MODULE_FILE = '../../dist/connectors/connectors-card';
52
53    /**
54     * Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
55     *
56     * @var string
57     */
58    const GUTENBERG_CONNECTORS_SCREEN_ID = 'settings_page_options-connectors-wp-admin';
59
60    /**
61     * Page slug registered by the Gutenberg plugin for the connectors submenu page.
62     *
63     * @var string
64     */
65    const GUTENBERG_CONNECTORS_PAGE_SLUG = 'options-connectors-wp-admin';
66
67    /**
68     * Initialize the connector.
69     */
70    public static function init() {
71        if ( static::$initialized ) {
72            return;
73        }
74        static::$initialized = true;
75
76        add_action( 'wp_connectors_init', array( static::class, 'register_connector' ), 20 );
77        add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_script_module' ) );
78        add_action( 'jetpack_client_authorize_error', array( static::class, 'store_auth_error' ) );
79    }
80
81    /**
82     * Register Jetpack as a connector in the WP core Connectors screen.
83     *
84     * The wp_connectors_init action is available in WordPress 7.0+.
85     * On older versions this action never fires, so the hook is safely a no-op.
86     *
87     * @since 8.2.0
88     *
89     * @param \WP_Connector_Registry $registry Connector registry instance.
90     */
91    public static function register_connector( $registry ) {
92        $registry->register(
93            'wordpress_com',
94            array(
95                'name'           => 'Jetpack Connection',
96                'description'    => __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com cloud services.', 'jetpack-connection' ),
97                'type'           => 'cloud_service',
98                'logo_url'       => static::get_connector_logo_url(),
99                'authentication' => array(
100                    'method' => 'none',
101                ),
102            )
103        );
104    }
105
106    /**
107     * Enqueue the connectors card script module on the Settings > Connectors page.
108     *
109     * Skips the card when its built files are missing.
110     *
111     * @since 8.2.0
112     * @since 9.11.0 Loads the webpack build from dist/, with its dependencies and version from the asset file.
113     * @since 9.14.0 Loads the Tracks script when tracking is allowed.
114     */
115    public static function enqueue_script_module() {
116        $screen = get_current_screen();
117
118        if ( ! $screen || ! static::is_connectors_screen( $screen ) ) {
119            return;
120        }
121
122        if ( ! class_exists( 'WP_Connector_Registry' ) ) {
123            return;
124        }
125
126        // The built files are missing in a checkout that hasn't run the build, or mid-deploy.
127        $asset = static::get_module_asset( __DIR__ . '/' . static::MODULE_FILE );
128        if ( null === $asset ) {
129            return;
130        }
131
132        $css_path = __DIR__ . '/css/connectors-card.css';
133        wp_enqueue_style(
134            'jetpack-connector-card',
135            plugins_url( 'css/connectors-card.css', __FILE__ ),
136            array(),
137            (string) @filemtime( $css_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
138        );
139
140        wp_register_script_module(
141            static::MODULE_ID,
142            // Resolve the '..' so core finds translations under the file's real path, as Assets::register_script() does.
143            Assets::normalize_path( plugins_url( static::MODULE_FILE . '.js', __FILE__ ) ),
144            $asset['module_dependencies'] ?? array(),
145            $asset['version'] ?? false
146        );
147        // WP 7.0+; the Gutenberg plugin's Connectors screen can run on older cores.
148        if ( function_exists( 'wp_set_script_module_translations' ) ) {
149            wp_set_script_module_translations( static::MODULE_ID, 'jetpack-connection' );
150        }
151        wp_enqueue_script_module( static::MODULE_ID );
152
153        // A script module can't depend on classic scripts, so the ones the card imports are enqueued beside it.
154        // Assets::enqueue_script also loads the stylesheet registered with the handle.
155        foreach ( $asset['dependencies'] ?? array() as $handle ) {
156            Assets::enqueue_script( $handle );
157        }
158
159        // The analytics package adds blog_id to every event from this global. Ungated, as an identity crisis loads Tracks without consent; same line as Initial_State::render().
160        wp_add_inline_script(
161            'jetpack-connection',
162            sprintf( 'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %d;', \Jetpack_Options::get_option( 'id', 0 ) ),
163            'before'
164        );
165
166        // The shared components the card renders push Tracks events to _tkq; only w.js is needed to send them. Same gate as My Jetpack.
167        if ( static::is_tracking_allowed() ) {
168            Tracking::register_tracks_functions_scripts();
169            wp_enqueue_script( 'jp-tracks' );
170        }
171
172        add_filter(
173            'script_module_data_' . static::MODULE_ID,
174            array( static::class, 'get_connector_data' )
175        );
176    }
177
178    /**
179     * Read the asset file the build emits beside the card's script module.
180     *
181     * @since 9.11.0
182     *
183     * @param string $module_path Path of the built module, without the extension.
184     * @return array|null Asset data, or null if the module or its asset file is missing, or the asset is invalid.
185     */
186    private static function get_module_asset( $module_path ) {
187        $asset_path = $module_path . '.asset.php';
188        if ( ! file_exists( $module_path . '.js' ) || ! file_exists( $asset_path ) ) {
189            return null;
190        }
191
192        $asset = require $asset_path;
193
194        return is_array( $asset ) ? $asset : null;
195    }
196
197    /**
198     * Build the data passed to the script module via the script_module_data_ filter.
199     *
200     * Card-only data. Connection state that the package's components also read (registration,
201     * owner, viewer, connected plugins, offline mode) comes from the jetpack-connection store,
202     * which Initial_State fills, so it is not repeated here.
203     *
204     * @since 8.2.0
205     * @since 9.13.0 Shared connection state moved to the jetpack-connection store.
206     * @since 9.14.0 Adds `connectorsUrl`; sends `connectedUsersUrl` whenever there are account rows, including while the owner's token is broken.
207     *
208     * @param array $data Existing script module data.
209     * @return array Filtered script module data.
210     */
211    public static function get_connector_data( $data ) {
212        $manager       = new Manager();
213        $is_registered = $manager->is_connected();
214
215        $data['apiRoot']              = esc_url_raw( rest_url() );
216        $data['apiNonce']             = wp_create_nonce( 'wp_rest' );
217        $data['redirectUri']          = static::get_connectors_page_path();
218        $data['connectorsUrl']        = admin_url( $data['redirectUri'] );
219        $data['connectorName']        = 'Jetpack Connection';
220        $data['connectorDescription'] = __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com cloud services.', 'jetpack-connection' );
221        $data['connectorLogoUrl']     = static::get_connector_logo_url();
222
223        $connected_plugins   = $manager->get_connected_plugins();
224        $connected_plugins   = is_array( $connected_plugins ) ? $connected_plugins : array();
225        $data['pluginLogos'] = static::get_plugin_logos( array_keys( $connected_plugins ) );
226
227        if ( $is_registered ) {
228            $data['siteDetails'] = array(
229                'siteUrl' => site_url(),
230                'homeUrl' => home_url(),
231            );
232
233            if ( array_key_exists( 'jetpack', $connected_plugins ) ) {
234                $data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
235            }
236
237            static::add_identity_crisis_data( $data );
238        }
239
240        // Not gated on has_connected_owner(): it is false when the owner's token breaks,
241        // and the card still lists the owner of record and a healthy viewer's account then.
242        if ( $is_registered && ( (int) \Jetpack_Options::get_option( 'master_user' ) > 0 || $manager->is_user_connected() ) ) {
243            $data['connectedUsersUrl'] = Users_Connection_Admin::get_connected_view_url();
244        }
245
246        $data['connectionOwnerEditUrl'] = static::get_connection_owner_edit_url( ( new Status() )->is_offline_mode() );
247
248        $protected_owner = static::get_protected_owner_card_state( $manager );
249        if ( null !== $protected_owner ) {
250            $data['protectedOwner'] = $protected_owner;
251        }
252
253        $host              = new Host();
254        $data['isWoaSite'] = $host->is_woa_site();
255        $data['isVipSite'] = $host->is_vip_site();
256
257        $auth_error = static::consume_auth_error();
258        if ( $auth_error ) {
259            $data['authError'] = $auth_error;
260        }
261
262        return $data;
263    }
264
265    /**
266     * Add Jetpack Identity Crisis (Safe Mode) data to the script module data.
267     *
268     * Sent only in Safe Mode, which the card reads from the store's `connectionStatus.isInSafeMode`.
269     * The card uses it to render IDC resolution options (migrate / start fresh / stay in safe
270     * mode) in the expanded details. Mirrors the data assembled by
271     * \Automattic\Jetpack\IdentityCrisis\UI::get_initial_state_data().
272     *
273     * @since 8.7.0
274     * @since 9.13.0 No longer sends `isInSafeMode`; the card reads it from the store.
275     *
276     * @param array $data Script module data passed by reference.
277     */
278    private static function add_identity_crisis_data( &$data ) {
279        if ( ! class_exists( Identity_Crisis::class ) ) {
280            return;
281        }
282
283        if ( ! ( new Status() )->in_safe_mode() ) {
284            return;
285        }
286
287        $idc_urls = Identity_Crisis::get_mismatched_urls();
288
289        $data['isSafeModeConfirmed'] = (bool) Identity_Crisis::$is_safe_mode_confirmed;
290        $data['idc']                 = array(
291            'currentUrl'                     => ( is_array( $idc_urls ) && array_key_exists( 'current_url', $idc_urls ) ) ? $idc_urls['current_url'] : home_url(),
292            'wpcomHomeUrl'                   => ( is_array( $idc_urls ) && array_key_exists( 'wpcom_url', $idc_urls ) ) ? $idc_urls['wpcom_url'] : '',
293            'isDevelopmentSite'              => (bool) Status::is_development_site(),
294            'possibleDynamicSiteUrlDetected' => (bool) Identity_Crisis::detect_possible_dynamic_site_url(),
295            // Guarded: an older UI class (mid-update, or the abandoned jetpack-identity-crisis package another plugin bundles) has it private.
296            'platform'                       => is_callable( array( UI::class, 'get_platform' ) ) ? UI::get_platform() : null,
297            'bumpStats'                      => static::is_tracking_allowed(),
298        );
299    }
300
301    /**
302     * Whether the site allows tracking, the same gate My Jetpack uses for Tracks.
303     *
304     * @since $$next-version$$
305     *
306     * @return bool
307     */
308    private static function is_tracking_allowed() {
309        return ( new Tracking() )->should_enable_tracking( new Terms_Of_Service(), new Status() );
310    }
311
312    /**
313     * Protected-owner state for the connector card.
314     *
315     * Omitted when nothing requests a protected owner and no anchor is stored,
316     * so the card keeps its current account sections. The status is
317     * Manager::resolve_protected_owner_state(), which the card switches on.
318     *
319     * `viewerIsConfirmedOwner` is that method's `is_current_user_the_po`. It tells the recovery
320     * copy apart: an owner whose own token broke is asked to reconnect, not to connect.
321     *
322     * The card always uses the package dialogs. `jetpack_connection_protected_owner_default_ui`
323     * is for a consumer's own surface, not this one.
324     *
325     * @since 9.9.0
326     *
327     * @param Manager $manager Connection manager instance.
328     * @return array{status: string, viewerIsConfirmedOwner: bool}|null
329     */
330    private static function get_protected_owner_card_state( $manager ) {
331        $requires = $manager->requires_protected_owner();
332        $anchor   = Protected_Owner::get_locked();
333
334        if ( ! $requires && ! $anchor ) {
335            return null;
336        }
337
338        $state = $manager->resolve_protected_owner_state();
339
340        // No anchor and this viewer cannot confirm: leave the card as it is.
341        if ( ! $anchor && Manager::PO_STATE_NOT_ELIGIBLE === $state['status'] ) {
342            return null;
343        }
344
345        return array(
346            'status'                 => $state['status'],
347            'viewerIsConfirmedOwner' => $state['is_current_user_the_po'],
348        );
349    }
350
351    /**
352     * The connection owner's profile URL, for a viewer who may open it.
353     *
354     * Needs `jetpack_connect` outside offline mode, so a restricted site admin sees the name as plain
355     * text. get_edit_user_link() adds the `edit_user` check, since multisite site admins cannot edit other users.
356     *
357     * @since 9.13.0
358     *
359     * @param bool $is_offline_mode Whether the site is in offline mode.
360     * @return string|null
361     */
362    private static function get_connection_owner_edit_url( $is_offline_mode ) {
363        if ( ! $is_offline_mode && ! current_user_can( 'jetpack_connect' ) ) {
364            return null;
365        }
366
367        $owner_id = (int) \Jetpack_Options::get_option( 'master_user' );
368        $url      = $owner_id > 0 ? get_edit_user_link( $owner_id ) : '';
369
370        return '' !== $url ? $url : null;
371    }
372
373    /**
374     * Check whether the given screen is the Connectors settings page.
375     *
376     * Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
377     * plugin (`settings_page_options-connectors-wp-admin`).
378     *
379     * @param \WP_Screen $screen Current admin screen.
380     * @return bool
381     */
382    private static function is_connectors_screen( $screen ) {
383        return 'options-connectors' === $screen->id
384            || static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
385    }
386
387    /**
388     * Return the admin-relative path for the Connectors page.
389     *
390     * WP 7.0 core uses the standalone `options-connectors.php` file while
391     * the Gutenberg plugin registers a submenu page under options-general.php
392     * with slug `options-connectors-wp-admin`. Both set parent_file to
393     * `options-general.php` for menu highlighting, so we distinguish them by
394     * checking the actual script filename being served.
395     *
396     * Note: for the Gutenberg case we use the registered page slug directly,
397     * not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
398     * (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
399     * as the `page=` parameter produces an invalid URL.
400     *
401     * The result is suitable for the `redirect_uri` parameter accepted by the
402     * `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
403     *
404     * @return string Admin-relative path, e.g. 'options-connectors.php'.
405     */
406    private static function get_connectors_page_path() {
407        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
408        $script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
409
410        if ( 'options-connectors.php' === $script ) {
411            return 'options-connectors.php';
412        }
413
414        // Gutenberg plugin registers the page under options-general.php.
415        $screen = get_current_screen();
416        if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
417            return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
418        }
419
420        return 'options-connectors.php';
421    }
422
423    /**
424     * Store an authorization error in a short-lived transient.
425     *
426     * Hooked to `jetpack_client_authorize_error` which fires when
427     * the auth webhook fails. The transient is read on the next
428     * Connectors page load so the JS card can display the error.
429     *
430     * @since 8.2.0
431     *
432     * @param \WP_Error $error Authorization error.
433     */
434    public static function store_auth_error( $error ) {
435        if ( is_wp_error( $error ) ) {
436            $user_id = get_current_user_id();
437            if ( $user_id ) {
438                set_transient(
439                    'jetpack_connector_auth_error_' . $user_id,
440                    $error->get_error_message(),
441                    60
442                );
443            }
444        }
445    }
446
447    /**
448     * Read and delete a stored authorization error for the current user.
449     *
450     * @return string|false Error message or false if none.
451     */
452    private static function consume_auth_error() {
453        $user_id = get_current_user_id();
454        if ( ! $user_id ) {
455            return false;
456        }
457
458        $key   = 'jetpack_connector_auth_error_' . $user_id;
459        $error = get_transient( $key );
460        if ( false !== $error ) {
461            delete_transient( $key );
462        }
463
464        return $error;
465    }
466
467    /**
468     * Map connected plugin slugs to their logo URLs.
469     *
470     * The plugin list itself comes from the jetpack-connection store; logos stay here because
471     * third parties add them through a PHP filter.
472     *
473     * @since 9.13.0
474     *
475     * @param string[] $slugs Connected plugin slugs.
476     * @return array<string,string> Logo URL by slug, for slugs that have one.
477     */
478    private static function get_plugin_logos( $slugs ) {
479        $logos = array();
480
481        foreach ( $slugs as $slug ) {
482            $logo_url = static::get_plugin_logo_url( (string) $slug );
483            if ( $logo_url ) {
484                $logos[ $slug ] = $logo_url;
485            }
486        }
487
488        return $logos;
489    }
490
491    /**
492     * Detect which plugin families are using the connection.
493     *
494     * @since 8.5.0
495     *
496     * @return array{has_woo: bool, has_a4a: bool}
497     */
498    public static function get_connected_plugin_families() {
499        $plugins = Plugin_Storage::get_all();
500
501        $has_woo = false;
502        $has_a4a = false;
503
504        if ( is_array( $plugins ) ) {
505            foreach ( array_keys( $plugins ) as $slug ) {
506                if ( str_starts_with( $slug, 'woocommerce' ) ) {
507                    $has_woo = true;
508                }
509                if ( str_starts_with( $slug, 'automattic' ) ) {
510                    $has_a4a = true;
511                }
512            }
513        }
514
515        return array(
516            'has_woo' => $has_woo,
517            'has_a4a' => $has_a4a,
518        );
519    }
520
521    /**
522     * Determine the connector card logo based on which plugin families are connected.
523     *
524     * Priority:
525     * 1. Both Woo-family and A4A plugins → jetpack-connect-all.svg
526     * 2. Woo-family only                 → jetpack-connect-woo.svg
527     * 3. A4A only                        → jetpack-connect-a8c.svg
528     * 4. Default (Jetpack only or other) → jetpack-connect.svg
529     *
530     * @since 8.3.2
531     *
532     * @return string Logo URL.
533     */
534    public static function get_connector_logo_url() {
535        $families = self::get_connected_plugin_families();
536
537        if ( $families['has_woo'] && $families['has_a4a'] ) {
538            return plugins_url( 'images/jetpack-connect-all.svg', __FILE__ );
539        }
540
541        if ( $families['has_woo'] ) {
542            return plugins_url( 'images/jetpack-connect-woo.svg', __FILE__ );
543        }
544
545        if ( $families['has_a4a'] ) {
546            return plugins_url( 'images/jetpack-connect-a8c.svg', __FILE__ );
547        }
548
549        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
550    }
551
552    /**
553     * Get the inline (single-row) connector logo for use in compact contexts like table cells.
554     *
555     * All circles are arranged horizontally in a single row, unlike the card
556     * logos which stack circles vertically for 3+ plugins.
557     *
558     * @since 8.5.0
559     *
560     * @return string Logo URL.
561     */
562    public static function get_inline_connector_logo_url() {
563        $families = self::get_connected_plugin_families();
564
565        if ( $families['has_woo'] && $families['has_a4a'] ) {
566            return plugins_url( 'images/jetpack-connect-all-inline.svg', __FILE__ );
567        }
568
569        if ( $families['has_woo'] ) {
570            return plugins_url( 'images/jetpack-connect-woo-inline.svg', __FILE__ );
571        }
572
573        if ( $families['has_a4a'] ) {
574            return plugins_url( 'images/jetpack-connect-a8c-inline.svg', __FILE__ );
575        }
576
577        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
578    }
579
580    /**
581     * Map a plugin slug to a brand logo URL.
582     *
583     * Jetpack-family plugins get the Jetpack mark, WooCommerce-family
584     * plugins get the Woo mark, and Automattic for Agencies gets the
585     * Automattic mark. Unknown slugs fall through to the
586     * `jetpack_connection_plugin_logo_url` filter so that third-party
587     * plugins can register their own logo. Only SVG URLs are accepted
588     * to keep the icons sharp at every display density.
589     *
590     * @since 8.3.2
591     *
592     * @param string $slug Plugin slug.
593     * @return string|null Logo URL or null.
594     */
595    private static function get_plugin_logo_url( $slug ) {
596        if ( str_starts_with( $slug, 'jetpack' ) ) {
597            return plugins_url( 'images/jetpack-icon.svg', __FILE__ ); // str_starts_with() is polyfilled by WP since 5.9; this code only runs on WP 7.0+.
598        }
599
600        if ( str_starts_with( $slug, 'woocommerce' ) ) {
601            return plugins_url( 'images/woo-icon.svg', __FILE__ );
602        }
603
604        if ( str_starts_with( $slug, 'automattic' ) ) {
605            return plugins_url( 'images/automattic-icon.svg', __FILE__ );
606        }
607
608        /**
609         * Filters a map of plugin slugs to custom logo URLs for the
610         * Settings → Connectors card.
611         *
612         * Add entries as `$slug => $url` pairs. URLs must point to an
613         * SVG file (`.svg` extension required); non-SVG values are
614         * silently ignored and the generic fallback icon is shown.
615         *
616         * Example:
617         *
618         *     add_filter( 'jetpack_connection_plugin_logos', function ( $logos ) {
619         *         $logos['my-plugin'] = plugins_url( 'assets/logo.svg', __FILE__ );
620         *         return $logos;
621         *     } );
622         *
623         * @since 8.3.2
624         *
625         * @param array<string,string> $logos Map of plugin slug to SVG URL.
626         */
627        $logos = apply_filters( 'jetpack_connection_plugin_logos', array() );
628
629        if ( isset( $logos[ $slug ] ) && is_string( $logos[ $slug ] ) && str_ends_with( strtolower( $logos[ $slug ] ), '.svg' ) ) {
630            return esc_url( $logos[ $slug ] );
631        }
632
633        return null;
634    }
635}