From 1a5ee2d9c7b4f0aedcea0f3d604b18a1616d6d2c Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 10 Mar 2026 15:44:28 +0100 Subject: [PATCH 1/6] Connectors: Add connectors registry for extensibility Ports the connectors registry from WordPress core (wordpress-develop#11175) to Gutenberg. Introduces WP_Connector_Registry class and wp_connectors_init action hook so plugins can register their own connectors alongside the built-in defaults (Anthropic, Google, OpenAI). Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 285 ++++++++++++++++ .../connectors/default-connectors.php | 321 +++++++++++++----- lib/experimental/connectors/load.php | 1 + 3 files changed, 515 insertions(+), 92 deletions(-) create mode 100644 lib/experimental/connectors/class-wp-connector-registry.php diff --git a/lib/experimental/connectors/class-wp-connector-registry.php b/lib/experimental/connectors/class-wp-connector-registry.php new file mode 100644 index 00000000000000..85872989077b04 --- /dev/null +++ b/lib/experimental/connectors/class-wp-connector-registry.php @@ -0,0 +1,285 @@ + + * @phpstan-var array + */ + private array $registered_connectors = array(); + + /** + * Registers a new connector. + * + * @since 7.0.0 + * + * @param string $id The unique connector identifier. Must contain only lowercase + * alphanumeric characters and underscores. + * @param array $args { + * An associative array of arguments for the connector. + * + * @type string $name Required. The connector's display name. + * @type string $description Optional. The connector's description. Default empty string. + * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type array $authentication { + * Required. Authentication configuration. + * + * @type string $method Required. The authentication method: 'api_key' or 'none'. + * @type string|null $credentials_url Optional. URL where users can obtain API credentials. + * } + * @type array $plugin { + * Optional. Plugin data for install/activate UI. + * + * @type string $slug The WordPress.org plugin slug. + * } + * } + * @return array|null The registered connector data on success, null on failure. + * + * @phpstan-param Connector $args + * @phpstan-return Connector|null + */ + public function register( string $id, array $args ): ?array { + if ( ! preg_match( '/^[a-z0-9_]+$/', $id ) ) { + _doing_it_wrong( + __METHOD__, + __( + 'Connector ID must contain only lowercase alphanumeric characters and underscores.', + 'gutenberg' + ), + '7.0.0' + ); + return null; + } + + if ( $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" is already registered.', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + // Validate required fields. + if ( empty( $args['name'] ) || ! is_string( $args['name'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "name" string.', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['type'] ) || ! is_string( $args['type'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires a non-empty "type" string.', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( ! isset( $args['authentication'] ) || ! is_array( $args['authentication'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" requires an "authentication" array.', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + if ( empty( $args['authentication']['method'] ) || ! in_array( $args['authentication']['method'], array( 'api_key', 'none' ), true ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $connector = array( + 'name' => $args['name'], + 'description' => isset( $args['description'] ) && is_string( $args['description'] ) ? $args['description'] : '', + 'type' => $args['type'], + 'authentication' => array( + 'method' => $args['authentication']['method'], + ), + ); + + if ( 'api_key' === $args['authentication']['method'] ) { + $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'] ?? null; + $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; + } + + if ( ! empty( $args['plugin'] ) && is_array( $args['plugin'] ) ) { + $connector['plugin'] = $args['plugin']; + } + + $this->registered_connectors[ $id ] = $connector; + return $connector; + } + + /** + * Unregisters a connector. + * + * @since 7.0.0 + * + * @param string $id The connector identifier. + * @return array|null The unregistered connector data on success, null on failure. + * + * @phpstan-return Connector|null + */ + public function unregister( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + + $unregistered = $this->registered_connectors[ $id ]; + unset( $this->registered_connectors[ $id ] ); + + return $unregistered; + } + + /** + * Retrieves the list of all registered connectors. + * + * Do not use this method directly. Instead, use the `gutenberg_get_connectors()` function. + * + * @since 7.0.0 + * + * @see gutenberg_get_connectors() + * + * @return array The array of registered connectors keyed by connector ID. + * @phpstan-return array + */ + public function get_all_registered(): array { + return $this->registered_connectors; + } + + /** + * Checks if a connector is registered. + * + * Do not use this method directly. Instead, use the `gutenberg_is_connector_registered()` function. + * + * @since 7.0.0 + * + * @see gutenberg_is_connector_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + public function is_registered( string $id ): bool { + return isset( $this->registered_connectors[ $id ] ); + } + + /** + * Retrieves a registered connector. + * + * Do not use this method directly. Instead, use the `gutenberg_get_connector()` function. + * + * @since 7.0.0 + * + * @see gutenberg_get_connector() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if it is not registered. + * @phpstan-return Connector|null + */ + public function get_registered( string $id ): ?array { + if ( ! $this->is_registered( $id ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" not found.', 'gutenberg' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + return $this->registered_connectors[ $id ]; + } + + /** + * Retrieves the main instance of the registry class. + * + * @since 7.0.0 + * + * @return WP_Connector_Registry|null The main registry instance, or null if not yet initialized. + */ + public static function get_instance(): ?self { + return self::$instance; + } + + /** + * Sets the main instance of the registry class. + * + * @since 7.0.0 + * @access private + * + * @param WP_Connector_Registry $registry The registry instance. + */ + public static function set_instance( WP_Connector_Registry $registry ): void { + if ( ! doing_action( 'init' ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The connector registry instance must be set during the init action.', 'gutenberg' ), + '7.0.0' + ); + return; + } + + self::$instance = $registry; + } + } +} diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php index 7855ff9f025e42..bf73771b40b3c1 100644 --- a/lib/experimental/connectors/default-connectors.php +++ b/lib/experimental/connectors/default-connectors.php @@ -5,6 +5,203 @@ * @package gutenberg */ +/** + * Checks if a connector is registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ +function gutenberg_is_connector_registered( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; + } + + return $registry->is_registered( $id ); +} + +/** + * Retrieves a registered connector. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_registered() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ +function gutenberg_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); +} + +/** + * Retrieves all registered connectors. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. + */ +function gutenberg_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); +} + +/** + * Initializes the connector registry with default connectors and fires the registration action. + * + * Creates the registry instance, registers built-in connectors (which cannot be unhooked), + * and then fires the `wp_connectors_init` action for plugins to register their own connectors. + * + * @access private + * @since 7.0.0 + */ +function _gutenberg_connectors_init(): void { + if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { + return; + } + + $registry = new WP_Connector_Registry(); + WP_Connector_Registry::set_instance( $registry ); + + // Built-in connectors. + $defaults = array( + 'anthropic' => array( + 'name' => 'Anthropic', + 'description' => __( 'Text generation with Claude.', 'gutenberg' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-anthropic', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.claude.com/settings/keys', + ), + ), + 'google' => array( + 'name' => 'Google', + 'description' => __( 'Text and image generation with Gemini and Imagen.', 'gutenberg' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-google', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://aistudio.google.com/api-keys', + ), + ), + 'openai' => array( + 'name' => 'OpenAI', + 'description' => __( 'Text and image generation with GPT and Dall-E.', 'gutenberg' ), + 'type' => 'ai_provider', + 'plugin' => array( + 'slug' => 'ai-provider-for-openai', + ), + 'authentication' => array( + 'method' => 'api_key', + 'credentials_url' => 'https://platform.openai.com/api-keys', + ), + ), + ); + + // Merge AI Client registry data on top of defaults. + // Registry values (from provider plugins) take precedence over hardcoded fallbacks. + $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry(); + + foreach ( $ai_registry->getRegisteredProviderIds() as $connector_id ) { + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); + $provider_metadata = $provider_class_name::metadata(); + + $auth_method = $provider_metadata->getAuthenticationMethod(); + $is_api_key = null !== $auth_method && $auth_method->isApiKey(); + + if ( $is_api_key ) { + $credentials_url = $provider_metadata->getCredentialsUrl(); + $authentication = array( + 'method' => 'api_key', + 'credentials_url' => $credentials_url ? $credentials_url : null, + ); + } else { + $authentication = array( 'method' => 'none' ); + } + + $name = $provider_metadata->getName(); + $description = $provider_metadata->getDescription(); + + if ( isset( $defaults[ $connector_id ] ) ) { + // Override fields with non-empty registry values. + if ( $name ) { + $defaults[ $connector_id ]['name'] = $name; + } + if ( $description ) { + $defaults[ $connector_id ]['description'] = $description; + } + // Always update auth method; keep existing credentials_url as fallback. + $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; + if ( ! empty( $authentication['credentials_url'] ) ) { + $defaults[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; + } + } else { + $defaults[ $connector_id ] = array( + 'name' => $name ? $name : ucwords( $connector_id ), + 'description' => $description ? $description : '', + 'type' => 'ai_provider', + 'authentication' => $authentication, + ); + } + } + + // Register all default connectors directly on the registry. + foreach ( $defaults as $id => $args ) { + $registry->register( $id, $args ); + } + + /** + * Fires when the connector registry is ready for plugins to register connectors. + * + * Default connectors have already been registered at this point and cannot be + * unhooked. Use `$registry->register()` within this action to add new connectors. + * + * Example usage: + * + * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { + * $registry->register( + * 'my_custom_ai', + * array( + * 'name' => __( 'My Custom AI', 'my-plugin' ), + * 'description' => __( 'Custom AI provider integration.', 'my-plugin' ), + * 'type' => 'ai_provider', + * 'authentication' => array( + * 'method' => 'api_key', + * 'credentials_url' => 'https://example.com/api-keys', + * ), + * ) + * ); + * } ); + * + * @since 7.0.0 + * + * @param WP_Connector_Registry $registry Connector registry instance. + */ + do_action( 'wp_connectors_init', $registry ); +} +remove_action( 'init', '_wp_connectors_init' ); +add_action( 'init', '_gutenberg_connectors_init' ); + /** * Determines the source of an API key for a given provider. * @@ -151,6 +348,8 @@ function _gutenberg_resolve_ai_provider_logo_url( string $path ): ?string { /** * Gets the registered connector settings. * + * Enriches registry data with logo URLs and plugin installation status. + * * @access private * * @return array { @@ -183,101 +382,28 @@ function _gutenberg_get_connector_settings(): array { return $cached; } - $connectors = array( - 'google' => array( - 'name' => 'Google', - 'description' => __( 'Text and image generation with Gemini and Imagen.', 'gutenberg' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-google', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://aistudio.google.com/api-keys', - ), - ), - 'openai' => array( - 'name' => 'OpenAI', - 'description' => __( 'Text and image generation with GPT and Dall-E.', 'gutenberg' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-openai', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.openai.com/api-keys', - ), - ), - 'anthropic' => array( - 'name' => 'Anthropic', - 'description' => __( 'Text generation with Claude.', 'gutenberg' ), - 'type' => 'ai_provider', - 'plugin' => array( - 'slug' => 'ai-provider-for-anthropic', - ), - 'authentication' => array( - 'method' => 'api_key', - 'credentials_url' => 'https://platform.claude.com/settings/keys', - ), - ), - ); - - $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $connectors = gutenberg_get_connectors(); + ksort( $connectors ); - foreach ( $registry->getRegisteredProviderIds() as $connector_id ) { - $provider_class = $registry->getProviderClassName( $connector_id ); - $metadata = $provider_class::metadata(); + // Enrich with logo URLs from AI Client registry. + if ( class_exists( '\WordPress\AiClient\AiClient' ) ) { + $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry(); - $auth_method = $metadata->getAuthenticationMethod(); - $is_api_key = null !== $auth_method && $auth_method->isApiKey(); + foreach ( $connectors as $connector_id => $connector ) { + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } - if ( $is_api_key ) { - $credentials_url = $metadata->getCredentialsUrl(); - $authentication = array( - 'method' => 'api_key', - 'credentials_url' => $credentials_url ? $credentials_url : null, - ); - } else { - $authentication = array( 'method' => 'none' ); - } + $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); + $provider_metadata = $provider_class_name::metadata(); - $name = $metadata->getName(); - $description = method_exists( $metadata, 'getDescription' ) ? $metadata->getDescription() : null; - $logo_url = method_exists( $metadata, 'getLogoPath' ) && $metadata->getLogoPath() - ? _gutenberg_resolve_ai_provider_logo_url( $metadata->getLogoPath() ) - : null; + $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath() + ? _gutenberg_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) + : null; - if ( isset( $connectors[ $connector_id ] ) ) { - // Override fields with non-empty registry values. - if ( $name ) { - $connectors[ $connector_id ]['name'] = $name; - } - if ( $description ) { - $connectors[ $connector_id ]['description'] = $description; - } if ( $logo_url ) { $connectors[ $connector_id ]['logo_url'] = $logo_url; } - // Always update auth method; keep existing credentials_url as fallback. - $connectors[ $connector_id ]['authentication']['method'] = $authentication['method']; - if ( ! empty( $authentication['credentials_url'] ) ) { - $connectors[ $connector_id ]['authentication']['credentials_url'] = $authentication['credentials_url']; - } - } else { - $connectors[ $connector_id ] = array( - 'name' => $name ? $name : ucwords( $connector_id ), - 'description' => $description ? $description : '', - 'logo_url' => $logo_url, - 'type' => 'ai_provider', - 'authentication' => $authentication, - ); - } - } - - // Add setting_name for connectors that use API key authentication. - foreach ( $connectors as $connector_id => $connector ) { - if ( 'api_key' === $connector['authentication']['method'] ) { - $connectors[ $connector_id ]['authentication']['setting_name'] = "connectors_ai_{$connector_id}_api_key"; } } @@ -388,9 +514,16 @@ function _gutenberg_register_default_connector_settings(): void { return; } - foreach ( _gutenberg_get_connector_settings() as $connector_data ) { + $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry(); + + foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + continue; + } + + // Skip registering the setting if the provider is not in the registry. + if ( ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -430,7 +563,7 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void { } try { - $registry = \WordPress\AiClient\AiClient::defaultRegistry(); + $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry(); foreach ( _gutenberg_get_connector_settings() as $connector_id => $connector_data ) { if ( 'ai_provider' !== $connector_data['type'] ) { continue; @@ -441,6 +574,10 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void { continue; } + if ( ! $ai_registry->hasProvider( $connector_id ) ) { + continue; + } + // Skip if the key is already provided via env var or constant. $key_source = _gutenberg_get_api_key_source( $connector_id ); if ( 'env' === $key_source || 'constant' === $key_source ) { @@ -448,11 +585,11 @@ function _gutenberg_pass_default_connector_keys_to_ai_client(): void { } $api_key = get_option( $auth['setting_name'], '' ); - if ( '' === $api_key || ! $registry->hasProvider( $connector_id ) ) { + if ( '' === $api_key ) { continue; } - $registry->setProviderRequestAuthentication( + $ai_registry->setProviderRequestAuthentication( $connector_id, new \WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication( $api_key ) ); diff --git a/lib/experimental/connectors/load.php b/lib/experimental/connectors/load.php index 7e60135edb41c3..0a9b16ef79bf53 100644 --- a/lib/experimental/connectors/load.php +++ b/lib/experimental/connectors/load.php @@ -34,4 +34,5 @@ function _gutenberg_connectors_add_settings_menu_item(): void { ); } +require __DIR__ . '/class-wp-connector-registry.php'; require __DIR__ . '/default-connectors.php'; From ef11e2b9c2eaef5fb90209988e5f65836453673b Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 09:23:20 +0100 Subject: [PATCH 2/6] Connectors: Polyfill wp_is_connector_registered, wp_get_connector, wp_get_connectors Uses function_exists guards so plugins can use the wp_* names now and seamlessly switch to core's implementation once WordPress 7.0 ships. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 12 +-- .../connectors/default-connectors.php | 102 +++++++++--------- 2 files changed, 60 insertions(+), 54 deletions(-) diff --git a/lib/experimental/connectors/class-wp-connector-registry.php b/lib/experimental/connectors/class-wp-connector-registry.php index 85872989077b04..43f0edd6d3d948 100644 --- a/lib/experimental/connectors/class-wp-connector-registry.php +++ b/lib/experimental/connectors/class-wp-connector-registry.php @@ -195,11 +195,11 @@ public function unregister( string $id ): ?array { /** * Retrieves the list of all registered connectors. * - * Do not use this method directly. Instead, use the `gutenberg_get_connectors()` function. + * Do not use this method directly. Instead, use the `wp_get_connectors()` function. * * @since 7.0.0 * - * @see gutenberg_get_connectors() + * @see wp_get_connectors() * * @return array The array of registered connectors keyed by connector ID. * @phpstan-return array @@ -211,11 +211,11 @@ public function get_all_registered(): array { /** * Checks if a connector is registered. * - * Do not use this method directly. Instead, use the `gutenberg_is_connector_registered()` function. + * Do not use this method directly. Instead, use the `wp_is_connector_registered()` function. * * @since 7.0.0 * - * @see gutenberg_is_connector_registered() + * @see wp_is_connector_registered() * * @param string $id The connector identifier. * @return bool True if the connector is registered, false otherwise. @@ -227,11 +227,11 @@ public function is_registered( string $id ): bool { /** * Retrieves a registered connector. * - * Do not use this method directly. Instead, use the `gutenberg_get_connector()` function. + * Do not use this method directly. Instead, use the `wp_get_connector()` function. * * @since 7.0.0 * - * @see gutenberg_get_connector() + * @see wp_get_connector() * * @param string $id The connector identifier. * @return array|null The registered connector data, or null if it is not registered. diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php index bf73771b40b3c1..0e57f2b459e99c 100644 --- a/lib/experimental/connectors/default-connectors.php +++ b/lib/experimental/connectors/default-connectors.php @@ -5,60 +5,66 @@ * @package gutenberg */ -/** - * Checks if a connector is registered. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::is_registered() - * - * @param string $id The connector identifier. - * @return bool True if the connector is registered, false otherwise. - */ -function gutenberg_is_connector_registered( string $id ): bool { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return false; - } +if ( ! function_exists( 'wp_is_connector_registered' ) ) { + /** + * Checks if a connector is registered. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::is_registered() + * + * @param string $id The connector identifier. + * @return bool True if the connector is registered, false otherwise. + */ + function wp_is_connector_registered( string $id ): bool { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return false; + } - return $registry->is_registered( $id ); + return $registry->is_registered( $id ); + } } -/** - * Retrieves a registered connector. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::get_registered() - * - * @param string $id The connector identifier. - * @return array|null The registered connector data, or null if not registered. - */ -function gutenberg_get_connector( string $id ): ?array { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return null; - } +if ( ! function_exists( 'wp_get_connector' ) ) { + /** + * Retrieves a registered connector. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_registered() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ + function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } - return $registry->get_registered( $id ); + return $registry->get_registered( $id ); + } } -/** - * Retrieves all registered connectors. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::get_all_registered() - * - * @return array[] An array of registered connectors keyed by connector ID. - */ -function gutenberg_get_connectors(): array { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return array(); - } +if ( ! function_exists( 'wp_get_connectors' ) ) { + /** + * Retrieves all registered connectors. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. + */ + function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } - return $registry->get_all_registered(); + return $registry->get_all_registered(); + } } /** @@ -382,7 +388,7 @@ function _gutenberg_get_connector_settings(): array { return $cached; } - $connectors = gutenberg_get_connectors(); + $connectors = wp_get_connectors(); ksort( $connectors ); // Enrich with logo URLs from AI Client registry. From 462a19877289e6a48c2acba60999a13294081c8f Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 09:39:12 +0100 Subject: [PATCH 3/6] Connectors: Move core polyfills to lib/compat/wordpress-7.0 Move WP_Connector_Registry class and wp_* helper functions to lib/compat/wordpress-7.0/ where other core polyfills live. This aligns with the existing PHPCS exclude pattern for the default text domain and keeps core-ported code in the standard location. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 19 +++-- lib/compat/wordpress-7.0/connectors.php | 69 +++++++++++++++++++ .../connectors/default-connectors.php | 62 ----------------- lib/experimental/connectors/load.php | 1 - lib/load.php | 2 + 5 files changed, 80 insertions(+), 73 deletions(-) rename lib/{experimental/connectors => compat/wordpress-7.0}/class-wp-connector-registry.php (93%) create mode 100644 lib/compat/wordpress-7.0/connectors.php diff --git a/lib/experimental/connectors/class-wp-connector-registry.php b/lib/compat/wordpress-7.0/class-wp-connector-registry.php similarity index 93% rename from lib/experimental/connectors/class-wp-connector-registry.php rename to lib/compat/wordpress-7.0/class-wp-connector-registry.php index 43f0edd6d3d948..fd13fa3c78c143 100644 --- a/lib/experimental/connectors/class-wp-connector-registry.php +++ b/lib/compat/wordpress-7.0/class-wp-connector-registry.php @@ -84,8 +84,7 @@ public function register( string $id, array $args ): ?array { _doing_it_wrong( __METHOD__, __( - 'Connector ID must contain only lowercase alphanumeric characters and underscores.', - 'gutenberg' + 'Connector ID must contain only lowercase alphanumeric characters and underscores.' ), '7.0.0' ); @@ -96,7 +95,7 @@ public function register( string $id, array $args ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" is already registered.', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" is already registered.' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -107,7 +106,7 @@ public function register( string $id, array $args ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" requires a non-empty "name" string.', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" requires a non-empty "name" string.' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -117,7 +116,7 @@ public function register( string $id, array $args ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" requires a non-empty "type" string.', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" requires a non-empty "type" string.' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -127,7 +126,7 @@ public function register( string $id, array $args ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" requires an "authentication" array.', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" requires an "authentication" array.' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -137,7 +136,7 @@ public function register( string $id, array $args ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" authentication method must be "api_key" or "none".' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -180,7 +179,7 @@ public function unregister( string $id ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" not found.', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -242,7 +241,7 @@ public function get_registered( string $id ): ?array { _doing_it_wrong( __METHOD__, /* translators: %s: Connector ID. */ - sprintf( __( 'Connector "%s" not found.', 'gutenberg' ), esc_html( $id ) ), + sprintf( __( 'Connector "%s" not found.' ), esc_html( $id ) ), '7.0.0' ); return null; @@ -273,7 +272,7 @@ public static function set_instance( WP_Connector_Registry $registry ): void { if ( ! doing_action( 'init' ) ) { _doing_it_wrong( __METHOD__, - __( 'The connector registry instance must be set during the init action.', 'gutenberg' ), + __( 'The connector registry instance must be set during the init action.' ), '7.0.0' ); return; diff --git a/lib/compat/wordpress-7.0/connectors.php b/lib/compat/wordpress-7.0/connectors.php new file mode 100644 index 00000000000000..8ffd9ee2bfdca1 --- /dev/null +++ b/lib/compat/wordpress-7.0/connectors.php @@ -0,0 +1,69 @@ +is_registered( $id ); + } +} + +if ( ! function_exists( 'wp_get_connector' ) ) { + /** + * Retrieves a registered connector. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_registered() + * + * @param string $id The connector identifier. + * @return array|null The registered connector data, or null if not registered. + */ + function wp_get_connector( string $id ): ?array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return null; + } + + return $registry->get_registered( $id ); + } +} + +if ( ! function_exists( 'wp_get_connectors' ) ) { + /** + * Retrieves all registered connectors. + * + * @since 7.0.0 + * + * @see WP_Connector_Registry::get_all_registered() + * + * @return array[] An array of registered connectors keyed by connector ID. + */ + function wp_get_connectors(): array { + $registry = WP_Connector_Registry::get_instance(); + if ( null === $registry ) { + return array(); + } + + return $registry->get_all_registered(); + } +} diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php index 0e57f2b459e99c..e785c56a0e1530 100644 --- a/lib/experimental/connectors/default-connectors.php +++ b/lib/experimental/connectors/default-connectors.php @@ -5,68 +5,6 @@ * @package gutenberg */ -if ( ! function_exists( 'wp_is_connector_registered' ) ) { - /** - * Checks if a connector is registered. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::is_registered() - * - * @param string $id The connector identifier. - * @return bool True if the connector is registered, false otherwise. - */ - function wp_is_connector_registered( string $id ): bool { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return false; - } - - return $registry->is_registered( $id ); - } -} - -if ( ! function_exists( 'wp_get_connector' ) ) { - /** - * Retrieves a registered connector. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::get_registered() - * - * @param string $id The connector identifier. - * @return array|null The registered connector data, or null if not registered. - */ - function wp_get_connector( string $id ): ?array { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return null; - } - - return $registry->get_registered( $id ); - } -} - -if ( ! function_exists( 'wp_get_connectors' ) ) { - /** - * Retrieves all registered connectors. - * - * @since 7.0.0 - * - * @see WP_Connector_Registry::get_all_registered() - * - * @return array[] An array of registered connectors keyed by connector ID. - */ - function wp_get_connectors(): array { - $registry = WP_Connector_Registry::get_instance(); - if ( null === $registry ) { - return array(); - } - - return $registry->get_all_registered(); - } -} - /** * Initializes the connector registry with default connectors and fires the registration action. * diff --git a/lib/experimental/connectors/load.php b/lib/experimental/connectors/load.php index 0a9b16ef79bf53..7e60135edb41c3 100644 --- a/lib/experimental/connectors/load.php +++ b/lib/experimental/connectors/load.php @@ -34,5 +34,4 @@ function _gutenberg_connectors_add_settings_menu_item(): void { ); } -require __DIR__ . '/class-wp-connector-registry.php'; require __DIR__ . '/default-connectors.php'; diff --git a/lib/load.php b/lib/load.php index dc6d9b4a99ab06..55cecf7e0b4c02 100644 --- a/lib/load.php +++ b/lib/load.php @@ -75,6 +75,8 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-7.0/template-activate.php'; require __DIR__ . '/compat/wordpress-7.0/rest-api.php'; require __DIR__ . '/compat/wordpress-7.0/global-styles.php'; + require __DIR__ . '/compat/wordpress-7.0/class-wp-connector-registry.php'; + require __DIR__ . '/compat/wordpress-7.0/connectors.php'; // Plugin specific code. require_once __DIR__ . '/class-wp-rest-global-styles-controller-gutenberg.php'; From 8143d8a0fa97b1b7146b0ccedca4515f3529ad74 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 09:43:28 +0100 Subject: [PATCH 4/6] Connectors: Add backport changelog entry for wordpress-develop#11175 Co-Authored-By: Claude Opus 4.6 --- backport-changelog/7.0/11175.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 backport-changelog/7.0/11175.md diff --git a/backport-changelog/7.0/11175.md b/backport-changelog/7.0/11175.md new file mode 100644 index 00000000000000..6f3951eb3c4265 --- /dev/null +++ b/backport-changelog/7.0/11175.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/11175 + +* https://github.com/WordPress/gutenberg/pull/76364 From 7a016aa796e1caf4efcfd4eee1b2f994d5fec950 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 10:01:01 +0100 Subject: [PATCH 5/6] Connectors: Add logo_url support to registry and resolve during init Extend WP_Connector_Registry to persist logo_url, removing the need for a separate enrichment step. Resolve logo URLs from AI Client metadata alongside name and description in _gutenberg_connectors_init. Co-Authored-By: Claude Opus 4.6 --- .../class-wp-connector-registry.php | 6 ++++ .../connectors/default-connectors.php | 29 +++++-------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/lib/compat/wordpress-7.0/class-wp-connector-registry.php b/lib/compat/wordpress-7.0/class-wp-connector-registry.php index fd13fa3c78c143..3bbef9b1a9bf1c 100644 --- a/lib/compat/wordpress-7.0/class-wp-connector-registry.php +++ b/lib/compat/wordpress-7.0/class-wp-connector-registry.php @@ -18,6 +18,7 @@ * @phpstan-type Connector array{ * name: string, * description: string, + * logo_url?: string|null, * type: string, * authentication: array{ * method: string, @@ -61,6 +62,7 @@ final class WP_Connector_Registry { * * @type string $name Required. The connector's display name. * @type string $description Optional. The connector's description. Default empty string. + * @type string|null $logo_url Optional. URL to the connector's logo image. Default null. * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. * @type array $authentication { * Required. Authentication configuration. @@ -151,6 +153,10 @@ public function register( string $id, array $args ): ?array { ), ); + if ( ! empty( $args['logo_url'] ) && is_string( $args['logo_url'] ) ) { + $connector['logo_url'] = $args['logo_url']; + } + if ( 'api_key' === $args['authentication']['method'] ) { $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url'] ?? null; $connector['authentication']['setting_name'] = "connectors_ai_{$id}_api_key"; diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php index e785c56a0e1530..a9d80b46c512d6 100644 --- a/lib/experimental/connectors/default-connectors.php +++ b/lib/experimental/connectors/default-connectors.php @@ -85,6 +85,9 @@ function _gutenberg_connectors_init(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); + $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath() + ? _gutenberg_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) + : null; if ( isset( $defaults[ $connector_id ] ) ) { // Override fields with non-empty registry values. @@ -94,6 +97,9 @@ function _gutenberg_connectors_init(): void { if ( $description ) { $defaults[ $connector_id ]['description'] = $description; } + if ( $logo_url ) { + $defaults[ $connector_id ]['logo_url'] = $logo_url; + } // Always update auth method; keep existing credentials_url as fallback. $defaults[ $connector_id ]['authentication']['method'] = $authentication['method']; if ( ! empty( $authentication['credentials_url'] ) ) { @@ -105,6 +111,7 @@ function _gutenberg_connectors_init(): void { 'description' => $description ? $description : '', 'type' => 'ai_provider', 'authentication' => $authentication, + 'logo_url' => $logo_url, ); } } @@ -329,28 +336,6 @@ function _gutenberg_get_connector_settings(): array { $connectors = wp_get_connectors(); ksort( $connectors ); - // Enrich with logo URLs from AI Client registry. - if ( class_exists( '\WordPress\AiClient\AiClient' ) ) { - $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry(); - - foreach ( $connectors as $connector_id => $connector ) { - if ( ! $ai_registry->hasProvider( $connector_id ) ) { - continue; - } - - $provider_class_name = $ai_registry->getProviderClassName( $connector_id ); - $provider_metadata = $provider_class_name::metadata(); - - $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath() - ? _gutenberg_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) - : null; - - if ( $logo_url ) { - $connectors[ $connector_id ]['logo_url'] = $logo_url; - } - } - } - // Add plugin installation and activation status. // Build a slug-to-file map following the same pattern as WP_Plugin_Dependencies::get_plugin_dirnames(). if ( ! function_exists( 'get_plugins' ) ) { From d54af13225b57a72dfb732da2da714e8340d45a5 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 11 Mar 2026 10:38:36 +0100 Subject: [PATCH 6/6] Connectors: Polyfill _wp_connectors_resolve_ai_provider_logo_url from core Move the logo URL resolver to lib/compat/wordpress-7.0/connectors.php as a polyfill for the _wp_connectors_resolve_ai_provider_logo_url function being added to WP core. Remove the Gutenberg-prefixed version and its unit tests, which will be covered by core. Co-Authored-By: Claude Opus 4.6 --- lib/compat/wordpress-7.0/connectors.php | 47 ++++++++++++ .../connectors/default-connectors.php | 48 +------------ .../resolve-ai-provider-logo-url-test.php | 72 ------------------- 3 files changed, 48 insertions(+), 119 deletions(-) delete mode 100644 phpunit/experimental/connectors/resolve-ai-provider-logo-url-test.php diff --git a/lib/compat/wordpress-7.0/connectors.php b/lib/compat/wordpress-7.0/connectors.php index 8ffd9ee2bfdca1..7f1a52681180c3 100644 --- a/lib/compat/wordpress-7.0/connectors.php +++ b/lib/compat/wordpress-7.0/connectors.php @@ -67,3 +67,50 @@ function wp_get_connectors(): array { return $registry->get_all_registered(); } } + +if ( ! function_exists( '_wp_connectors_resolve_ai_provider_logo_url' ) ) { + /** + * Resolves an AI provider logo file path to a URL. + * + * Converts an absolute file path within the plugins or must-use plugins + * directory to the corresponding URL. + * + * @access private + * @since 7.0.0 + * + * @param string $path Absolute file path to the logo. Must be within + * WP_PLUGIN_DIR or WPMU_PLUGIN_DIR; triggers + * _doing_it_wrong() otherwise. + * @return string|null The logo URL, or null if the path is empty or + * outside the supported directories. + */ + function _wp_connectors_resolve_ai_provider_logo_url( string $path ): ?string { + if ( ! $path ) { + return null; + } + + $path = wp_normalize_path( $path ); + + if ( ! file_exists( $path ) ) { + return null; + } + + $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); + if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); + } + + $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); + if ( str_starts_with( $path, $plugin_dir . '/' ) ) { + return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); + } + + _doing_it_wrong( + __FUNCTION__, + __( 'Provider logo path must be located within the plugins or must-use plugins directory.' ), + '7.0.0' + ); + + return null; + } +} diff --git a/lib/experimental/connectors/default-connectors.php b/lib/experimental/connectors/default-connectors.php index a9d80b46c512d6..df9433f5fb0ee6 100644 --- a/lib/experimental/connectors/default-connectors.php +++ b/lib/experimental/connectors/default-connectors.php @@ -86,7 +86,7 @@ function _gutenberg_connectors_init(): void { $name = $provider_metadata->getName(); $description = $provider_metadata->getDescription(); $logo_url = method_exists( $provider_metadata, 'getLogoPath' ) && $provider_metadata->getLogoPath() - ? _gutenberg_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) + ? _wp_connectors_resolve_ai_provider_logo_url( $provider_metadata->getLogoPath() ) : null; if ( isset( $defaults[ $connector_id ] ) ) { @@ -250,52 +250,6 @@ function _gutenberg_is_ai_api_key_valid( string $key, string $provider_id ): ?bo } } -/** - * Resolves an AI provider logo file path to a URL. - * - * The AI Client library returns absolute file paths (not URLs) for logo files - * since it is not WordPress-specific. This function converts a path within - * the plugins or must-use plugins directory to the corresponding URL. - * - * @access private - * @since 7.0.0 - * - * @param string $path Absolute file path to the logo. Must be within - * WP_PLUGIN_DIR or WPMU_PLUGIN_DIR; triggers - * _doing_it_wrong() otherwise. - * @return string|null The logo URL, or null if the path is empty or - * outside the supported directories. - */ -function _gutenberg_resolve_ai_provider_logo_url( string $path ): ?string { - if ( ! $path ) { - return null; - } - - $path = wp_normalize_path( $path ); - - if ( ! file_exists( $path ) ) { - return null; - } - - $mu_plugin_dir = wp_normalize_path( WPMU_PLUGIN_DIR ); - if ( str_starts_with( $path, $mu_plugin_dir . '/' ) ) { - return plugins_url( substr( $path, strlen( $mu_plugin_dir ) ), WPMU_PLUGIN_DIR . '/.' ); - } - - $plugin_dir = wp_normalize_path( WP_PLUGIN_DIR ); - if ( str_starts_with( $path, $plugin_dir . '/' ) ) { - return plugins_url( substr( $path, strlen( $plugin_dir ) ) ); - } - - _doing_it_wrong( - __FUNCTION__, - __( 'Provider logo path must be located within the plugins or must-use plugins directory.', 'gutenberg' ), - '7.0.0' - ); - - return null; -} - /** * Gets the registered connector settings. * diff --git a/phpunit/experimental/connectors/resolve-ai-provider-logo-url-test.php b/phpunit/experimental/connectors/resolve-ai-provider-logo-url-test.php deleted file mode 100644 index 32daa6351cdab5..00000000000000 --- a/phpunit/experimental/connectors/resolve-ai-provider-logo-url-test.php +++ /dev/null @@ -1,72 +0,0 @@ -markTestSkipped( 'The AI Client library is not available.' ); - } - } - - public function test_returns_null_when_path_is_empty() { - $this->assertNull( _gutenberg_resolve_ai_provider_logo_url( '' ) ); - } - - public function test_resolves_plugin_dir_path_to_url() { - $logo_path = WP_PLUGIN_DIR . '/my-plugin/logo.svg'; - - // Create the file so file_exists() passes. - wp_mkdir_p( dirname( $logo_path ) ); - file_put_contents( $logo_path, '' ); - - try { - $result = _gutenberg_resolve_ai_provider_logo_url( $logo_path ); - $this->assertSame( site_url( '/wp-content/plugins/my-plugin/logo.svg' ), $result ); - } finally { - unlink( $logo_path ); - rmdir( dirname( $logo_path ) ); - } - } - - public function test_resolves_mu_plugin_dir_path_to_url() { - $logo_path = WPMU_PLUGIN_DIR . '/my-mu-plugin/logo.svg'; - - // Create the file so file_exists() passes. - wp_mkdir_p( dirname( $logo_path ) ); - file_put_contents( $logo_path, '' ); - - try { - $result = _gutenberg_resolve_ai_provider_logo_url( $logo_path ); - $this->assertSame( site_url( '/wp-content/mu-plugins/my-mu-plugin/logo.svg' ), $result ); - } finally { - unlink( $logo_path ); - rmdir( dirname( $logo_path ) ); - } - } - - public function test_returns_null_when_file_does_not_exist() { - $result = _gutenberg_resolve_ai_provider_logo_url( WP_PLUGIN_DIR . '/nonexistent/logo.svg' ); - $this->assertNull( $result ); - } - - /** - * @expectedIncorrectUsage _gutenberg_resolve_ai_provider_logo_url - */ - public function test_returns_null_and_triggers_doing_it_wrong_for_path_outside_plugin_dirs() { - $tmp_file = tempnam( sys_get_temp_dir(), 'logo_' ); - file_put_contents( $tmp_file, '' ); - - try { - $result = _gutenberg_resolve_ai_provider_logo_url( $tmp_file ); - $this->assertNull( $result ); - } finally { - unlink( $tmp_file ); - } - } -}