diff --git a/lib/webfonts-api/class-wp-webfonts-controller.php b/lib/webfonts-api/class-wp-webfonts-controller.php new file mode 100644 index 00000000000000..c14980c4fcfedf --- /dev/null +++ b/lib/webfonts-api/class-wp-webfonts-controller.php @@ -0,0 +1,276 @@ +` + * (e.g. `'wp_enqueue_scripts'`), or print the resource `` + * (`'wp_resource_hints'` ). Then it interacts with the components + * in this API to process the event. + * + * @since 5.9.0 + */ +class WP_Webfonts_Controller { + + /** + * Instance of the webfont's registry. + * + * @since 5.9.0 + * + * @var WP_Webfonts_Registry + */ + private $webfonts_registry; + + /** + * Instance of the providers' registry. + * + * @since 5.9.0 + * + * @var WP_Webfonts_Provider_Registry + */ + private $providers_registry; + + /** + * Stylesheet handle. + * + * @since 5.9.0 + * + * @var string + */ + private $stylesheet_handle = ''; + + /** + * Create the controller. + * + * @since 5.9.0 + * + * @param WP_Webfonts_Registry $webfonts_registry Instance of the webfonts' registry. + * @param WP_Webfonts_Provider_Registry $providers_registry Instance of the providers' registry. + */ + public function __construct( + WP_Webfonts_Registry $webfonts_registry, + WP_Webfonts_Provider_Registry $providers_registry + ) { + $this->webfonts_registry = $webfonts_registry; + $this->providers_registry = $providers_registry; + } + + /** + * Initializes the controller. + * + * @since 5.9.0 + */ + public function init() { + $this->providers_registry->init(); + + // Register callback to generate and enqueue styles. + if ( did_action( 'wp_enqueue_scripts' ) ) { + $this->stylesheet_handle = 'webfonts-footer'; + $hook = 'wp_print_footer_scripts'; + } else { + $this->stylesheet_handle = 'webfonts'; + $hook = 'wp_enqueue_scripts'; + } + add_action( $hook, array( $this, 'generate_and_enqueue_styles' ) ); + + // Enqueue webfonts in the block editor. + add_action( 'admin_init', array( $this, 'generate_and_enqueue_editor_styles' ) ); + + // Add resources hints. + add_filter( 'wp_resource_hints', array( $this, 'get_resource_hints' ), 10, 2 ); + } + + /** + * Gets the instance of the webfonts' registry. + * + * The Webfonts Registry handles the registration + * and in-memory storage of webfonts. + * + * @since 5.9.0 + * + * @return WP_Webfonts_Registry + */ + public function webfonts() { + return $this->webfonts_registry; + } + + /** + * Gets the instance of the providers' registry. + * + * @see WP_Webfonts_Provider_Registry for more information + * on the available methods for use. + * + * @since 5.9.0 + * + * @return WP_Webfonts_Provider_Registry + */ + public function providers() { + return $this->providers_registry; + } + + /** + * Generate and enqueue webfonts styles. + * + * @since 5.9.0 + */ + public function generate_and_enqueue_styles() { + // Generate the styles. + $styles = $this->generate_styles(); + + // Bail out if there are no styles to enqueue. + if ( '' === $styles ) { + return; + } + + // Enqueue the stylesheet. + wp_register_style( $this->stylesheet_handle, '' ); + wp_enqueue_style( $this->stylesheet_handle ); + + // Add the styles to the stylesheet. + wp_add_inline_style( $this->stylesheet_handle, $styles ); + } + + /** + * Generate and enqueue editor styles. + * + * @since 5.9.0 + */ + public function generate_and_enqueue_editor_styles() { + // Generate the styles. + $styles = $this->generate_styles(); + + // Bail out if there are no styles to enqueue. + if ( '' === $styles ) { + return; + } + + wp_add_inline_style( 'wp-block-library', $styles ); + } + + /** + * Generate styles for webfonts. + * + * By default (due to privacy concerns), this API will not do remote requests to + * external webfont services nor generate `@font-face` styles for these remote + * providers. The filter `'has_remote_webfonts_request_permission'` is provided + * to grant permission to do the remote request. + * + * @since 5.9.0 + * + * @return string $styles Generated styles. + */ + private function generate_styles() { + $styles = ''; + $providers = $this->providers_registry->get_all_registered(); + + /* + * Loop through each of the providers to get the CSS for their respective webfonts + * to incrementally generate the collective styles for all of them. + */ + foreach ( $providers as $provider_id => $provider ) { + $registered_webfonts = $this->webfonts_registry->get_by_provider( $provider_id ); + + // If there are no registered webfonts for this provider, skip it. + if ( empty( $registered_webfonts ) ) { + continue; + } + + /* + * Skip fetching from a remote fonts service if the user has not + * consented to the remote request. + */ + if ( + 'local' !== $provider_id && + /** + * Allows permission to be set for doing remote requests + * to a webfont service provider. + * + * By default, the Webfonts API will not make remote requests + * due to privacy concerns. + * + * @since 5.9.0 + * + * @param bool $has_permission Permission to do the remote request. + * Default false. + * @param string $provider_id Provider's ID, e.g. 'google', to identify + * the remote webfonts service provider. + */ + true !== apply_filters( 'has_remote_webfonts_request_permission', false, $provider_id ) + ) { + continue; + } + + /* + * Process the webfonts by first passing them to the provider via `set_webfonts()` + * and then getting the CSS from the provider. + */ + $provider->set_webfonts( $registered_webfonts ); + $styles .= $provider->get_css(); + } + + return $styles; + } + + /** + * Gets the resource hints. + * + * Callback hooked to the filter `'wp_resource_hints'`. Generation + * and rendering of the resource `` is handled where that filter + * fires. This method adds the resource link attributes to pass back + * to that handler. + * + * @since 5.9.0 + * + * @param array $urls { + * Array of resources and their attributes, or URLs to print for resource hints. + * + * @type array|string ...$0 { + * Array of resource attributes, or a URL string. + * + * @type string $href URL to include in resource hints. Required. + * @type string $as How the browser should treat the resource + * (`script`, `style`, `image`, `document`, etc). + * @type string $crossorigin Indicates the CORS policy of the specified resource. + * @type float $pr Expected probability that the resource hint will be used. + * @type string $type Type of the resource (`text/html`, `text/css`, etc). + * } + * } + * @param string $relation_type The relation type the URLs are printed for, + * e.g. 'preconnect' or 'prerender'. + * @return array URLs to print for resource hints. + */ + public function get_resource_hints( $urls, $relation_type ) { + foreach ( $this->providers_registry->get_all_registered() as $provider ) { + foreach ( $provider->get_resource_hints() as $relation => $relation_hints ) { + if ( $relation !== $relation_type ) { + continue; + } + // Append this provider's resource hints to the end of the given `$urls` array. + array_push( $urls, ...$relation_hints ); + } + } + + return $urls; + } +} diff --git a/lib/webfonts-api/class-wp-webfonts-provider-registry.php b/lib/webfonts-api/class-wp-webfonts-provider-registry.php new file mode 100644 index 00000000000000..89366e68077a4d --- /dev/null +++ b/lib/webfonts-api/class-wp-webfonts-provider-registry.php @@ -0,0 +1,136 @@ + @type WP_Webfonts_Provider Provider instance. + * + * @since 5.9.0 + * + * @var WP_Webfonts_Provider[] + */ + private $registered = array(); + + /** + * Gets all registered providers. + * + * Return an array of providers, each keyed by their unique + * ID (i.e. the `$id` property in the provider's object) with + * an instance of the provider (object): + * ID => provider instance + * + * @since 5.9.0 + * + * @return WP_Webfonts_Provider[] All registered providers, + * each keyed by their unique ID. + */ + public function get_all_registered() { + return $this->registered; + } + + /** + * Initializes the registry. + * + * @since 5.9.0 + */ + public function init() { + $this->register_core_providers(); + } + + /** + * Registers the core providers. + * + * Loads each bundled provider's file into memory and + * then registers it for use with the API. + * + * @since 5.9.0 + */ + private function register_core_providers() { + // Load the abstract class into memory. + require_once __DIR__ . '/providers/class-wp-webfonts-provider.php'; + + // Register the Google Provider. + require_once __DIR__ . '/providers/class-wp-webfonts-google-provider.php'; + $this->register( WP_Webfonts_Google_Provider::class ); + + // Register the Local Provider. + require_once __DIR__ . '/providers/class-wp-webfonts-local-provider.php'; + $this->register( WP_Webfonts_Local_Provider::class ); + } + + /** + * Registers a webfont provider. + * + * The provider will be registered by its unique ID + * (via `WP_Webfonts_Provider::get_id()`) and instance of + * the provider (object): + * ID => provider instance + * + * Once registered, provider is ready for use within the API. + * + * @since 5.9.0 + * + * @param string $classname The provider's class name. + * The class should be a child of `WP_Webfonts_Provider`. + * See {@see WP_Webfonts_Provider}. + * + * @return bool True when registered. False when provider does not exist. + */ + public function register( $classname ) { + /* + * Bail out if the class does not exist in memory (its file + * has to be loaded into memory before registration) or the + * `class` itself is not a child that extends `WP_Webfonts_Provider` + * (the parent class of a provider). + */ + if ( + ! class_exists( $classname ) || + ! is_subclass_of( $classname, 'WP_Webfonts_Provider' ) + ) { + return false; + } + + /* + * Create an instance of the provider. + * This API uses one instance of each provider. + */ + $provider = new $classname; + $id = $provider->get_id(); + + // Store the provider's instance by its unique provider ID. + if ( ! isset( $this->registered[ $id ] ) ) { + $this->registered[ $id ] = $provider; + } + + return true; + } +} diff --git a/lib/webfonts-api/class-wp-webfonts-registry.php b/lib/webfonts-api/class-wp-webfonts-registry.php new file mode 100644 index 00000000000000..1c8a3867414f89 --- /dev/null +++ b/lib/webfonts-api/class-wp-webfonts-registry.php @@ -0,0 +1,245 @@ + @type array Webfont. + * + * @since 5.9.0 + * + * @var array[] + */ + private $registered = array(); + + /** + * Registration keys per provider. + * + * Provides a O(1) lookup when querying by provider. + * + * @since 5.9.0 + * + * @var array[] + */ + private $registry_by_provider = array(); + + /** + * Schema validator. + * + * @since 5.9.0 + * + * @var WP_Webfonts_Schema_Validator + */ + private $validator; + + /** + * Creates the registry. + * + * @since 5.9.0 + * + * @param WP_Webfonts_Schema_Validator $validator Instance of the validator. + */ + public function __construct( WP_Webfonts_Schema_Validator $validator ) { + $this->validator = $validator; + } + + /** + * Gets all registered webfonts. + * + * @since 5.9.0 + * + * @return array[] Registered webfonts each keyed by font-family.font-style.font-weight. + */ + public function get_all_registered() { + return $this->registered; + } + + /** + * Gets the registered webfonts for the given provider. + * + * @since 5.9.0 + * + * @param string $provider_id Provider ID to fetch. + * @return array[] Registered webfonts. + */ + public function get_by_provider( $provider_id ) { + if ( ! isset( $this->registry_by_provider[ $provider_id ] ) ) { + return array(); + } + + $webfonts = array(); + foreach ( $this->registry_by_provider[ $provider_id ] as $registration_key ) { + // Skip if not registered. + if ( ! isset( $this->registered[ $registration_key ] ) ) { + continue; + } + + $webfonts[ $registration_key ] = $this->registered[ $registration_key ]; + } + + return $webfonts; + } + + /** + * Registers the given webfont if its schema is valid. + * + * @since 5.9.0 + * + * @param array $webfont { + * Webfont definition. + * + * @type string $provider The provider ID (e.g. 'local', 'google'). + * @type string $font_family The @font-face font-family property. + * @type string $font_weight The @font-face font-weight property. + * The font-weight can be a single value, or a range. + * If a single value, then the font-weight can either be + * a numeric value (400, 700, etc), or a word value + * (normal, bold, etc). + * If a range, then the font-weight can be a numeric range + * using 2 values, separated by a space ('100 700'). + * @type string $font_style The @font-face font-style property. + * The font-style can be a valid CSS value (normal, italic etc). + * @type string $font_display The @font-face font-display property. + * Accepted values: 'auto', 'block', 'fallback', 'swap'. + * @type array|string $src The @font-face src property. + * The src can be a single URL, or an array of URLs. + * @type string $font_stretch The @font-face font-stretch property. + * @type string $font_variant The @font-face font-variant property. + * @type string $font_feature_settings The @font-face font-feature-settings property. + * @type string $font_variation_settings The @font-face font-variation-settings property. + * @type string $line_gap_override The @font-face line-gap-override property. + * @type string $size_adjust The @font-face size-adjust property. + * @type string $unicode_range The @font-face unicode-range property. + * @type string $ascend_override The @font-face ascend-override property. + * @type string $descend_override The @font-face descend-override property. + * } + * @return string Registration key. + */ + public function register( array $webfont ) { + $webfont = $this->convert_to_kebab_case( $webfont ); + + // Validate schema. + if ( ! $this->validator->is_valid_schema( $webfont ) ) { + return ''; + } + + $webfont = $this->validator->set_valid_properties( $webfont ); + + // Add to registry. + $registration_key = $this->generate_registration_key( $webfont ); + if ( isset( $this->registered[ $registration_key ] ) ) { + return $registration_key; + } + + $this->registered[ $registration_key ] = $webfont; + $this->store_for_query_by( $webfont, $registration_key ); + + return $registration_key; + } + + /** + * Convert snake_case keys into kebab-case. + * + * @since 5.9.0 + * + * @param array $webfont Webfont definition. + * @return array Webfont with kebab-case properties (keys). + */ + private function convert_to_kebab_case( array $webfont ) { + $kebab_case = array(); + foreach ( $webfont as $key => $value ) { + $converted_key = str_replace( '_', '-', $key ); + $kebab_case[ $converted_key ] = $value; + } + + return $kebab_case; + } + + /** + * Store the webfont for query by request. + * + * This container provides a performant way to quickly query webfonts by + * provider. The registration keys are stored for O(1) lookup. + * + * @since 5.9.0 + * + * @param array $webfont Webfont definition. + * @param string $registration_key Webfont's registration key. + */ + private function store_for_query_by( array $webfont, $registration_key ) { + $provider = $webfont['provider']; + + // Initialize the array if it does not exist. + if ( ! isset( $this->registry_by_provider[ $provider ] ) ) { + $this->registry_by_provider[ $provider ] = array(); + } + + $this->registry_by_provider[ $provider ][] = $registration_key; + } + + /** + * Generates the registration key. + * + * Format: font-family.font-style.font-weight + * For example: `'open-sans.normal.400'`. + * + * @since 5.9.0 + * + * @param array $webfont Webfont definition. + * @return string Registration key. + */ + private function generate_registration_key( array $webfont ) { + return sprintf( + '%s.%s.%s', + $this->convert_font_family_into_key( $webfont['font-family'] ), + trim( $webfont['font-style'] ), + trim( $webfont['font-weight'] ) + ); + } + + /** + * Converts the given font family into a key. + * + * For example: 'Open Sans' becomes 'open-sans'. + * + * @since 5.9.0 + * + * @param string $font_family Font family to convert into a key. + * @return string Font-family as a key. + */ + private function convert_font_family_into_key( $font_family ) { + return sanitize_title( $font_family ); + } +} diff --git a/lib/webfonts-api/class-wp-webfonts-schema-validator.php b/lib/webfonts-api/class-wp-webfonts-schema-validator.php new file mode 100644 index 00000000000000..263f5595b9b028 --- /dev/null +++ b/lib/webfonts-api/class-wp-webfonts-schema-validator.php @@ -0,0 +1,335 @@ + '', + 'font-family' => '', + 'font-style' => 'normal', + 'font-weight' => '400', + 'font-display' => 'fallback', + ); + + /** + * Webfont being validated. + * + * Set as a property for performance. + * + * @var array + */ + private $webfont = array(); + + /** + * Checks if the given webfont schema is valid. + * + * @since 5.9.0 + * + * @param array $webfont Webfont to validate. + * @return bool True when valid. False when invalid. + */ + public function is_valid_schema( array $webfont ) { + $is_valid = ( + $this->is_valid_provider( $webfont ) && + $this->is_valid_font_family( $webfont ) + ); + + if ( ! $is_valid ) { + return false; + } + + if ( 'local' === $webfont['provider'] || array_key_exists( 'src', $webfont ) ) { + $is_valid = $this->is_src_valid( $webfont ); + } + + return $is_valid; + } + + /** + * Checks if the provider is valid. + * + * @since 5.9.0 + * + * @param array $webfont Webfont to validate. + * @return bool True if valid. False if invalid. + */ + private function is_valid_provider( array $webfont ) { + if ( + empty( $webfont['provider'] ) || + ! is_string( $webfont['provider'] ) + ) { + trigger_error( __( 'Webfont provider must be a non-empty string.' ) ); + + return false; + } + + return true; + } + + /** + * Checks if the font family is valid. + * + * @since 5.9.0 + * + * @param array $webfont Webfont to validate. + * @return bool True when valid. False when invalid. + */ + private function is_valid_font_family( array $webfont ) { + if ( + empty( $webfont['font-family'] ) || + ! is_string( $webfont['font-family'] ) + ) { + trigger_error( __( 'Webfont font family must be a non-empty string.' ) ); + + return false; + } + + return true; + } + + /** + * Checks if the "src" value is valid. + * + * @since 5.9.0 + * + * @param array $webfont Webfont to validate. + * @return bool True if valid. False if invalid. + */ + private function is_src_valid( array $webfont ) { + if ( + empty( $webfont['src'] ) || + ( + ! is_string( $webfont['src'] ) && ! is_array( $webfont['src'] ) + ) + ) { + trigger_error( __( 'Webfont src must be a non-empty string or an array of strings.' ) ); + + return false; + } + + foreach ( (array) $webfont['src'] as $src ) { + if ( empty( $src ) || ! is_string( $src ) ) { + trigger_error( __( 'Each webfont src must be a non-empty string.' ) ); + + return false; + } + + if ( ! $this->is_src_value_valid( $src ) ) { + trigger_error( __( 'Webfont src must be a valid URL or a data URI.' ) ); + + return false; + } + } + + return true; + } + + /** + * Checks if the given `src` value is valid. + * + * @since 5.9.0 + * + * @param string $src Source to validate. + * @return bool True when valid. False when invalid. + */ + private function is_src_value_valid( $src ) { + if ( + // Validate data URLs. + preg_match( '/^data:.+;base64/', $src ) || + // Validate URLs. + filter_var( $src, FILTER_VALIDATE_URL ) || + // Check if it's a URL starting with "//" (omitted protocol). + 0 === strpos( $src, '//' ) + ) { + return true; + } + + return false; + } + + /** + * Sets valid properties. + * + * @since 5.9.0 + * + * @param array $webfont Webfont definition. + * @return array Updated webfont. + */ + public function set_valid_properties( array $webfont ) { + $this->webfont = array_merge( $this->basic_schema, $webfont ); + + $this->set_valid_font_face_property(); + $this->set_valid_font_style(); + $this->set_valid_font_weight(); + $this->set_valid_font_display(); + + $webfont = $this->webfont; + $this->webfont = array(); // Reset property. + + return $webfont; + } + + /** + * Checks if the CSS property is valid for @font-face. + * + * @since 5.9.0 + */ + private function set_valid_font_face_property() { + foreach ( array_keys( $this->webfont ) as $property ) { + /* + * Skip valid configuration parameters + * (these are configuring the webfont but are not @font-face properties). + */ + if ( 'provider' === $property || 'provider-params' === $property ) { + continue; + } + + if ( ! in_array( $property, $this->font_face_properties, true ) ) { + unset( $this->webfont[ $property ] ); + } + } + } + + /** + * Sets a default font-style if invalid. + * + * @since 5.9.0 + */ + private function set_valid_font_style() { + // If empty or not a string, trigger an error and then set the default value. + if ( + empty( $this->webfont['font-style'] ) || + ! is_string( $this->webfont['font-style'] ) + ) { + trigger_error( __( 'Webfont font style must be a non-empty string.' ) ); + + } elseif ( // Bail out if the font-style is a valid value. + in_array( $this->webfont['font-style'], self::VALID_FONT_STYLE, true ) || + preg_match( '/^oblique\s+(\d+)%/', $this->webfont['font-style'] ) + ) { + return; + } + + $this->webfont['font-style'] = 'normal'; + } + + /** + * Sets a default font-weight if invalid. + * + * @since 5.9.0 + */ + private function set_valid_font_weight() { + // If empty or not a string, trigger an error and then set the default value. + if ( + empty( $this->webfont['font-weight'] ) || + ! is_string( $this->webfont['font-weight'] ) + ) { + trigger_error( __( 'Webfont font weight must be a non-empty string.' ) ); + + } elseif ( // Bail out if the font-weight is a valid value. + // Check if value is a single font-weight, formatted as a number. + in_array( $this->webfont['font-weight'], self::VALID_FONT_WEIGHT, true ) || + // Check if value is a single font-weight, formatted as a number. + preg_match( '/^(\d+)$/', $this->webfont['font-weight'], $matches ) || + // Check if value is a range of font-weights, formatted as a number range. + preg_match( '/^(\d+)\s+(\d+)$/', $this->webfont['font-weight'], $matches ) + ) { + return; + } + + // Not valid. Set the default value. + $this->webfont['font-weight'] = '400'; + } + + /** + * Sets a default font-display if invalid. + * + * @since 5.9.0 + */ + private function set_valid_font_display() { + if ( + empty( $this->webfont['font-display'] ) || + ! in_array( $this->webfont['font-display'], self::VALID_FONT_DISPLAY, true ) + ) { + $this->webfont['font-display'] = 'fallback'; + } + } +} diff --git a/lib/webfonts-api/providers/class-wp-webfonts-google-provider.php b/lib/webfonts-api/providers/class-wp-webfonts-google-provider.php new file mode 100644 index 00000000000000..4379ca835e373c --- /dev/null +++ b/lib/webfonts-api/providers/class-wp-webfonts-google-provider.php @@ -0,0 +1,328 @@ +` in the `
`. + * + * @since 5.9.0 + * + * @var string[] See {@see WP_Webfonts_Provider::$resource_hints} for + * the list of resource hints. + */ + protected $resource_hints = array( + 'preconnect' => array( + array( + 'href' => 'https://fonts.gstatic.com', + 'crossorigin' => 'anonymous', + ), + ), + ); + + /** + * Gets the `@font-face` CSS styles for Google Fonts. + * + * This method does the following processing tasks: + * 1. Orchestrates an optimized Google Fonts API URL for each font-family. + * 2. Caches each URL, if not already cached. + * 3. Does a remote request to the Google Fonts API service to fetch the styles. + * 4. Generates the `@font-face` for all its webfonts. + * + * @since 5.9.0 + * + * @return string The `@font-face` CSS. + */ + public function get_css() { + $css = ''; + $urls = $this->build_collection_api_urls(); + + foreach ( $urls as $url ) { + $css .= $this->get_cached_remote_styles( 'google_fonts_' . md5( $url ), $url ); + } + + return $css; + } + + /** + * Builds the Google Fonts URL for a collection of webfonts. + * + * For example, if given the following webfonts: + * ``` + * array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ) + * ``` + * then the returned collection would be: + * ``` + * array( + * 'https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,200;0,300;0,400;1,400;1,500;1,600&display=fallback' + * ) + * ``` + * + * @since 5.9.0 + * + * @return array Collection of font-family urls. + */ + private function build_collection_api_urls() { + $font_families_urls = array(); + + /* + * Iterate over each font-family group to build the Google Fonts API URL + * for that specific family. Each is added to the collection of URLs to be + * returned to the `get_css()` method for making the remote request. + */ + foreach ( $this->organize_webfonts() as $font_display => $font_families ) { + $url_parts = array(); + foreach ( $font_families as $font_family => $webfonts ) { + list( $normal_weights, $italic_weights ) = $this->collect_font_weights( $webfonts ); + + // Build the font-style with its font-weights. + $url_part = urlencode( $font_family ); + if ( empty( $italic_weights ) && ! empty( $normal_weights ) ) { + $url_part .= ':wght@' . implode( ';', $normal_weights ); + } elseif ( ! empty( $italic_weights ) && empty( $normal_weights ) ) { + $url_part .= ':ital,wght@1,' . implode( ';', $normal_weights ); + } elseif ( ! empty( $italic_weights ) && ! empty( $normal_weights ) ) { + $url_part .= ':ital,wght@0,' . implode( ';0,', $normal_weights ) . ';1,' . implode( ';1,', $italic_weights ); + } + + // Add it to the collection. + $url_parts[] = $url_part; + } + + // Build the URL for this font-family and add it to the collection. + $font_families_urls[] = $this->root_url . '?family=' . implode( '&family=', $url_parts ) . '&display=' . $font_display; + } + + return $font_families_urls; + } + + /** + * Organizes the webfonts by font-display and then font-family. + * + * To optimizing building the URL for the Google Fonts API request, + * this method organizes the webfonts first by font-display and then + * by font-family. + * + * For example, if given the following webfonts: + * ``` + * array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ) + * ``` + * then the returned collection would be: + * ``` + * array( + * 'fallback' => array( + * 'Source Serif Pro' => array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ), + * ), + * ) + * + * @since 5.9.0 + * + * @return array[][] Webfonts organized by font-display and then font-family. + */ + private function organize_webfonts() { + $font_display_groups = array(); + + /* + * Group by font-display. + * Each font-display will need to be a separate request. + */ + foreach ( $this->webfonts as $webfont ) { + if ( ! isset( $font_display_groups[ $webfont['font-display'] ] ) ) { + $font_display_groups[ $webfont['font-display'] ] = array(); + } + $font_display_groups[ $webfont['font-display'] ][] = $webfont; + } + + /* + * Iterate over each font-display group and group by font-family. + * Multiple font-families can be combined in the same request, + * but their params need to be grouped. + */ + foreach ( $font_display_groups as $font_display => $font_display_group ) { + $font_families = array(); + + foreach ( $font_display_group as $webfont ) { + if ( ! isset( $font_families[ $webfont['font-family'] ] ) ) { + $font_families[ $webfont['font-family'] ] = array(); + } + $font_families[ $webfont['font-family'] ][] = $webfont; + } + + $font_display_groups[ $font_display ] = $font_families; + } + + return $font_display_groups; + } + + /** + * Collects all font-weights grouped by 'normal' and 'italic' font-style. + * + * For example, if given the following webfonts: + * ``` + * array( + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'normal', + * 'font-weight' => '200 400', + * ), + * array( + * 'font-family' => 'Source Serif Pro', + * 'font-style' => 'italic', + * 'font-weight' => '400 600', + * ), + * ) + * ``` + * Then the returned collection would be: + * ``` + * array( + * array( 200, 300, 400 ), + * array( 400, 500, 600 ), + * ) + * ``` + * + * @since 5.9.0 + * + * @param array $webfonts Webfonts to process. + * @return array[] { + * The font-weights grouped by font-style. + * + * @type array $normal_weights Individual font-weight values for 'normal' font-style. + * @type array $italic_weights Individual font-weight values for 'italic' font-style. + * } + */ + private function collect_font_weights( array $webfonts ) { + $normal_weights = array(); + $italic_weights = array(); + + foreach ( $webfonts as $webfont ) { + $font_weights = $this->get_font_weights( $webfont['font-weight'] ); + // Skip this webfont if it does not have a font-weight defined. + if ( empty( $font_weights ) ) { + continue; + } + + // Add the individual font-weights to the end of font-style array. + if ( 'italic' === $webfont['font-style'] ) { + array_push( $italic_weights, ...$font_weights ); + } else { + array_push( $normal_weights, ...$font_weights ); + } + } + + // Remove duplicates. + $normal_weights = array_unique( $normal_weights ); + $italic_weights = array_unique( $italic_weights ); + + return array( $normal_weights, $italic_weights ); + } + + /** + * Converts the given string of font-weight into an array of individual weight values. + * + * When given a single font-weight, the value is wrapped into an array. + * + * A range of font-weights is specified as '400 600' with the lightest value first, + * a space, and then the heaviest value last. + * + * When given a range of font-weight values, the range is converted into individual + * font-weight values. For example, a range of '400 600' is converted into + * `array( 400, 500, 600 )`. + * + * @since 5.9.0 + * + * @param string $font_weights The font-weights string. + * @return array The font-weights array. + */ + private function get_font_weights( $font_weights ) { + $font_weights = trim( $font_weights ); + + // A single font-weight. + if ( false === strpos( $font_weights, ' ' ) ) { + return array( $font_weights ); + } + + // Process a range of font-weight values that are delimited by ' '. + $font_weights = explode( ' ', $font_weights ); + + // If there are 2 values, treat them as a range. + if ( 2 === count( $font_weights ) ) { + $font_weights = range( (int) $font_weights[0], (int) $font_weights[1], 100 ); + } + + return $font_weights; + } +} diff --git a/lib/webfonts-api/providers/class-wp-webfonts-local-provider.php b/lib/webfonts-api/providers/class-wp-webfonts-local-provider.php new file mode 100644 index 00000000000000..4d8b02bd06c6e6 --- /dev/null +++ b/lib/webfonts-api/providers/class-wp-webfonts-local-provider.php @@ -0,0 +1,264 @@ + + * array( + * 'source-serif-pro.normal.200 900' => array( + * 'provider' => 'local', + * 'font_family' => 'Source Serif Pro', + * 'font_weight' => '200 900', + * 'font_style' => 'normal', + * 'src' => 'https://example.com/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ), + * ), + * 'source-serif-pro.italic.400 900' => array( + * 'provider' => 'local', + * 'font_family' => 'Source Serif Pro', + * 'font_weight' => '200 900', + * 'font_style' => 'italic', + * 'src' => 'https://example.com/wp-content/themes/twentytwentytwo/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2' ), + * ), + * ) + * + * + * the following `@font-face` styles are generated and returned: + *
+ * @font-face{
+ * font-family:"Source Serif Pro";
+ * font-style:normal;
+ * font-weight:200 900;
+ * font-stretch:normal;
+ * src:local("Source Serif Pro"), url('/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');
+ * }
+ * @font-face{
+ * font-family:"Source Serif Pro";
+ * font-style:italic;
+ * font-weight:200 900;
+ * font-stretch:normal;
+ * src:local("Source Serif Pro"), url('/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2') format('woff2');
+ * }
+ *
+ *
+ * @since 5.9.0
+ *
+ * @return string The `@font-face` CSS.
+ */
+ public function get_css() {
+ $css = '';
+
+ foreach ( $this->webfonts as $webfont ) {
+ // Order the webfont's `src` items to optimize for browser support.
+ $webfont = $this->order_src( $webfont );
+
+ // Build the @font-face CSS for this webfont.
+ $css .= "@font-face{\n" . $this->build_font_face_css( $webfont ) . "}\n";
+ }
+
+ return $css;
+ }
+
+ /**
+ * Order `src` items to optimize for browser support.
+ *
+ * @since 5.9.0
+ *
+ * @param array $webfont Webfont to process.
+ * @return array
+ */
+ private function order_src( array $webfont ) {
+ if ( ! is_array( $webfont['src'] ) ) {
+ $webfont['src'] = (array) $webfont['src'];
+ }
+
+ $src = array();
+ $src_ordered = array();
+
+ foreach ( $webfont['src'] as $url ) {
+ // Add data URIs first.
+ if ( 0 === strpos( trim( $url ), 'data:' ) ) {
+ $src_ordered[] = array(
+ 'url' => $url,
+ 'format' => 'data',
+ );
+ continue;
+ }
+ $format = pathinfo( $url, PATHINFO_EXTENSION );
+ $src[ $format ] = $url;
+ }
+
+ // Add woff2.
+ if ( ! empty( $src['woff2'] ) ) {
+ $src_ordered[] = array(
+ 'url' => $src['woff2'],
+ 'format' => 'woff2',
+ );
+ }
+
+ // Add woff.
+ if ( ! empty( $src['woff'] ) ) {
+ $src_ordered[] = array(
+ 'url' => $src['woff'],
+ 'format' => 'woff',
+ );
+ }
+
+ // Add ttf.
+ if ( ! empty( $src['ttf'] ) ) {
+ $src_ordered[] = array(
+ 'url' => $src['ttf'],
+ 'format' => 'truetype',
+ );
+ }
+
+ // Add eot.
+ if ( ! empty( $src['eot'] ) ) {
+ $src_ordered[] = array(
+ 'url' => $src['eot'],
+ 'format' => 'embedded-opentype',
+ );
+ }
+
+ // Add otf.
+ if ( ! empty( $src['otf'] ) ) {
+ $src_ordered[] = array(
+ 'url' => $src['otf'],
+ 'format' => 'opentype',
+ );
+ }
+ $webfont['src'] = $src_ordered;
+
+ return $webfont;
+ }
+
+ /**
+ * Builds the font-family's CSS.
+ *
+ * @since 5.9.0
+ *
+ * @param array $webfont Webfont to process.
+ * @return string This font-family's CSS.
+ */
+ private function build_font_face_css( array $webfont ) {
+ $css = '';
+
+ // Wrap font-family in quotes if it contains spaces.
+ if (
+ false !== strpos( $webfont['font-family'], ' ' ) &&
+ false === strpos( $webfont['font-family'], '"' ) &&
+ false === strpos( $webfont['font-family'], "'" )
+ ) {
+ $webfont['font-family'] = '"' . $webfont['font-family'] . '"';
+ }
+
+ foreach ( $webfont as $key => $value ) {
+
+ // Skip "provider".
+ if ( 'provider' === $key ) {
+ continue;
+ }
+
+ // Compile the "src" parameter.
+ if ( 'src' === $key ) {
+ $value = $this->compile_src( $webfont['font-family'], $value );
+ }
+
+ // If font-variation-settings is an array, convert it to a string.
+ if ( 'font-variation-settings' === $key && is_array( $value ) ) {
+ $value = $this->compile_variations( $value );
+ }
+
+ if ( ! empty( $value ) ) {
+ $css .= ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG )
+ ? "\t$key:$value;\n"
+ : "$key:$value";
+ }
+ }
+
+ return $css;
+ }
+
+ /**
+ * Compiles the `src` into valid CSS.
+ *
+ * @since 5.9.0
+ *
+ * @param string $font_family Font family.
+ * @param array $value Value to process.
+ * @return string The CSS.
+ */
+ private function compile_src( $font_family, array $value ) {
+ $src = "local($font_family)";
+
+ foreach ( $value as $item ) {
+
+ if ( 0 === strpos( $item['url'], get_site_url() ) ) {
+ $item['url'] = wp_make_link_relative( $item['url'] );
+ }
+
+ $src .= ( 'data' === $item['format'] )
+ ? ", url({$item['url']})"
+ : ", url('{$item['url']}') format('{$item['format']}')";
+ }
+ return $src;
+ }
+
+ /**
+ * Compiles the font variation settings.
+ *
+ * @since 5.9.0
+ *
+ * @param array $font_variation_settings Array of font variation settings.
+ * @return string The CSS.
+ */
+ private function compile_variations( array $font_variation_settings ) {
+ $variations = '';
+
+ foreach ( $font_variation_settings as $key => $value ) {
+ $variations .= "$key $value";
+ }
+
+ return $variations;
+ }
+}
diff --git a/lib/webfonts-api/providers/class-wp-webfonts-provider.php b/lib/webfonts-api/providers/class-wp-webfonts-provider.php
new file mode 100644
index 00000000000000..73b756c5d37d25
--- /dev/null
+++ b/lib/webfonts-api/providers/class-wp-webfonts-provider.php
@@ -0,0 +1,195 @@
+` in the ``.
+ *
+ * @since 5.9.0
+ *
+ * @var string[] {
+ * Resource attributes for each relation type (e.g. 'preconnect' or 'prerender').
+ *
+ * @type string $relation_type => array {
+ * Array of resource attributes.
+ *
+ * @type string $href URL to include in resource hints. Required.
+ * @type string $as Optional. How the browser should treat the resource
+ * (`script`, `style`, `image`, `document`, etc).
+ * @type string $crossorigin Optional. Indicates the CORS policy of the specified resource.
+ * @type float $pr Optional. Expected probability that the resource hint will be used.
+ * @type string $type Optional. Type of the resource (`text/html`, `text/css`, etc).
+ * }
+ * }
+ */
+ protected $resource_hints = array();
+
+ /**
+ * Get the provider's unique ID.
+ *
+ * @since 5.9.0
+ *
+ * @return string
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Sets this provider's webfonts property.
+ *
+ * The API's Controller passes this provider's webfonts
+ * for processing here in the provider.
+ *
+ * @since 5.9.0
+ *
+ * @param array[] $webfonts Registered webfonts.
+ */
+ public function set_webfonts( array $webfonts ) {
+ $this->webfonts = $webfonts;
+ }
+
+ /**
+ * Gets the `@font-face` CSS for the provider's webfonts.
+ *
+ * This method is where the provider does it processing to build the
+ * needed `@font-face` CSS for all of its webfonts. Specifics of how
+ * this processing is done is contained in each provider.
+ *
+ * @since 5.9.0
+ *
+ * @return string The `@font-face` CSS.
+ */
+ abstract public function get_css();
+
+ /**
+ * Gets cached styles from a remote URL.
+ *
+ * @since 5.9.0
+ *
+ * @param string $id An ID used to cache the styles.
+ * @param string $url The URL to fetch.
+ * @param array $args Optional. The arguments to pass to `wp_remote_get()`.
+ * Default empty array.
+ * @return string The styles.
+ */
+ protected function get_cached_remote_styles( $id, $url, array $args = array() ) {
+ $css = get_site_transient( $id );
+
+ // Get remote response and cache the CSS if it hasn't been cached already.
+ if ( false === $css ) {
+ $css = $this->get_remote_styles( $url, $args );
+
+ /*
+ * Early return if the request failed.
+ * Cache an empty string for 60 seconds to avoid bottlenecks.
+ */
+ if ( empty( $css ) ) {
+ set_site_transient( $id, '', MINUTE_IN_SECONDS );
+ return '';
+ }
+
+ // Cache the CSS for a month.
+ set_site_transient( $id, $css, MONTH_IN_SECONDS );
+ }
+
+ return $css;
+ }
+
+ /**
+ * Gets styles from the remote font service via the given URL.
+ *
+ * @since 5.9.0
+ *
+ * @param string $url The URL to fetch.
+ * @param array $args Optional. The arguments to pass to `wp_remote_get()`.
+ * Default empty array.
+ * @return string The styles on success. Empty string on failure.
+ */
+ protected function get_remote_styles( $url, array $args = array() ) {
+ // Use a modern user-agent, to get woff2 files.
+ $args['user-agent'] = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:73.0) Gecko/20100101 Firefox/73.0';
+
+ // Get the remote URL contents.
+ $response = wp_remote_get( $url, $args );
+
+ // Early return if the request failed.
+ if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
+ return '';
+ }
+
+ // Get the response body.
+ return wp_remote_retrieve_body( $response );
+ }
+
+ /**
+ * Gets the provider's resource hints.
+ *
+ * The Controller calls this method {@see WP_Webfonts_Controller::get_resource_hints()}
+ * when the `'wp_resource_hints'` filter fires.
+ *
+ * @since 5.9.0
+ *
+ * @return string[] Array of resource attributes.
+ * See {@see WP_Webfonts_Provider::$resource_hints} for
+ * the list of resource hints.
+ */
+ public function get_resource_hints() {
+ return $this->resource_hints;
+ }
+}
diff --git a/lib/webfonts.php b/lib/webfonts.php
new file mode 100644
index 00000000000000..1b120aad5839e4
--- /dev/null
+++ b/lib/webfonts.php
@@ -0,0 +1,185 @@
+init();
+ }
+
+ return $instance;
+}
+
+/**
+ * Registers a collection of webfonts.
+ *
+ * Example of how to register Source Serif Pro font with font-weight range of 200-900
+ * and font-style of normal and italic:
+ *
+ * If the font files are contained within the theme:
+ *
+ * wp_register_webfonts(
+ * array(
+ * array(
+ * 'provider' => 'local',
+ * 'font_family' => 'Source Serif Pro',
+ * 'font_weight' => '200 900',
+ * 'font_style' => 'normal',
+ * 'src' => get_theme_file_uri( 'assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ),
+ * ),
+ * array(
+ * 'provider' => 'local',
+ * 'font_family' => 'Source Serif Pro',
+ * 'font_weight' => '200 900',
+ * 'font_style' => 'italic',
+ * 'src' => get_theme_file_uri( 'assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2' ),
+ * ),
+ * )
+ * );
+ *
+ *
+ * When requesting from the remote Google Fonts API service provider:
+ *
+ * wp_register_webfonts(
+ * array(
+ * array(
+ * 'provider' => 'google',
+ * 'font_family' => 'Source Serif Pro',
+ * 'font_weight' => '200 900',
+ * 'font_style' => 'normal',
+ * ),
+ * array(
+ * 'provider' => 'google',
+ * 'font_family' => 'Source Serif Pro',
+ * 'font_weight' => '200 900',
+ * 'font_style' => 'italic',
+ * ),
+ * )
+ * );
+ *
+ *
+ * @since 5.9.0
+ *
+ * @param array $webfonts Webfonts to be registered.
+ * This contains an array of webfonts to be registered.
+ * Each webfont is an array.
+ * See {@see WP_Webfonts_Registry::register()} for a list of
+ * supported arguments for each webfont.
+ */
+function wp_register_webfonts( array $webfonts = array() ) {
+ foreach ( $webfonts as $webfont ) {
+ wp_webfonts()->webfonts()->register( $webfont );
+ }
+}
+
+/**
+ * Registers a single webfont.
+ *
+ * Example of how to register Source Serif Pro font with font-weight range of 200-900:
+ *
+ * If the font file is contained within the theme:
+ * ```
+ * wp_register_webfont(
+ * array(
+ * 'provider' => 'local',
+ * 'font_family' => 'Source Serif Pro',
+ * 'font_weight' => '200 900',
+ * 'font_style' => 'normal',
+ * 'src' => get_theme_file_uri( 'assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2' ),
+ * )
+ * );
+ * ```
+ *
+ * When requesting from the remote Google Fonts API service provider:
+ * ```
+ * wp_register_webfonts(
+ * array(
+ * 'provider' => 'google',
+ * 'font_family' => 'Source Serif Pro',
+ * 'font_weight' => '200 900',
+ * 'font_style' => 'normal',
+ * )
+ * );
+ * ```
+ *
+ * @since 5.9.0
+ *
+ * @param array $webfont Webfont to be registered.
+ * See {@see WP_Webfonts_Registry::register()} for a list of supported arguments.
+ * @return string Registration key.
+ */
+function wp_register_webfont( array $webfont ) {
+ return wp_webfonts()->webfonts()->register( $webfont );
+}
+
+/**
+ * Registers a custom font service provider.
+ *
+ * A webfont provider contains the business logic for how to
+ * interact with a remote font service and how to generate
+ * the `@font-face` styles for that remote service.
+ *
+ * See the `WP_Webfonts_Google_Provider` for inspiration.
+ *
+ * How to register a custom font service provider:
+ * 1. Load its class file into memory before registration.
+ * 2. Pass the class' name to this function.
+ *
+ * For example, for a class named `My_Custom_Font_Service_Provider`:
+ * ```
+ * wp_register_webfont_provider( My_Custom_Font_Service_Provider::class );
+ * ```
+ *
+ * @since 5.9.0
+ *
+ * @param string $classname The provider's class name.
+ * The class should be a child of `WP_Webfonts_Provider`.
+ * See {@see WP_Webfonts_Provider}.
+ *
+ * @return bool True when registered. False when provider does not exist.
+ */
+function wp_register_webfont_provider( $classname ) {
+ return wp_webfonts()->providers()->register( $classname );
+}
+
+/**
+ * Gets all registered providers.
+ *
+ * Return an array of providers, each keyed by their unique
+ * ID (i.e. the `$id` property in the provider's object) with
+ * an instance of the provider (object):
+ * ID => provider instance
+ *
+ * Each provider contains the business logic for how to
+ * process its specific font service (i.e. local or remote)
+ * and how to generate the `@font-face` styles for its service.
+ *
+ * @since 5.9.0
+ *
+ * @return WP_Webfonts_Provider[] All registered providers,
+ * each keyed by their unique ID.
+ */
+function wp_get_webfont_providers() {
+ return wp_webfonts()->providers()->get_all_registered();
+}
diff --git a/phpunit/webfonts-api/mocks/class-my-custom-webfonts-provider-mock.php b/phpunit/webfonts-api/mocks/class-my-custom-webfonts-provider-mock.php
new file mode 100644
index 00000000000000..d62a8789b88a7b
--- /dev/null
+++ b/phpunit/webfonts-api/mocks/class-my-custom-webfonts-provider-mock.php
@@ -0,0 +1,35 @@
+ array(
+ array(
+ 'href' => 'https://fonts.my-custom-api.com',
+ 'crossorigin' => 'anonymous',
+ ),
+ ),
+ );
+
+ public function get_css() {
+ return "
+ @font-face{
+ font-family: 'Source Serif Pro';
+ font-weight: 200 900;
+ font-style: normal;
+ font-stretch: normal;
+ src: url('https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Roman.ttf.woff2') format('woff2');
+ }
+ @font-face{
+ font-family: 'Source Serif Pro';
+ font-weight: 200 900;
+ font-style: italic;
+ font-stretch: normal;
+ src: url('https://example.com/assets/fonts/source-serif-pro/SourceSerif4Variable-Italic.ttf.woff2') format('woff2');
+ }
+ ";
+ }
+}
diff --git a/phpunit/webfonts-api/providers/wpWebfontsGoogleProvider.php b/phpunit/webfonts-api/providers/wpWebfontsGoogleProvider.php
new file mode 100644
index 00000000000000..a3d763d105c47c
--- /dev/null
+++ b/phpunit/webfonts-api/providers/wpWebfontsGoogleProvider.php
@@ -0,0 +1,348 @@
+provider = new WP_Webfonts_Google_Provider();
+ }
+
+ /**
+ * @covers WP_Webfonts_Google_Provider::set_webfonts
+ */
+ public function test_set_webfonts() {
+ $webfonts = array(
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => 'fallback',
+ ),
+ 'open-sans.italic.700' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'italic',
+ 'font-weight' => '700',
+ 'font-display' => 'fallback',
+ ),
+ 'roboto.normal.900' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Roboto',
+ 'font-style' => 'normal',
+ 'font-weight' => '900',
+ 'font-display' => 'fallback',
+ ),
+ );
+
+ $this->provider->set_webfonts( $webfonts );
+
+ $property = $this->get_webfonts_property();
+ $this->assertSame( $webfonts, $property->getValue( $this->provider ) );
+ }
+
+ /**
+ * @covers WP_Webfonts_Google_Provider::build_collection_api_urls
+ *
+ * @dataProvider data_build_collection_api_urls
+ *
+ * @since 5.9.0
+ *
+ * @param array $webfonts Webfonts input.
+ * @param array $expected Expected urls.
+ */
+ public function test_build_collection_api_urls( array $webfonts, array $expected ) {
+ $property = new ReflectionProperty( $this->provider, 'webfonts' );
+ $property->setAccessible( true );
+ $property->setValue( $this->provider, $webfonts );
+
+ $method = new ReflectionMethod( $this->provider, 'build_collection_api_urls' );
+ $method->setAccessible( true );
+ $actual = $method->invoke( $this->provider );
+
+ $this->assertSame( $expected, $actual );
+ }
+
+ /**
+ * Data Provider.
+ *
+ * @return array
+ */
+ public function data_build_collection_api_urls() {
+ return array(
+ 'single font-family + single variation' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => 'fallback',
+ ),
+ ),
+ 'expected' => array(
+ 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=fallback',
+ ),
+ ),
+ 'single font-family + multiple variations' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => 'fallback',
+ ),
+ 'open-sans.italic.700' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'italic',
+ 'font-weight' => '700',
+ 'font-display' => 'fallback',
+ ),
+ ),
+ 'expected' => array(
+ 'https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;1,700&display=fallback',
+ ),
+ ),
+ 'multiple font-families and variations' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => 'fallback',
+ ),
+ 'open-sans.italic.700' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'italic',
+ 'font-weight' => '700',
+ 'font-display' => 'fallback',
+ ),
+ 'roboto.normal.900' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Roboto',
+ 'font-style' => 'normal',
+ 'font-weight' => '900',
+ 'font-display' => 'fallback',
+ ),
+ ),
+ 'expected' => array(
+ 'https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;1,700&family=Roboto:wght@900&display=fallback',
+ ),
+ ),
+ 'range of font-weight values' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400 900' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400 900',
+ 'font-display' => 'fallback',
+ ),
+ 'open-sans.normal.100 400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '100 400',
+ 'font-display' => 'fallback',
+ ),
+ ),
+ 'expected' => array(
+ 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700;800;900;100;200;300&display=fallback',
+ ),
+ ),
+ 'duplicate font-weight values' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400 900' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400 600',
+ 'font-display' => 'fallback',
+ ),
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => 'fallback',
+ ),
+ ),
+ 'expected' => array(
+ 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600&display=fallback',
+ ),
+ ),
+ );
+ }
+
+ /**
+ * @covers WP_Webfonts_Google_Provider::get_css
+ *
+ * @dataProvider data_get_css
+ *
+ * @param array $webfonts Prepared webfonts (to store in WP_Webfonts_Local_Provider::$webfonts property)
+ * @param int $response_code Remote request response code.
+ * @param string $expected Expected CSS.
+ */
+ public function test_get_css( array $webfonts, $response_code, $expected ) {
+ $property = $this->get_webfonts_property();
+ $property->setValue( $this->provider, $webfonts );
+
+ add_filter(
+ 'pre_http_request',
+ static function() use ( $response_code, $expected ) {
+ return array(
+ 'headers' => array(),
+ 'body' => $expected,
+ 'response' => array(
+ 'code' => $response_code,
+ ),
+ 'cookies' => array(),
+ 'filename' => null,
+ );
+ }
+ );
+
+ $this->assertSame( $expected, $this->provider->get_css() );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array
+ */
+ public function data_get_css() {
+ return array(
+ 'failure: invalid font-family' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Invalid',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => '',
+ ),
+ ),
+ 'response_code' => 400, // The requested font families are not available.
+ 'expected' => '',
+ ),
+ 'success: single font-family' => array(
+ 'webfonts' => array(
+ 'open-sans.normal.400' => array(
+ 'provider' => 'google',
+ 'font-family' => 'Open Sans',
+ 'font-style' => 'normal',
+ 'font-weight' => '400',
+ 'font-display' => 'fallback',
+ ),
+ ),
+ 'response_code' => 200,
+ 'expected' => <<