Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.79% covered (warning)
74.79%
181 / 242
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Connector
74.79% covered (warning)
74.79%
181 / 242
63.16% covered (warning)
63.16%
12 / 19
212.02
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
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 get_connector_data
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
8.01
 add_identity_crisis_data
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 get_protected_owner_card_state
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 should_enqueue_protected_owner_dialogs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_current_user_data
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 get_connection_owner_data
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 resolve_user_fields
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
10
 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_connected_plugins_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 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\Modules;
17use Automattic\Jetpack\Status;
18use Automattic\Jetpack\Status\Host;
19
20/**
21 * Jetpack connector card handler.
22 *
23 * @since 8.2.0
24 */
25class Jetpack_Connector {
26
27    /**
28     * Whether the connector has been initialized.
29     *
30     * @var bool
31     */
32    private static $initialized = false;
33
34    /**
35     * Script module identifier.
36     *
37     * @var string
38     */
39    const MODULE_ID = '@automattic/jetpack-connection-connectors';
40
41    /**
42     * Screen ID assigned by WordPress to the Gutenberg plugin's connectors submenu page.
43     *
44     * @var string
45     */
46    const GUTENBERG_CONNECTORS_SCREEN_ID = 'settings_page_options-connectors-wp-admin';
47
48    /**
49     * Page slug registered by the Gutenberg plugin for the connectors submenu page.
50     *
51     * @var string
52     */
53    const GUTENBERG_CONNECTORS_PAGE_SLUG = 'options-connectors-wp-admin';
54
55    /**
56     * Initialize the connector.
57     */
58    public static function init() {
59        if ( static::$initialized ) {
60            return;
61        }
62        static::$initialized = true;
63
64        add_action( 'wp_connectors_init', array( static::class, 'register_connector' ), 20 );
65        add_action( 'admin_enqueue_scripts', array( static::class, 'enqueue_script_module' ) );
66        add_action( 'jetpack_client_authorize_error', array( static::class, 'store_auth_error' ) );
67    }
68
69    /**
70     * Register Jetpack as a connector in the WP core Connectors screen.
71     *
72     * The wp_connectors_init action is available in WordPress 7.0+.
73     * On older versions this action never fires, so the hook is safely a no-op.
74     *
75     * @since 8.2.0
76     *
77     * @param \WP_Connector_Registry $registry Connector registry instance.
78     */
79    public static function register_connector( $registry ) {
80        $registry->register(
81            'wordpress_com',
82            array(
83                'name'           => 'Jetpack Connection',
84                'description'    => __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' ),
85                'type'           => 'cloud_service',
86                'logo_url'       => static::get_connector_logo_url(),
87                'authentication' => array(
88                    'method' => 'none',
89                ),
90            )
91        );
92    }
93
94    /**
95     * Enqueue the connectors card script module on the Settings > Connectors page.
96     *
97     * @since 8.2.0
98     */
99    public static function enqueue_script_module() {
100        $screen = get_current_screen();
101
102        if ( ! $screen || ! static::is_connectors_screen( $screen ) ) {
103            return;
104        }
105
106        if ( ! class_exists( 'WP_Connector_Registry' ) ) {
107            return;
108        }
109
110        $css_path = __DIR__ . '/css/connectors-card.css';
111        wp_enqueue_style(
112            'jetpack-connector-card',
113            plugins_url( 'css/connectors-card.css', __FILE__ ),
114            array(),
115            (string) @filemtime( $css_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
116        );
117
118        $js_path = __DIR__ . '/js/connectors-card.js';
119        wp_register_script_module(
120            static::MODULE_ID,
121            plugins_url( 'js/connectors-card.js', __FILE__ ),
122            array(
123                array(
124                    'id'     => '@wordpress/connectors',
125                    'import' => 'static',
126                ),
127            ),
128            (string) @filemtime( $js_path ) // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback to empty string if file is missing.
129        );
130        wp_enqueue_script_module( static::MODULE_ID );
131
132        // Assets::enqueue_script also loads the stylesheet registered with the handle.
133        if ( static::should_enqueue_protected_owner_dialogs( new Manager() ) ) {
134            Assets::enqueue_script( 'jetpack-connection' );
135        }
136
137        add_filter(
138            'script_module_data_' . static::MODULE_ID,
139            array( static::class, 'get_connector_data' )
140        );
141    }
142
143    /**
144     * Build the data passed to the script module via the script_module_data_ filter.
145     *
146     * @since 8.2.0
147     *
148     * @param array $data Existing script module data.
149     * @return array Filtered script module data.
150     */
151    public static function get_connector_data( $data ) {
152        $manager       = new Manager();
153        $is_registered = $manager->is_connected();
154        $is_connected  = $is_registered && $manager->has_connected_owner();
155
156        $data['isConnected']          = $is_connected;
157        $data['isRegistered']         = $is_registered;
158        $data['isOfflineMode']        = ( new Status() )->is_offline_mode();
159        $data['isFirstConnection']    = ! $is_registered && ! (bool) \Jetpack_Options::get_option( 'id' );
160        $data['apiRoot']              = esc_url_raw( rest_url() );
161        $data['apiNonce']             = wp_create_nonce( 'wp_rest' );
162        $data['redirectUri']          = static::get_connectors_page_path();
163        $data['connectorName']        = 'Jetpack Connection';
164        $data['connectorDescription'] = __( 'Enhanced functionality for Jetpack and WooCommerce with WordPress.com.', 'jetpack-connection' );
165        $data['connectorLogoUrl']     = static::get_connector_logo_url();
166
167        $data['connectedPlugins'] = static::get_connected_plugins_data( $manager );
168
169        if ( $is_registered ) {
170            $data['siteDetails'] = array(
171                'blogId'  => (int) \Jetpack_Options::get_option( 'id' ),
172                'siteUrl' => site_url(),
173                'homeUrl' => home_url(),
174            );
175
176            if ( in_array( 'jetpack', array_column( $data['connectedPlugins'], 'slug' ), true ) ) {
177                $data['ssoStatus'] = ( new Modules() )->is_active( 'sso', false );
178            }
179
180            static::add_identity_crisis_data( $data );
181        }
182
183        if ( $is_connected ) {
184            $data['currentUser']     = static::get_current_user_data( $manager );
185            $data['connectionOwner'] = static::get_connection_owner_data( $manager );
186        }
187
188        $protected_owner = static::get_protected_owner_card_state( $manager );
189        if ( null !== $protected_owner ) {
190            $data['protectedOwner'] = $protected_owner;
191        }
192
193        $host              = new Host();
194        $data['isWoaSite'] = $host->is_woa_site();
195        $data['isVipSite'] = $host->is_vip_site();
196
197        $auth_error = static::consume_auth_error();
198        if ( $auth_error ) {
199            $data['authError'] = $auth_error;
200        }
201
202        return $data;
203    }
204
205    /**
206     * Add Jetpack Identity Crisis (Safe Mode) data to the script module data.
207     *
208     * The connector card uses this to swap the status badge to "Safe Mode" and
209     * to render IDC resolution options (migrate / start fresh / stay in safe
210     * mode) in the expanded details. Mirrors the data assembled by
211     * \Automattic\Jetpack\IdentityCrisis\UI::get_initial_state_data().
212     *
213     * @since 8.7.0
214     *
215     * @param array $data Script module data passed by reference.
216     */
217    private static function add_identity_crisis_data( &$data ) {
218        if ( ! class_exists( Identity_Crisis::class ) ) {
219            return;
220        }
221
222        $in_safe_mode = ( new Status() )->in_safe_mode();
223
224        $data['isInSafeMode'] = $in_safe_mode;
225
226        if ( ! $in_safe_mode ) {
227            return;
228        }
229
230        $idc_urls = Identity_Crisis::get_mismatched_urls();
231
232        $data['isSafeModeConfirmed'] = (bool) Identity_Crisis::$is_safe_mode_confirmed;
233        $data['idc']                 = array(
234            'currentUrl'                     => ( is_array( $idc_urls ) && array_key_exists( 'current_url', $idc_urls ) ) ? $idc_urls['current_url'] : home_url(),
235            'wpcomHomeUrl'                   => ( is_array( $idc_urls ) && array_key_exists( 'wpcom_url', $idc_urls ) ) ? $idc_urls['wpcom_url'] : '',
236            'isDevelopmentSite'              => (bool) Status::is_development_site(),
237            'possibleDynamicSiteUrlDetected' => (bool) Identity_Crisis::detect_possible_dynamic_site_url(),
238        );
239    }
240
241    /**
242     * Protected-owner state for the connector card.
243     *
244     * Omitted when nothing requests a protected owner and no anchor is stored,
245     * so the card keeps its current account sections. The status is
246     * Manager::resolve_protected_owner_state(), which the card switches on.
247     *
248     * `viewerIsConfirmedOwner` is that method's `is_current_user_the_po`. It tells the recovery
249     * copy apart: an owner whose own token broke is asked to reconnect, not to connect.
250     *
251     * @since $$next-version$$
252     *
253     * @param Manager $manager Connection manager instance.
254     * @return array{status: string, viewerIsConfirmedOwner: bool}|null
255     */
256    private static function get_protected_owner_card_state( $manager ) {
257        $requires = $manager->requires_protected_owner();
258        $anchor   = Protected_Owner::get_locked();
259
260        if ( ! $requires && ! $anchor ) {
261            return null;
262        }
263
264        $state = $manager->resolve_protected_owner_state();
265
266        // No anchor and this viewer cannot confirm: leave the card as it is.
267        if ( ! $anchor && Manager::PO_STATE_NOT_ELIGIBLE === $state['status'] ) {
268            return null;
269        }
270
271        return array(
272            'status'                 => $state['status'],
273            'viewerIsConfirmedOwner' => $state['is_current_user_the_po'],
274        );
275    }
276
277    /**
278     * Whether the card can open one of the shared protected-owner dialogs.
279     *
280     * Confirming is only reachable for a connected administrator, on a site that has asked for a
281     * protected owner and does not have one yet. Releasing is only reachable for the confirmed
282     * owner, and deliberately does not depend on a consumer still asking: a site has to be able
283     * to give up a lock after the plugin that wanted it is gone.
284     *
285     * Both read the one state call rather than asking again, so neither adds a WordPress.com
286     * round trip to a screen load.
287     *
288     * The card always uses the package dialogs. `jetpack_connection_protected_owner_default_ui`
289     * is for a consumer's own surface, not this one.
290     *
291     * @since $$next-version$$
292     *
293     * @param Manager $manager Connection manager instance.
294     * @return bool
295     */
296    private static function should_enqueue_protected_owner_dialogs( $manager ) {
297        $state = $manager->resolve_protected_owner_state();
298
299        if ( $manager->requires_protected_owner() && Manager::PO_STATE_CAN_ESTABLISH === $state['status'] ) {
300            return true;
301        }
302
303        // `RE_EVALUATE` is the anchored state where the connection owner matches the anchor, so
304        // pinning the viewer to that owner is the same gate the release endpoint applies — a
305        // matching binding alone would offer a dialog the endpoint then refuses.
306        return Manager::PO_STATE_RE_EVALUATE === $state['status']
307            && get_current_user_id() === (int) $manager->get_connection_owner_id();
308    }
309
310    /**
311     * Get the current (logged-in) user's connection details.
312     *
313     * @param Manager $manager Connection manager instance.
314     * @return array|null Current user data or null if not connected.
315     */
316    private static function get_current_user_data( $manager ) {
317        $user_id = get_current_user_id();
318
319        if ( ! $user_id || ! $manager->is_user_connected( $user_id ) ) {
320            return null;
321        }
322
323        $user      = get_userdata( $user_id );
324        $user_info = static::resolve_user_fields( $user, $manager->get_connected_user_data( $user_id ) );
325        $is_owner  = $manager->is_connection_owner( $user_id );
326
327        $has_other_connected_users = false;
328        if ( $is_owner ) {
329            $connected_users           = $manager->get_connected_users( 'any', 2 );
330            $has_other_connected_users = count( $connected_users ) > 1;
331        }
332
333        return array_merge(
334            $user_info,
335            array(
336                'isOwner'                => $is_owner,
337                'hasOtherConnectedUsers' => $has_other_connected_users,
338            )
339        );
340    }
341
342    /**
343     * Get the connection owner details for the script module.
344     *
345     * @param Manager $manager Connection manager instance.
346     * @return array|null Owner data or null if unavailable.
347     */
348    private static function get_connection_owner_data( $manager ) {
349        $owner = $manager->get_connection_owner();
350
351        if ( false === $owner ) {
352            return null;
353        }
354
355        $fields = static::resolve_user_fields( $owner, $manager->get_connected_user_data( $owner->ID ) );
356
357        $fields['localLogin'] = $owner->user_login;
358
359        return $fields;
360    }
361
362    /**
363     * Merge local WP user fields with WordPress.com user data.
364     *
365     * WPCOM values take precedence when available. Returns the common
366     * user shape used by both currentUser and connectionOwner.
367     *
368     * @param \WP_User|false $wp_user        Local WordPress user object (false if unavailable).
369     * @param array|false    $wpcom_user_data WPCOM user data from the connection manager.
370     * @return array User data with displayName, login, email, and avatar.
371     */
372    private static function resolve_user_fields( $wp_user, $wpcom_user_data ) {
373        $display_name = $wp_user ? $wp_user->display_name : '';
374        $login        = $wp_user ? $wp_user->user_login : '';
375        $email        = $wp_user ? $wp_user->user_email : '';
376
377        if ( is_array( $wpcom_user_data ) ) {
378            if ( ! empty( $wpcom_user_data['display_name'] ) ) {
379                $display_name = $wpcom_user_data['display_name'];
380            }
381            if ( ! empty( $wpcom_user_data['login'] ) ) {
382                $login = $wpcom_user_data['login'];
383            }
384            if ( ! empty( $wpcom_user_data['email'] ) ) {
385                $email = $wpcom_user_data['email'];
386            }
387        }
388
389        $user_id = $wp_user ? $wp_user->ID : 0;
390
391        return array(
392            'displayName' => $display_name,
393            'login'       => $login,
394            'email'       => $email,
395            'avatar'      => $user_id
396                ? get_avatar_url(
397                    $user_id,
398                    array(
399                        'size'    => 48,
400                        'default' => 'mysteryman',
401                    )
402                )
403                : '',
404        );
405    }
406
407    /**
408     * Check whether the given screen is the Connectors settings page.
409     *
410     * Handles both WP 7.0 core (`options-connectors`) and the Gutenberg
411     * plugin (`settings_page_options-connectors-wp-admin`).
412     *
413     * @param \WP_Screen $screen Current admin screen.
414     * @return bool
415     */
416    private static function is_connectors_screen( $screen ) {
417        return 'options-connectors' === $screen->id
418            || static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id;
419    }
420
421    /**
422     * Return the admin-relative path for the Connectors page.
423     *
424     * WP 7.0 core uses the standalone `options-connectors.php` file while
425     * the Gutenberg plugin registers a submenu page under options-general.php
426     * with slug `options-connectors-wp-admin`. Both set parent_file to
427     * `options-general.php` for menu highlighting, so we distinguish them by
428     * checking the actual script filename being served.
429     *
430     * Note: for the Gutenberg case we use the registered page slug directly,
431     * not `$screen->id`. WordPress auto-prefixes screen IDs for submenu pages
432     * (e.g. `settings_page_options-connectors-wp-admin`), so using `$screen->id`
433     * as the `page=` parameter produces an invalid URL.
434     *
435     * The result is suitable for the `redirect_uri` parameter accepted by the
436     * `jetpack/v4/connection/register` REST endpoint (which wraps it in `admin_url()`).
437     *
438     * @return string Admin-relative path, e.g. 'options-connectors.php'.
439     */
440    private static function get_connectors_page_path() {
441        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only compared against a hardcoded string.
442        $script = isset( $_SERVER['SCRIPT_NAME'] ) ? wp_basename( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) : '';
443
444        if ( 'options-connectors.php' === $script ) {
445            return 'options-connectors.php';
446        }
447
448        // Gutenberg plugin registers the page under options-general.php.
449        $screen = get_current_screen();
450        if ( $screen && static::GUTENBERG_CONNECTORS_SCREEN_ID === $screen->id ) {
451            return 'options-general.php?page=' . static::GUTENBERG_CONNECTORS_PAGE_SLUG;
452        }
453
454        return 'options-connectors.php';
455    }
456
457    /**
458     * Store an authorization error in a short-lived transient.
459     *
460     * Hooked to `jetpack_client_authorize_error` which fires when
461     * the auth webhook fails. The transient is read on the next
462     * Connectors page load so the JS card can display the error.
463     *
464     * @since 8.2.0
465     *
466     * @param \WP_Error $error Authorization error.
467     */
468    public static function store_auth_error( $error ) {
469        if ( is_wp_error( $error ) ) {
470            $user_id = get_current_user_id();
471            if ( $user_id ) {
472                set_transient(
473                    'jetpack_connector_auth_error_' . $user_id,
474                    $error->get_error_message(),
475                    60
476                );
477            }
478        }
479    }
480
481    /**
482     * Read and delete a stored authorization error for the current user.
483     *
484     * @return string|false Error message or false if none.
485     */
486    private static function consume_auth_error() {
487        $user_id = get_current_user_id();
488        if ( ! $user_id ) {
489            return false;
490        }
491
492        $key   = 'jetpack_connector_auth_error_' . $user_id;
493        $error = get_transient( $key );
494        if ( false !== $error ) {
495            delete_transient( $key );
496        }
497
498        return $error;
499    }
500
501    /**
502     * Get connected plugins data for the script module.
503     *
504     * @param Manager $manager Connection manager instance.
505     * @return array List of connected plugin data.
506     */
507    private static function get_connected_plugins_data( $manager ) {
508        $plugins = $manager->get_connected_plugins();
509
510        if ( is_wp_error( $plugins ) || ! is_array( $plugins ) ) {
511            return array();
512        }
513
514        $result = array();
515
516        foreach ( $plugins as $slug => $plugin_data ) {
517            $name = $plugin_data['name'] ?? $slug;
518
519            $entry = array(
520                'name' => $name,
521                'slug' => $slug,
522            );
523
524            $logo_url = static::get_plugin_logo_url( $slug );
525            if ( $logo_url ) {
526                $entry['logoUrl'] = $logo_url;
527            }
528
529            $result[] = $entry;
530        }
531
532        return $result;
533    }
534
535    /**
536     * Detect which plugin families are using the connection.
537     *
538     * @since 8.5.0
539     *
540     * @return array{has_woo: bool, has_a4a: bool}
541     */
542    public static function get_connected_plugin_families() {
543        $plugins = Plugin_Storage::get_all();
544
545        $has_woo = false;
546        $has_a4a = false;
547
548        if ( is_array( $plugins ) ) {
549            foreach ( array_keys( $plugins ) as $slug ) {
550                if ( str_starts_with( $slug, 'woocommerce' ) ) {
551                    $has_woo = true;
552                }
553                if ( str_starts_with( $slug, 'automattic' ) ) {
554                    $has_a4a = true;
555                }
556            }
557        }
558
559        return array(
560            'has_woo' => $has_woo,
561            'has_a4a' => $has_a4a,
562        );
563    }
564
565    /**
566     * Determine the connector card logo based on which plugin families are connected.
567     *
568     * Priority:
569     * 1. Both Woo-family and A4A plugins → jetpack-connect-all.svg
570     * 2. Woo-family only                 → jetpack-connect-woo.svg
571     * 3. A4A only                        → jetpack-connect-a8c.svg
572     * 4. Default (Jetpack only or other) → jetpack-connect.svg
573     *
574     * @since 8.3.2
575     *
576     * @return string Logo URL.
577     */
578    public static function get_connector_logo_url() {
579        $families = self::get_connected_plugin_families();
580
581        if ( $families['has_woo'] && $families['has_a4a'] ) {
582            return plugins_url( 'images/jetpack-connect-all.svg', __FILE__ );
583        }
584
585        if ( $families['has_woo'] ) {
586            return plugins_url( 'images/jetpack-connect-woo.svg', __FILE__ );
587        }
588
589        if ( $families['has_a4a'] ) {
590            return plugins_url( 'images/jetpack-connect-a8c.svg', __FILE__ );
591        }
592
593        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
594    }
595
596    /**
597     * Get the inline (single-row) connector logo for use in compact contexts like table cells.
598     *
599     * All circles are arranged horizontally in a single row, unlike the card
600     * logos which stack circles vertically for 3+ plugins.
601     *
602     * @since 8.5.0
603     *
604     * @return string Logo URL.
605     */
606    public static function get_inline_connector_logo_url() {
607        $families = self::get_connected_plugin_families();
608
609        if ( $families['has_woo'] && $families['has_a4a'] ) {
610            return plugins_url( 'images/jetpack-connect-all-inline.svg', __FILE__ );
611        }
612
613        if ( $families['has_woo'] ) {
614            return plugins_url( 'images/jetpack-connect-woo-inline.svg', __FILE__ );
615        }
616
617        if ( $families['has_a4a'] ) {
618            return plugins_url( 'images/jetpack-connect-a8c-inline.svg', __FILE__ );
619        }
620
621        return plugins_url( 'images/jetpack-connect.svg', __FILE__ );
622    }
623
624    /**
625     * Map a plugin slug to a brand logo URL.
626     *
627     * Jetpack-family plugins get the Jetpack mark, WooCommerce-family
628     * plugins get the Woo mark, and Automattic for Agencies gets the
629     * Automattic mark. Unknown slugs fall through to the
630     * `jetpack_connection_plugin_logo_url` filter so that third-party
631     * plugins can register their own logo. Only SVG URLs are accepted
632     * to keep the icons sharp at every display density.
633     *
634     * @since 8.3.2
635     *
636     * @param string $slug Plugin slug.
637     * @return string|null Logo URL or null.
638     */
639    private static function get_plugin_logo_url( $slug ) {
640        if ( str_starts_with( $slug, 'jetpack' ) ) {
641            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+.
642        }
643
644        if ( str_starts_with( $slug, 'woocommerce' ) ) {
645            return plugins_url( 'images/woo-icon.svg', __FILE__ );
646        }
647
648        if ( str_starts_with( $slug, 'automattic' ) ) {
649            return plugins_url( 'images/automattic-icon.svg', __FILE__ );
650        }
651
652        /**
653         * Filters a map of plugin slugs to custom logo URLs for the
654         * Settings → Connectors card.
655         *
656         * Add entries as `$slug => $url` pairs. URLs must point to an
657         * SVG file (`.svg` extension required); non-SVG values are
658         * silently ignored and the generic fallback icon is shown.
659         *
660         * Example:
661         *
662         *     add_filter( 'jetpack_connection_plugin_logos', function ( $logos ) {
663         *         $logos['my-plugin'] = plugins_url( 'assets/logo.svg', __FILE__ );
664         *         return $logos;
665         *     } );
666         *
667         * @since 8.3.2
668         *
669         * @param array<string,string> $logos Map of plugin slug to SVG URL.
670         */
671        $logos = apply_filters( 'jetpack_connection_plugin_logos', array() );
672
673        if ( isset( $logos[ $slug ] ) && is_string( $logos[ $slug ] ) && str_ends_with( strtolower( $logos[ $slug ] ), '.svg' ) ) {
674            return esc_url( $logos[ $slug ] );
675        }
676
677        return null;
678    }
679}