diff --git a/lib/experimental/fonts/font-library/class-wp-font-collection.php b/lib/experimental/fonts/font-library/class-wp-font-collection.php index 6189da5fa984b..1ff96b1343b45 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-collection.php +++ b/lib/experimental/fonts/font-library/class-wp-font-collection.php @@ -21,41 +21,128 @@ class WP_Font_Collection { /** - * Font collection configuration. + * The unique slug for the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $slug; + + /** + * The name of the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $name; + + /** + * Description of the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $description; + + /** + * Source of the font collection. + * + * @since 6.5.0 + * + * @var string + */ + private $src; + + /** + * Array of font families in the collection. * * @since 6.5.0 * * @var array */ - private $config; + private $font_families; + + /** + * Categories associated with the font collection. + * + * @since 6.5.0 + * + * @var array + */ + private $categories; + /** * WP_Font_Collection constructor. * * @since 6.5.0 * - * @param array $config Font collection config options. - * See {@see wp_register_font_collection()} for the supported fields. - * @throws Exception If the required parameters are missing. + * @param array $config Font collection config options. { + * @type string $slug The font collection's unique slug. + * @type string $name The font collection's name. + * @type string $description The font collection's description. + * @type string $src The font collection's source. + * @type array $font_families An array of font families in the font collection. + * @type array $categories The font collection's categories. + * } */ public function __construct( $config ) { - if ( empty( $config ) || ! is_array( $config ) ) { - throw new Exception( 'Font Collection config options is required as a non-empty array.' ); - } + $this->is_config_valid( $config ); + + $this->slug = isset( $config['slug'] ) ? $config['slug'] : ''; + $this->name = isset( $config['name'] ) ? $config['name'] : ''; + $this->description = isset( $config['description'] ) ? $config['description'] : ''; + $this->src = isset( $config['src'] ) ? $config['src'] : ''; + $this->font_families = isset( $config['font_families'] ) ? $config['font_families'] : array(); + $this->categories = isset( $config['categories'] ) ? $config['categories'] : array(); + } - if ( empty( $config['slug'] ) || ! is_string( $config['slug'] ) ) { - throw new Exception( 'Font Collection config slug is required as a non-empty string.' ); + /** + * Checks if the font collection config is valid. + * + * @since 6.5.0 + * + * @param array $config Font collection config options. { + * @type string $slug The font collection's unique slug. + * @type string $name The font collection's name. + * @type string $description The font collection's description. + * @type string $src The font collection's source. + * @type array $font_families An array of font families in the font collection. + * @type array $categories The font collection's categories. + * } + * @return bool True if the font collection config is valid and false otherwise. + */ + public static function is_config_valid( $config ) { + if ( empty( $config ) || ! is_array( $config ) ) { + _doing_it_wrong( __METHOD__, __( 'Font Collection config options are required as a non-empty array.', 'gutenberg' ), '6.5.0' ); + return false; } - if ( empty( $config['name'] ) || ! is_string( $config['name'] ) ) { - throw new Exception( 'Font Collection config name is required as a non-empty string.' ); + $required_keys = array( 'slug', 'name' ); + foreach ( $required_keys as $key ) { + if ( empty( $config[ $key ] ) ) { + _doing_it_wrong( + __METHOD__, + // translators: %s: Font collection config key. + sprintf( __( 'Font Collection config %s is required as a non-empty string.', 'gutenberg' ), $key ), + '6.5.0' + ); + return false; + } } - if ( ( empty( $config['src'] ) || ! is_string( $config['src'] ) ) && ( empty( $config['data'] ) ) ) { - throw new Exception( 'Font Collection config "src" option OR "data" option is required.' ); + if ( + ( empty( $config['src'] ) && empty( $config['font_families'] ) ) || + ( ! empty( $config['src'] ) && ! empty( $config['font_families'] ) ) + ) { + _doing_it_wrong( __METHOD__, __( 'Font Collection config "src" option OR "font_families" option are required.', 'gutenberg' ), '6.5.0' ); + return false; } - $this->config = $config; + return true; } /** @@ -73,56 +160,59 @@ public function __construct( $config ) { */ public function get_config() { return array( - 'slug' => $this->config['slug'], - 'name' => $this->config['name'], - 'description' => $this->config['description'] ?? '', + 'slug' => $this->slug, + 'name' => $this->name, + 'description' => $this->description, ); } /** - * Gets the font collection config and data. + * Gets the font collection content. * - * This function returns an array containing the font collection's unique ID, - * name, and its data as a PHP array. + * Load the font collection data from the src if it is not already loaded. * * @since 6.5.0 * - * @return array { - * An array of font collection config and data. + * @return array|WP_Error { + * An array of font collection contents. * - * @type string $slug The font collection's unique ID. - * @type string $name The font collection's name. - * @type string $description The font collection's description. - * @type array $data The font collection's data as a PHP array. + * @type array $font_families The font collection's font families. + * @type string $categories The font collection's categories. * } + * + * A WP_Error object if there was an error loading the font collection data. */ - public function get_config_and_data() { - $config_and_data = $this->get_config(); - $config_and_data['data'] = $this->load_data(); - return $config_and_data; + public function get_content() { + // If the font families are not loaded, and the src is not empty, load the data from the src. + if ( empty( $this->font_families ) && ! empty( $this->src ) ) { + $data = $this->load_contents_from_src(); + if ( is_wp_error( $data ) ) { + return $data; + } + } + + return array( + 'font_families' => $this->font_families, + 'categories' => $this->categories, + ); } /** - * Loads the font collection data. + * Loads the font collection data from the src. * * @since 6.5.0 * * @return array|WP_Error An array containing the list of font families in font-collection.json format on success, * else an instance of WP_Error on failure. */ - public function load_data() { - - if ( ! empty( $this->config['data'] ) ) { - return $this->config['data']; - } - + private function load_contents_from_src() { // If the src is a URL, fetch the data from the URL. - if ( str_contains( $this->config['src'], 'http' ) && str_contains( $this->config['src'], '://' ) ) { - if ( ! wp_http_validate_url( $this->config['src'] ) ) { + if ( preg_match( '#^https?://#', $this->src ) ) { + if ( ! wp_http_validate_url( $this->src ) ) { return new WP_Error( 'font_collection_read_error', __( 'Invalid URL for Font Collection data.', 'gutenberg' ) ); } - $response = wp_remote_get( $this->config['src'] ); + $response = wp_remote_get( $this->src ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'font_collection_read_error', __( 'Error fetching the Font Collection data from a URL.', 'gutenberg' ) ); } @@ -133,15 +223,22 @@ public function load_data() { } // If the src is a file path, read the data from the file. } else { - if ( ! file_exists( $this->config['src'] ) ) { + if ( ! file_exists( $this->src ) ) { return new WP_Error( 'font_collection_read_error', __( 'Font Collection data JSON file does not exist.', 'gutenberg' ) ); } - $data = wp_json_file_decode( $this->config['src'], array( 'associative' => true ) ); + $data = wp_json_file_decode( $this->src, array( 'associative' => true ) ); if ( empty( $data ) ) { return new WP_Error( 'font_collection_read_error', __( 'Error reading the Font Collection data JSON file contents.', 'gutenberg' ) ); } } + if ( empty( $data['font_families'] ) ) { + return new WP_Error( 'font_collection_contents_error', __( 'Font Collection data JSON file does not contain font families.', 'gutenberg' ) ); + } + + $this->font_families = $data['font_families']; + $this->categories = $data['categories'] ?? array(); + return $data; } } diff --git a/lib/experimental/fonts/font-library/class-wp-font-family-utils.php b/lib/experimental/fonts/font-library/class-wp-font-family-utils.php index 35e6856e50aad..71a3bb262e600 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-family-utils.php +++ b/lib/experimental/fonts/font-library/class-wp-font-family-utils.php @@ -91,7 +91,7 @@ public static function format_font_family( $font_family ) { function ( $family ) { $trimmed = trim( $family ); if ( ! empty( $trimmed ) && strpos( $trimmed, ' ' ) !== false && strpos( $trimmed, "'" ) === false && strpos( $trimmed, '"' ) === false ) { - return "'" . $trimmed . "'"; + return '"' . $trimmed . '"'; } return $trimmed; }, @@ -107,4 +107,84 @@ function ( $family ) { return $font_family; } + + /** + * Generates a slug from font face properties, e.g. `open sans;normal;400;100%;U+0-10FFFF` + * + * Used for comparison with other font faces in the same family, to prevent duplicates + * that would both match according the CSS font matching spec. Uses only simple case-insensitive + * matching for fontFamily and unicodeRange, so does not handle overlapping font-family lists or + * unicode ranges. + * + * @since 6.5.0 + * + * @link https://drafts.csswg.org/css-fonts/#font-style-matching + * + * @param array $settings { + * Font face settings. + * + * @type string $fontFamily Font family name. + * @type string $fontStyle Optional font style, defaults to 'normal'. + * @type string $fontWeight Optional font weight, defaults to 400. + * @type string $fontStretch Optional font stretch, defaults to '100%'. + * @type string $unicodeRange Optional unicode range, defaults to 'U+0-10FFFF'. + * } + * @return string Font face slug. + */ + public static function get_font_face_slug( $settings ) { + $settings = wp_parse_args( + $settings, + array( + 'fontFamily' => '', + 'fontStyle' => 'normal', + 'fontWeight' => '400', + 'fontStretch' => '100%', + 'unicodeRange' => 'U+0-10FFFF', + ) + ); + + // Convert all values to lowercase for comparison. + // Font family names may use multibyte characters. + $font_family = mb_strtolower( $settings['fontFamily'] ); + $font_style = strtolower( $settings['fontStyle'] ); + $font_weight = strtolower( $settings['fontWeight'] ); + $font_stretch = strtolower( $settings['fontStretch'] ); + $unicode_range = strtoupper( $settings['unicodeRange'] ); + + // Convert weight keywords to numeric strings. + $font_weight = str_replace( 'normal', '400', $font_weight ); + $font_weight = str_replace( 'bold', '700', $font_weight ); + + // Convert stretch keywords to numeric strings. + $font_stretch_map = array( + 'ultra-condensed' => '50%', + 'extra-condensed' => '62.5%', + 'condensed' => '75%', + 'semi-condensed' => '87.5%', + 'normal' => '100%', + 'semi-expanded' => '112.5%', + 'expanded' => '125%', + 'extra-expanded' => '150%', + 'untra-expanded' => '200%', + ); + $font_stretch = str_replace( array_keys( $font_stretch_map ), array_values( $font_stretch_map ), $font_stretch ); + + $slug_elements = array( $font_family, $font_style, $font_weight, $font_stretch, $unicode_range ); + + $slug_elements = array_map( + function ( $elem ) { + // Remove quotes to normalize font-family names, and ';' to use as a separator. + $elem = trim( str_replace( array( '"', "'", ';' ), '', $elem ) ); + + // Normalize comma separated lists by removing whitespace in between items, + // but keep whitespace within items (e.g. "Open Sans" and "OpenSans" are different fonts). + // CSS spec for whitespace includes: U+000A LINE FEED, U+0009 CHARACTER TABULATION, or U+0020 SPACE, + // which by default are all matched by \s in PHP. + return preg_replace( '/,\s+/', ',', $elem ); + }, + $slug_elements + ); + + return join( ';', $slug_elements ); + } } diff --git a/lib/experimental/fonts/font-library/class-wp-font-library.php b/lib/experimental/fonts/font-library/class-wp-font-library.php index fd36f6ba073c4..51a84b957ea11 100644 --- a/lib/experimental/fonts/font-library/class-wp-font-library.php +++ b/lib/experimental/fonts/font-library/class-wp-font-library.php @@ -62,11 +62,17 @@ public static function get_expected_font_mime_types_per_php_version( $php_versio * @return WP_Font_Collection|WP_Error A font collection is it was registered successfully and a WP_Error otherwise. */ public static function register_font_collection( $config ) { + if ( ! WP_Font_Collection::is_config_valid( $config ) ) { + $error_message = __( 'Font collection config is invalid.', 'gutenberg' ); + return new WP_Error( 'font_collection_registration_error', $error_message ); + } + $new_collection = new WP_Font_Collection( $config ); - if ( self::is_collection_registered( $config['slug'] ) ) { + + if ( self::is_collection_registered( $new_collection->get_config()['slug'] ) ) { $error_message = sprintf( /* translators: %s: Font collection slug. */ - __( 'Font collection with slug: "%s" is already registered.', 'default' ), + __( 'Font collection with slug: "%s" is already registered.', 'gutenberg' ), $config['slug'] ); _doing_it_wrong( @@ -76,7 +82,7 @@ public static function register_font_collection( $config ) { ); return new WP_Error( 'font_collection_registration_error', $error_message ); } - self::$collections[ $config['slug'] ] = $new_collection; + self::$collections[ $new_collection->get_config()['slug'] ] = $new_collection; return $new_collection; } diff --git a/lib/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php deleted file mode 100644 index 0e31bd4004b40..0000000000000 --- a/lib/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php +++ /dev/null @@ -1,25 +0,0 @@ - WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_font_collections' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), ) ); @@ -54,13 +54,29 @@ public function register_routes() { array( array( 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_font_collection' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), ), ) ); } + /** + * Gets the font collections available. + * + * @since 6.5.0 + * + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $collections = array(); + foreach ( WP_Font_Library::get_font_collections() as $collection ) { + $collections[] = $collection->get_config(); + } + + return rest_ensure_response( $collections, 200 ); + } + /** * Gets a font collection. * @@ -69,54 +85,42 @@ public function register_routes() { * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function get_font_collection( $request ) { + public function get_item( $request ) { $slug = $request->get_param( 'slug' ); $collection = WP_Font_Library::get_font_collection( $slug ); + // If the collection doesn't exist returns a 404. if ( is_wp_error( $collection ) ) { $collection->add_data( array( 'status' => 404 ) ); return $collection; } - $config_and_data = $collection->get_config_and_data(); - $collection_data = $config_and_data['data']; - // If there was an error getting the collection data, return the error. - if ( is_wp_error( $collection_data ) ) { - $collection_data->add_data( array( 'status' => 500 ) ); - return $collection_data; - } - - return new WP_REST_Response( $config_and_data ); - } + $config = $collection->get_config(); + $contents = $collection->get_content(); - /** - * Gets the font collections available. - * - * @since 6.5.0 - * - * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. - */ - public function get_font_collections() { - $collections = array(); - foreach ( WP_Font_Library::get_font_collections() as $collection ) { - $collections[] = $collection->get_config_and_data(); + // If there was an error getting the collection data, return the error. + if ( is_wp_error( $contents ) ) { + $contents->add_data( array( 'status' => 500 ) ); + return $contents; } - return new WP_REST_Response( $collections, 200 ); + $collection_data = array_merge( $config, $contents ); + return rest_ensure_response( $collection_data ); } /** - * Checks whether the user has permissions to update the Font Library. + * Checks whether the user has permissions to use the Fonts Collections. * * @since 6.5.0 * * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. */ - public function update_font_library_permissions_check() { + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + if ( ! current_user_can( 'edit_theme_options' ) ) { return new WP_Error( - 'rest_cannot_update_font_library', - __( 'Sorry, you are not allowed to update the Font Library on this site.', 'gutenberg' ), + 'rest_cannot_read', + __( 'Sorry, you are not allowed to use the Font Library on this site.', 'gutenberg' ), array( 'status' => rest_authorization_required_code(), ) diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php new file mode 100644 index 0000000000000..fac32362325f4 --- /dev/null +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php @@ -0,0 +1,836 @@ +namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.', 'gutenberg' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( $this, 'get_items_permissions_check' ), + 'args' => $this->get_collection_params(), + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_item' ), + 'permission_callback' => array( $this, 'create_item_permissions_check' ), + 'args' => $this->get_create_params(), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)', + array( + 'args' => array( + 'font_family_id' => array( + 'description' => __( 'The ID for the parent font family of the font face.', 'gutenberg' ), + 'type' => 'integer', + 'required' => true, + ), + 'id' => array( + 'description' => __( 'Unique identifier for the font face.', 'gutenberg' ), + 'type' => 'integer', + 'required' => true, + ), + ), + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => array( $this, 'get_item_permissions_check' ), + 'args' => array( + 'context' => $this->get_context_param( array( 'default' => 'edit' ) ), + ), + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_item' ), + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), + 'args' => array( + 'force' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Whether to bypass Trash and force deletion.', 'default' ), + ), + ), + ), + 'schema' => array( $this, 'get_public_item_schema' ), + ) + ); + } + + /** + * Checks if a given request has access to font faces. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- required by parent class + $post_type = get_post_type_object( $this->post_type ); + + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font faces.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + return true; + } + + /** + * Checks if a given request has access to a font face. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_item_permissions_check( $request ) { + return $this->get_items_permissions_check( $request ); + } + + /** + * Validates settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return false|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_create_font_face_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings parameter must be a valid JSON string.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Check that the font face settings match the theme.json schema. + $schema = $this->get_item_schema()['properties']['font_face_settings']; + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_face_settings' ); + + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + $required = $schema['required']; + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Font family setting key. */ + sprintf( __( 'font_face_setting[%s] cannot be empty.', 'gutenberg' ), $key ), + array( 'status' => 400 ) + ); + } + } + + $srcs = is_array( $settings['src'] ) ? $settings['src'] : array( $settings['src'] ); + + // Check that srcs are non-empty strings. + $filtered_src = array_filter( array_filter( $srcs, 'is_string' ) ); + if ( empty( $filtered_src ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_face_settings[src] values must be non-empty strings.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Check that each file in the request references a src in the settings. + $files = $request->get_file_params(); + foreach ( array_keys( $files ) as $file ) { + if ( ! in_array( $file, $srcs, true ) ) { + return new WP_Error( + 'rest_invalid_param', + // translators: %s: File key (e.g. `file-0`) in the request data. + sprintf( __( 'File %1$s must be used in font_face_settings[src].', 'gutenberg' ), $file ), + array( 'status' => 400 ) + ); + } + } + + return true; + } + + /** + * Sanitizes the font face settings when creating a font face. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font face settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array of font face settings. + */ + public function sanitize_font_face_settings( $value ) { + // Settings arrive as stringified JSON, since this is a multipart/form-data request. + $settings = json_decode( $value, true ); + + if ( isset( $settings['fontFamily'] ) ) { + $settings['fontFamily'] = WP_Font_Family_Utils::format_font_family( $settings['fontFamily'] ); + } + + return $settings; + } + + /** + * Retrieves a collection of font faces within the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + return parent::get_items( $request ); + } + + /** + * Retrieves a single font face within the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + // Check that the font face has a valid parent font family. + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d"', 'gutenberg' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + return parent::get_item( $request ); + } + + /** + * Creates a font face for the parent font family. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function create_item( $request ) { + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + $file_params = $request->get_file_params(); + + // Check that the necessary font face properties are unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'title' => WP_Font_Family_Utils::get_font_face_slug( $settings ), + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_face', + __( 'A font face matching those settings already exists.', 'gutenberg' ), + array( 'status' => 400 ) + ); + } + + // Move the uploaded font asset from the temp folder to the fonts directory. + if ( ! function_exists( 'wp_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $srcs = is_string( $settings['src'] ) ? array( $settings['src'] ) : $settings['src']; + $processed_srcs = array(); + $font_file_meta = array(); + + foreach ( $srcs as $src ) { + // If src not a file reference, use it as is. + if ( ! isset( $file_params[ $src ] ) ) { + $processed_srcs[] = $src; + continue; + } + + $file = $file_params[ $src ]; + $font_file = $this->handle_font_file_upload( $file ); + if ( is_wp_error( $font_file ) ) { + return $font_file; + } + + $processed_srcs[] = $font_file['url']; + $font_file_meta[] = $this->relative_fonts_path( $font_file['file'] ); + } + + // Store the updated settings for prepare_item_for_database to use. + $settings['src'] = count( $processed_srcs ) === 1 ? $processed_srcs[0] : $processed_srcs; + $request->set_param( 'font_face_settings', $settings ); + + // Ensure that $settings data is slashed, so values with quotes are escaped. + // WP_REST_Posts_Controller::create_item uses wp_slash() on the post_content. + $font_face_post = parent::create_item( $request ); + + if ( is_wp_error( $font_face_post ) ) { + return $font_face_post; + } + + $font_face_id = $font_face_post->data['id']; + + foreach ( $font_file_meta as $font_file_path ) { + add_post_meta( $font_face_id, '_wp_font_face_file', $font_file_path ); + } + + return $font_face_post; + } + + /** + * Deletes a single font face. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function delete_item( $request ) { + $post = $this->get_post( $request['id'] ); + if ( is_wp_error( $post ) ) { + return $post; + } + + $font_family = $this->get_parent_font_family_post( $request['font_family_id'] ); + if ( is_wp_error( $font_family ) ) { + return $font_family; + } + + if ( (int) $font_family->ID !== (int) $post->post_parent ) { + return new WP_Error( + 'rest_font_face_parent_id_mismatch', + /* translators: %d: A post id. */ + sprintf( __( 'The font face does not belong to the specified font family with id of "%d"', 'gutenberg' ), $font_family->ID ), + array( 'status' => 404 ) + ); + } + + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; + + // We don't support trashing for font faces. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), + array( 'status' => 501 ) + ); + } + + return parent::delete_item( $request ); + } + + /** + * Prepares a single font face output for response. + * + * @since 6.5.0 + * + * @param WP_Post $item Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; + } + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'parent', $fields ) ) { + $data['parent'] = $item->post_parent; + } + + if ( rest_is_field_included( 'font_face_settings', $fields ) ) { + $data['font_face_settings'] = $this->get_settings_from_post( $item ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'edit'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font face data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font face post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_face', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'edit', 'embed' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'edit', 'embed' ), + ), + 'parent' => array( + 'description' => __( 'The ID for the parent font family of the font face.', 'gutenberg' ), + 'type' => 'integer', + 'context' => array( 'edit', 'embed' ), + ), + // Font face settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'edit', 'embed' ), + 'properties' => array( + 'fontFamily' => array( + 'description' => __( 'CSS font-family value.', 'gutenberg' ), + 'type' => 'string', + 'default' => '', + ), + 'fontStyle' => array( + 'description' => __( 'CSS font-style value.', 'gutenberg' ), + 'type' => 'string', + 'default' => 'normal', + ), + 'fontWeight' => array( + 'description' => __( 'List of available font weights, separated by a space.', 'gutenberg' ), + 'default' => '400', + // Changed from `oneOf` to avoid errors from loose type checking. + // e.g. a fontWeight of "400" validates as both a string and an integer due to is_numeric check. + 'type' => array( 'string', 'integer' ), + ), + 'fontDisplay' => array( + 'description' => __( 'CSS font-display value.', 'gutenberg' ), + 'type' => 'string', + 'default' => 'fallback', + 'enum' => array( + 'auto', + 'block', + 'fallback', + 'swap', + 'optional', + ), + ), + 'src' => array( + 'description' => __( 'Paths or URLs to the font files.', 'gutenberg' ), + // Changed from `oneOf` to `anyOf` due to rest_sanitize_array converting a string into an array. + 'anyOf' => array( + array( + 'type' => 'string', + ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'default' => array(), + ), + 'fontStretch' => array( + 'description' => __( 'CSS font-stretch value.', 'gutenberg' ), + 'type' => 'string', + ), + 'ascentOverride' => array( + 'description' => __( 'CSS ascent-override value.', 'gutenberg' ), + 'type' => 'string', + ), + 'descentOverride' => array( + 'description' => __( 'CSS descent-override value.', 'gutenberg' ), + 'type' => 'string', + ), + 'fontVariant' => array( + 'description' => __( 'CSS font-variant value.', 'gutenberg' ), + 'type' => 'string', + ), + 'fontFeatureSettings' => array( + 'description' => __( 'CSS font-feature-settings value.', 'gutenberg' ), + 'type' => 'string', + ), + 'fontVariationSettings' => array( + 'description' => __( 'CSS font-variation-settings value.', 'gutenberg' ), + 'type' => 'string', + ), + 'lineGapOverride' => array( + 'description' => __( 'CSS line-gap-override value.', 'gutenberg' ), + 'type' => 'string', + ), + 'sizeAdjust' => array( + 'description' => __( 'CSS size-adjust value.', 'gutenberg' ), + 'type' => 'string', + ), + 'unicodeRange' => array( + 'description' => __( 'CSS unicode-range value.', 'gutenberg' ), + 'type' => 'string', + ), + 'preview' => array( + 'description' => __( 'URL to a preview image of the font face.', 'gutenberg' ), + 'type' => 'string', + ), + ), + 'required' => array( 'fontFamily', 'src' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } + + /** + * Retrieves the query params for the font face collection. + * + * @since 6.5.0 + * + * @return array Collection parameters. + */ + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'edit'; + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['slug'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font face controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_face_collection_params', $query_params ); + } + + /** + * Get the params used when creating a new font face. + * + * @since 6.5.0 + * + * @return array Font face create arguments. + */ + public function get_create_params() { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating, font_face_settings is stringified JSON, to work with multipart/form-data used + // when uploading font files. + 'font_face_settings' => array( + 'description' => __( 'font-face declaration in theme.json format, encoded as a string.', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_create_font_face_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_face_settings' ), + ), + ); + } + + /** + * Get the parent font family, if the ID is valid. + * + * @since 6.5.0 + * + * @param int $font_family_id Supplied ID. + * @return WP_Post|WP_Error Post object if ID is valid, WP_Error otherwise. + */ + protected function get_parent_font_family_post( $font_family_id ) { + $error = new WP_Error( + 'rest_post_invalid_parent', + __( 'Invalid post parent ID.', 'default' ), + array( 'status' => 404 ) + ); + + if ( (int) $font_family_id <= 0 ) { + return $error; + } + + $font_family_post = get_post( (int) $font_family_id ); + + if ( empty( $font_family_post ) || empty( $font_family_post->ID ) + || 'wp_font_family' !== $font_family_post->post_type + ) { + return $error; + } + + return $font_family_post; + } + + /** + * Prepares links for the request. + * + * @since 6.5.0 + * + * @param WP_Post $post Post object. + * @return array Links for the given post. + */ + protected function prepare_links( $post ) { + // Entity meta. + $links = array( + 'self' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ), + ), + 'collection' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent . '/font-faces' ), + ), + 'parent' => array( + 'href' => rest_url( $this->namespace . '/font-families/' . $post->post_parent ), + ), + ); + + return $links; + } + + /** + * Prepares a single font face post for creation. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_post = new stdClass(); + + // Settings have already been decoded by ::sanitize_font_face_settings(). + $settings = $request->get_param( 'font_face_settings' ); + + // Store this "slug" as the post_title rather than post_name, since it uses the fontFamily setting, + // which may contain multibyte characters. + $title = WP_Font_Family_Utils::get_font_face_slug( $settings ); + + $prepared_post->post_type = $this->post_type; + $prepared_post->post_parent = $request['font_family_id']; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $title; + $prepared_post->post_name = sanitize_title( $title ); + $prepared_post->post_content = wp_json_encode( $settings ); + + return $prepared_post; + } + + /** + * Handles the upload of a font file using wp_handle_upload(). + * + * @since 6.5.0 + * + * @param array $file Single file item from $_FILES. + * @return array Array containing uploaded file attributes on success, or error on failure. + */ + protected function handle_font_file_upload( $file ) { + add_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); + + $overrides = array( + 'upload_error_handler' => array( $this, 'handle_font_file_upload_error' ), + // Arbitrary string to avoid the is_uploaded_file() check applied + // when using 'wp_handle_upload'. + 'action' => 'wp_handle_font_upload', + // Not testing a form submission. + 'test_form' => false, + // Seems mime type for files that are not images cannot be tested. + // See wp_check_filetype_and_ext(). + 'test_type' => true, + // Only allow uploading font files for this request. + 'mimes' => WP_Font_Library::get_expected_font_mime_types_per_php_version(), + ); + + $uploaded_file = wp_handle_upload( $file, $overrides ); + + remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); + + return $uploaded_file; + } + + /** + * Handles file upload error. + * + * @since 6.5.0 + * + * @param array $file File upload data. + * @param string $message Error message from wp_handle_upload(). + * @return WP_Error WP_Error object. + */ + public function handle_font_file_upload_error( $file, $message ) { + $status = 500; + $code = 'rest_font_upload_unknown_error'; + + if ( __( 'Sorry, you are not allowed to upload this file type.', 'default' ) === $message ) { + $status = 400; + $code = 'rest_font_upload_invalid_file_type'; + } + + return new WP_Error( $code, $message, array( 'status' => $status ) ); + } + + /** + * Returns relative path to an uploaded font file. + * + * The path is relative to the current fonts dir. + * + * @since 6.5.0 + * @access private + * + * @param string $path Full path to the file. + * @return string Relative path on success, unchanged path on failure. + */ + protected function relative_fonts_path( $path ) { + $new_path = $path; + + $fonts_dir = wp_get_font_dir(); + if ( str_starts_with( $new_path, $fonts_dir['path'] ) ) { + $new_path = str_replace( $fonts_dir, '', $new_path ); + $new_path = ltrim( $new_path, '/' ); + } + + return $new_path; + } + + /** + * Gets the font face's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font face post object. + * @return array Font face settings array. + */ + protected function get_settings_from_post( $post ) { + $settings = json_decode( $post->post_content, true ); + $properties = $this->get_item_schema()['properties']['font_face_settings']['properties']; + + // Provide required, empty settings if needed. + if ( null === $settings ) { + $settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + } + + // Only return the properties defined in the schema. + return array_intersect_key( $settings, $properties ); + } +} diff --git a/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php b/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php index ede8762c88c6d..887a8a5250cc3 100644 --- a/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php +++ b/lib/experimental/fonts/font-library/class-wp-rest-font-families-controller.php @@ -1,12 +1,10 @@ rest_base = 'font-families'; - $this->namespace = 'wp/v2'; - $this->post_type = 'wp_font_family'; - } + protected $allow_batch = false; /** - * Registers the routes for the objects of the controller. + * Checks if a given request has access to font families. * * @since 6.5.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ - public function register_routes() { - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), - ), - ) - ); + public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- required by parent class + $post_type = get_post_type_object( $this->post_type ); - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::EDITABLE, - 'callback' => array( $this, 'install_fonts' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), - 'args' => array( - 'font_family_settings' => array( - 'required' => true, - 'type' => 'string', - 'validate_callback' => array( $this, 'validate_install_font_families' ), - ), - ), - ), - ) - ); + if ( ! current_user_can( $post_type->cap->read ) ) { + return new WP_Error( + 'rest_cannot_read', + __( 'Sorry, you are not allowed to access font families.', 'gutenberg' ), + array( 'status' => rest_authorization_required_code() ) + ); + } - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::DELETABLE, - 'callback' => array( $this, 'uninstall_fonts' ), - 'permission_callback' => array( $this, 'update_font_library_permissions_check' ), - 'args' => $this->uninstall_schema(), - ), - ) - ); + return true; } /** - * Returns validation errors in font families data for installation. + * Checks if a given request has access to a font family. * * @since 6.5.0 * - * @param array[] $font_families Font families to install. - * @param array $files Files to install. - * @return array $error_messages Array of error messages. + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. */ - private function get_validation_errors( $font_family_settings, $files ) { - $error_messages = array(); + public function get_item_permissions_check( $request ) { + return $this->get_items_permissions_check( $request ); + } - if ( ! is_array( $font_family_settings ) ) { - $error_messages[] = __( 'font_family_settings should be a font family definition.', 'gutenberg' ); - return $error_messages; + /** + * Validates settings when creating or updating a font family. + * + * @since 6.5.0 + * + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return false|WP_Error True if the settings are valid, otherwise a WP_Error object. + */ + public function validate_font_family_settings( $value, $request ) { + $settings = json_decode( $value, true ); + + // Check settings string is valid JSON. + if ( null === $settings ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_family_settings parameter must be a valid JSON string.', 'gutenberg' ), + array( 'status' => 400 ) + ); } - if ( - ! isset( $font_family_settings['slug'] ) || - ! isset( $font_family_settings['name'] ) || - ! isset( $font_family_settings['fontFamily'] ) - ) { - $error_messages[] = __( 'Font family should have slug, name and fontFamily properties defined.', 'gutenberg' ); + $schema = $this->get_item_schema()['properties']['font_family_settings']; + $required = $schema['required']; - return $error_messages; - } + if ( isset( $request['id'] ) ) { + // Allow sending individual properties if we are updating an existing font family. + unset( $schema['required'] ); - if ( isset( $font_family_settings['fontFace'] ) ) { - if ( ! is_array( $font_family_settings['fontFace'] ) ) { - $error_messages[] = __( 'Font family should have fontFace property defined as an array.', 'gutenberg' ); + // But don't allow updating the slug, since it is used as a unique identifier. + if ( isset( $settings['slug'] ) ) { + return new WP_Error( + 'rest_invalid_param', + __( 'font_family_settings[slug] cannot be updated.', 'gutenberg' ), + array( 'status' => 400 ) + ); } + } - if ( count( $font_family_settings['fontFace'] ) < 1 ) { - $error_messages[] = __( 'Font family should have at least one font face definition.', 'gutenberg' ); - } + // Check that the font face settings match the theme.json schema. + $has_valid_settings = rest_validate_value_from_schema( $settings, $schema, 'font_family_settings' ); - if ( ! empty( $font_family_settings['fontFace'] ) ) { - for ( $face_index = 0; $face_index < count( $font_family_settings['fontFace'] ); $face_index++ ) { - - $font_face = $font_family_settings['fontFace'][ $face_index ]; - if ( ! isset( $font_face['fontWeight'] ) || ! isset( $font_face['fontStyle'] ) ) { - $error_messages[] = sprintf( - // translators: font face index. - __( 'Font family Font face [%1$s] should have fontWeight and fontStyle properties defined.', 'gutenberg' ), - $face_index - ); - } - - if ( isset( $font_face['downloadFromUrl'] ) && isset( $font_face['uploadedFile'] ) ) { - $error_messages[] = sprintf( - // translators: font face index. - __( 'Font family Font face [%1$s] should have only one of the downloadFromUrl or uploadedFile properties defined and not both.', 'gutenberg' ), - $face_index - ); - } - - if ( isset( $font_face['uploadedFile'] ) ) { - if ( ! isset( $files[ $font_face['uploadedFile'] ] ) ) { - $error_messages[] = sprintf( - // translators: font face index. - __( 'Font family Font face [%1$s] file is not defined in the request files.', 'gutenberg' ), - $face_index - ); - } - } - } + if ( is_wp_error( $has_valid_settings ) ) { + $has_valid_settings->add_data( array( 'status' => 400 ) ); + return $has_valid_settings; + } + + // Check that none of the required settings are empty values. + foreach ( $required as $key ) { + if ( isset( $settings[ $key ] ) && ! $settings[ $key ] ) { + return new WP_Error( + 'rest_invalid_param', + /* translators: %s: Font family setting key. */ + sprintf( __( 'font_family_settings[%s] cannot be empty.', 'gutenberg' ), $key ), + array( 'status' => 400 ) + ); } } - return $error_messages; + return true; } /** - * Validate input for the install endpoint. + * Sanitizes the font family settings when creating or updating a font family. * - * @since 6.5.0 + * @since 6.5.0 * - * @param string $param The font families to install. - * @param WP_REST_Request $request The request object. - * @return true|WP_Error True if the parameter is valid, WP_Error otherwise. + * @param string $value Encoded JSON string of font family settings. + * @param WP_REST_Request $request Request object. + * @return array Decoded array font family settings. */ - public function validate_install_font_families( $param, $request ) { - $font_family_settings = json_decode( $param, true ); - $files = $request->get_file_params(); - $error_messages = $this->get_validation_errors( $font_family_settings, $files ); + public function sanitize_font_family_settings( $value ) { + $settings = json_decode( $value, true ); - if ( empty( $error_messages ) ) { - return true; + if ( isset( $settings['fontFamily'] ) ) { + $settings['fontFamily'] = WP_Font_Family_Utils::format_font_family( $settings['fontFamily'] ); } - return new WP_Error( 'rest_invalid_param', implode( ', ', $error_messages ), array( 'status' => 400 ) ); + // Provide default for preview, if not provided. + if ( ! isset( $settings['preview'] ) ) { + $settings['preview'] = ''; + } + + return $settings; } /** - * Gets the schema for the uninstall endpoint. + * Creates a single font family. * * @since 6.5.0 * - * @return array Schema array. + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function uninstall_schema() { - return array( - 'font_families' => array( - 'type' => 'array', - 'description' => __( 'The font families to install.', 'gutenberg' ), - 'required' => true, - 'minItems' => 1, - 'items' => array( - 'required' => true, - 'type' => 'object', - 'properties' => array( - 'slug' => array( - 'type' => 'string', - 'description' => __( 'The font family slug.', 'gutenberg' ), - 'required' => true, - ), - ), - ), - ), + public function create_item( $request ) { + $settings = $request->get_param( 'font_family_settings' ); + + // Check that the font family slug is unique. + $query = new WP_Query( + array( + 'post_type' => $this->post_type, + 'posts_per_page' => 1, + 'name' => $settings['slug'], + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) ); + if ( ! empty( $query->get_posts() ) ) { + return new WP_Error( + 'rest_duplicate_font_family', + /* translators: %s: Font family slug. */ + sprintf( __( 'A font family with slug "%s" already exists.', 'gutenberg' ), $settings['slug'] ), + array( 'status' => 400 ) + ); + } + + return parent::create_item( $request ); } /** - * Removes font families from the Font Library and all their assets. + * Deletes a single font family. * * @since 6.5.0 * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ - public function uninstall_fonts( $request ) { - $fonts_to_uninstall = $request->get_param( 'font_families' ); + public function delete_item( $request ) { + $force = isset( $request['force'] ) ? (bool) $request['force'] : false; - $errors = array(); - $successes = array(); - - if ( empty( $fonts_to_uninstall ) ) { - $errors[] = new WP_Error( - 'no_fonts_to_install', - __( 'No fonts to uninstall', 'gutenberg' ) - ); - $data = array( - 'successes' => $successes, - 'errors' => $errors, + // We don't support trashing for font families. + if ( ! $force ) { + return new WP_Error( + 'rest_trash_not_supported', + /* translators: %s: force=true */ + sprintf( __( "Font faces do not support trashing. Set '%s' to delete.", 'gutenberg' ), 'force=true' ), + array( 'status' => 501 ) ); - $response = rest_ensure_response( $data ); - $response->set_status( 400 ); - return $response; } - foreach ( $fonts_to_uninstall as $font_data ) { - $font = new WP_Font_Family( $font_data ); - $result = $font->uninstall(); - if ( is_wp_error( $result ) ) { - $errors[] = $result; - } else { - $successes[] = $result; - } - } - $data = array( - 'successes' => $successes, - 'errors' => $errors, - ); - return rest_ensure_response( $data ); + return parent::delete_item( $request ); } /** - * Checks whether the user has permissions to update the Font Library. + * Prepares a single font family output for response. * * @since 6.5.0 * - * @return true|WP_Error True if the request has write access for the item, WP_Error object otherwise. + * @param WP_Post $item Post object. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response Response object. */ - public function update_font_library_permissions_check() { - if ( ! current_user_can( 'edit_theme_options' ) ) { - return new WP_Error( - 'rest_cannot_update_font_library', - __( 'Sorry, you are not allowed to update the Font Library on this site.', 'gutenberg' ), - array( - 'status' => rest_authorization_required_code(), - ) - ); + public function prepare_item_for_response( $item, $request ) { + $fields = $this->get_fields_for_response( $request ); + $data = array(); + + if ( rest_is_field_included( 'id', $fields ) ) { + $data['id'] = $item->ID; } - return true; + + if ( rest_is_field_included( 'theme_json_version', $fields ) ) { + $data['theme_json_version'] = 2; + } + + if ( rest_is_field_included( 'font_faces', $fields ) ) { + $data['font_faces'] = $this->get_font_face_ids( $item->ID ); + } + + if ( rest_is_field_included( 'font_family_settings', $fields ) ) { + $data['font_family_settings'] = $this->get_settings_from_post( $item ); + } + + $context = ! empty( $request['context'] ) ? $request['context'] : 'edit'; + $data = $this->add_additional_fields_to_object( $data, $request ); + $data = $this->filter_response_by_context( $data, $context ); + + $response = rest_ensure_response( $data ); + + if ( rest_is_field_included( '_links', $fields ) ) { + $links = $this->prepare_links( $item ); + $response->add_links( $links ); + } + + /** + * Filters the font family data for a REST API response. + * + * @since 6.5.0 + * + * @param WP_REST_Response $response The response object. + * @param WP_Post $post Font family post object. + * @param WP_REST_Request $request Request object. + */ + return apply_filters( 'rest_prepare_wp_font_family', $response, $item, $request ); + } + + /** + * Retrieves the post's schema, conforming to JSON Schema. + * + * @since 6.5.0 + * + * @return array Item schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->post_type, + 'type' => 'object', + // Base properties for every Post. + 'properties' => array( + 'id' => array( + 'description' => __( 'Unique identifier for the post.', 'default' ), + 'type' => 'integer', + 'context' => array( 'edit' ), + 'readonly' => true, + ), + 'theme_json_version' => array( + 'description' => __( 'Version of the theme.json schema used for the typography settings.', 'gutenberg' ), + 'type' => 'integer', + 'default' => 2, + 'minimum' => 2, + 'maximum' => 2, + 'context' => array( 'edit' ), + ), + 'font_faces' => array( + 'description' => __( 'The IDs of the child font faces in the font family.', 'gutenberg' ), + 'type' => 'array', + 'context' => array( 'edit' ), + 'items' => array( + 'type' => 'integer', + ), + ), + // Font family settings come directly from theme.json schema + // See https://schemas.wp.org/trunk/theme.json + 'font_family_settings' => array( + 'description' => __( 'font-face declaration in theme.json format.', 'gutenberg' ), + 'type' => 'object', + 'context' => array( 'edit' ), + 'properties' => array( + 'name' => array( + 'description' => 'Name of the font family preset, translatable.', + 'type' => 'string', + ), + 'slug' => array( + 'description' => 'Kebab-case unique identifier for the font family preset.', + 'type' => 'string', + ), + 'fontFamily' => array( + 'description' => 'CSS font-family value.', + 'type' => 'string', + ), + 'preview' => array( + 'description' => 'URL to a preview image of the font family.', + 'type' => 'string', + ), + ), + 'required' => array( 'name', 'slug', 'fontFamily' ), + 'additionalProperties' => false, + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); } /** - * Checks whether the font directory exists or not. + * Retrieves the query params for the font family collection. * * @since 6.5.0 * - * @return bool Whether the font directory exists. + * @return array Collection parameters. */ - private function has_upload_directory() { - $upload_dir = wp_get_font_dir()['path']; - return is_dir( $upload_dir ); + public function get_collection_params() { + $query_params = parent::get_collection_params(); + + $query_params['context']['default'] = 'edit'; + + // Remove unneeded params. + unset( $query_params['after'] ); + unset( $query_params['modified_after'] ); + unset( $query_params['before'] ); + unset( $query_params['modified_before'] ); + unset( $query_params['search'] ); + unset( $query_params['search_columns'] ); + unset( $query_params['status'] ); + + $query_params['orderby']['default'] = 'id'; + $query_params['orderby']['enum'] = array( 'id', 'include' ); + + /** + * Filters collection parameters for the font family controller. + * + * @since 6.5.0 + * + * @param array $query_params JSON Schema-formatted collection parameters. + */ + return apply_filters( 'rest_wp_font_family_collection_params', $query_params ); } /** - * Checks whether the user has write permissions to the temp and fonts directories. + * Retrieves the query params for the font family collection, defaulting to the 'edit' context. * * @since 6.5.0 * - * @return true|WP_Error True if the user has write permissions, WP_Error object otherwise. + * @param array $args Optional. Additional arguments for context parameter. Default empty array. + * @return array Context parameter details. */ - private function has_write_permission() { - // The update endpoints requires write access to the temp and the fonts directories. - $temp_dir = get_temp_dir(); - $upload_dir = wp_get_font_dir()['path']; - if ( ! is_writable( $temp_dir ) || ! wp_is_writable( $upload_dir ) ) { - return false; + public function get_context_param( $args = array() ) { + if ( isset( $args['default'] ) ) { + $args['default'] = 'edit'; } - return true; + return parent::get_context_param( $args ); } /** - * Checks whether the request needs write permissions. + * Get the arguments used when creating or updating a font family. * * @since 6.5.0 * - * @param array[] $font_family_settings Font family definition. - * @return bool Whether the request needs write permissions. + * @return array Font family create/edit arguments. */ - private function needs_write_permission( $font_family_settings ) { - if ( isset( $font_family_settings['fontFace'] ) ) { - foreach ( $font_family_settings['fontFace'] as $face ) { - // If the font is being downloaded from a URL or uploaded, it needs write permissions. - if ( isset( $face['downloadFromUrl'] ) || isset( $face['uploadedFile'] ) ) { - return true; - } - } + public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CREATABLE ) { + if ( WP_REST_Server::CREATABLE === $method || WP_REST_Server::EDITABLE === $method ) { + $properties = $this->get_item_schema()['properties']; + return array( + 'theme_json_version' => $properties['theme_json_version'], + // When creating or updating, font_family_settings is stringified JSON, to work with multipart/form-data. + // Font families don't currently support file uploads, but may accept preview files in the future. + 'font_family_settings' => array( + 'description' => __( 'font-family declaration in theme.json format, encoded as a string.', 'gutenberg' ), + 'type' => 'string', + 'required' => true, + 'validate_callback' => array( $this, 'validate_font_family_settings' ), + 'sanitize_callback' => array( $this, 'sanitize_font_family_settings' ), + ), + ); } - return false; + + return parent::get_endpoint_args_for_item_schema( $method ); } /** - * Installs new fonts. + * Get the child font face post IDs. * - * Takes a request containing new fonts to install, downloads their assets, and adds them - * to the Font Library. + * @since 6.5.0 + * + * @param int $font_family_id Font family post ID. + * @return int[] Array of child font face post IDs. + * . + */ + protected function get_font_face_ids( $font_family_id ) { + $query = new WP_Query( + array( + 'fields' => 'ids', + 'post_parent' => $font_family_id, + 'post_type' => 'wp_font_face', + 'posts_per_page' => 99, + 'order' => 'ASC', + 'orderby' => 'id', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + return $query->get_posts(); + } + + /** + * Prepares font family links for the request. * * @since 6.5.0 * - * @param WP_REST_Request $request The request object containing the new fonts to install - * in the request parameters. - * @return WP_REST_Response|WP_Error The updated Font Library post content. + * @param WP_Post $post Post object. + * @return array Links for the given post. */ - public function install_fonts( $request ) { - // Get new fonts to install. - $font_family_settings = $request->get_param( 'font_family_settings' ); - - /* - * As this is receiving form data, the font families are encoded as a string. - * The form data is used because local fonts need to use that format to - * attach the files in the request. - */ - $font_family_settings = json_decode( $font_family_settings, true ); + protected function prepare_links( $post ) { + // Entity meta. + $links = parent::prepare_links( $post ); - $successes = array(); - $errors = array(); - $response_status = 200; + return array( + 'self' => $links['self'], + 'collection' => $links['collection'], + 'font_faces' => $this->prepare_font_face_links( $post->ID ), + ); + } - if ( empty( $font_family_settings ) ) { - $errors[] = new WP_Error( - 'no_fonts_to_install', - __( 'No fonts to install', 'gutenberg' ) + /** + * Prepares child font face links for the request. + * + * @param int $font_family_id Font family post ID. + * @return array Links for the child font face posts. + */ + protected function prepare_font_face_links( $font_family_id ) { + $font_face_ids = $this->get_font_face_ids( $font_family_id ); + $links = array(); + foreach ( $font_face_ids as $font_face_id ) { + $links[] = array( + 'embeddable' => true, + 'href' => rest_url( $this->namespace . '/' . $this->rest_base . '/' . $font_family_id . '/font-faces/' . $font_face_id ), ); - $response_status = 400; } + return $links; + } - if ( $this->needs_write_permission( $font_family_settings ) ) { - $upload_dir = wp_get_font_dir()['path']; - if ( ! $this->has_upload_directory() ) { - if ( ! wp_mkdir_p( $upload_dir ) ) { - $errors[] = new WP_Error( - 'cannot_create_fonts_folder', - sprintf( - /* translators: %s: Directory path. */ - __( 'Error: Unable to create directory %s.', 'gutenberg' ), - esc_html( $upload_dir ) - ) - ); - $response_status = 500; - } + /** + * Prepares a single font family post for create or update. + * + * @since 6.5.0 + * + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Post object or WP_Error. + */ + protected function prepare_item_for_database( $request ) { + $prepared_post = new stdClass(); + // Settings have already been decoded by ::sanitize_font_family_settings(). + $settings = $request->get_param( 'font_family_settings' ); + + // This is an update and we merge with the existing font family. + if ( isset( $request['id'] ) ) { + $existing_post = $this->get_post( $request['id'] ); + if ( is_wp_error( $existing_post ) ) { + return $existing_post; } - if ( $this->has_upload_directory() && ! $this->has_write_permission() ) { - $errors[] = new WP_Error( - 'cannot_write_fonts_folder', - __( 'Error: WordPress does not have permission to write the fonts folder on your server.', 'gutenberg' ) - ); - $response_status = 500; - } + $prepared_post->ID = $existing_post->ID; + $existing_settings = $this->get_settings_from_post( $existing_post ); + $settings = array_merge( $existing_settings, $settings ); } - if ( ! empty( $errors ) ) { - $data = array( - 'successes' => $successes, - 'errors' => $errors, - ); - $response = rest_ensure_response( $data ); - $response->set_status( $response_status ); - return $response; - } + $prepared_post->post_type = $this->post_type; + $prepared_post->post_status = 'publish'; + $prepared_post->post_title = $settings['name']; + $prepared_post->post_name = sanitize_title( $settings['slug'] ); - // Get uploaded files (used when installing local fonts). - $files = $request->get_file_params(); - $font = new WP_Font_Family( $font_family_settings ); - $result = $font->install( $files ); - if ( is_wp_error( $result ) ) { - $errors[] = $result; - } else { - $successes[] = $result; - } + // Remove duplicate information from settings. + unset( $settings['name'] ); + unset( $settings['slug'] ); + + $prepared_post->post_content = wp_json_encode( $settings ); - $data = array( - 'successes' => $successes, - 'errors' => $errors, + return $prepared_post; + } + + /** + * Gets the font family's settings from the post. + * + * @since 6.5.0 + * + * @param WP_Post $post Font family post object. + * @return array Font family settings array. + */ + protected function get_settings_from_post( $post ) { + $settings_json = json_decode( $post->post_content, true ); + + // Default to empty strings if the settings are missing. + return array( + 'name' => isset( $post->post_title ) && $post->post_title ? $post->post_title : '', + 'slug' => isset( $post->post_name ) && $post->post_name ? $post->post_name : '', + 'fontFamily' => isset( $settings_json['fontFamily'] ) && $settings_json['fontFamily'] ? $settings_json['fontFamily'] : '', + 'preview' => isset( $settings_json['preview'] ) && $settings_json['preview'] ? $settings_json['preview'] : '', ); - return rest_ensure_response( $data ); } } diff --git a/lib/experimental/fonts/font-library/font-library.php b/lib/experimental/fonts/font-library/font-library.php index d1ad8e1447ad9..a6a324b17731c 100644 --- a/lib/experimental/fonts/font-library/font-library.php +++ b/lib/experimental/fonts/font-library/font-library.php @@ -22,16 +22,73 @@ function gutenberg_init_font_library_routes() { // @core-merge: This code will go into Core's `create_initial_post_types()`. $args = array( + 'labels' => array( + 'name' => __( 'Font Families', 'gutenberg' ), + 'singular_name' => __( 'Font Family', 'gutenberg' ), + ), 'public' => false, - '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ - 'label' => 'Font Family', + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'edit_theme_options', + 'read_post' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'edit_post' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_post' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => false, + 'query_var' => false, 'show_in_rest' => true, 'rest_base' => 'font-families', 'rest_controller_class' => 'WP_REST_Font_Families_Controller', - 'autosave_rest_controller_class' => 'WP_REST_Autosave_Font_Families_Controller', + // Disable autosave endpoints for font families. + 'autosave_rest_controller_class' => 'stdClass', ); register_post_type( 'wp_font_family', $args ); + register_post_type( + 'wp_font_face', + array( + 'labels' => array( + 'name' => __( 'Font Faces', 'gutenberg' ), + 'singular_name' => __( 'Font Face', 'gutenberg' ), + ), + 'public' => false, + '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ + 'hierarchical' => false, + 'capabilities' => array( + 'read' => 'edit_theme_options', + 'read_post' => 'edit_theme_options', + 'read_private_posts' => 'edit_theme_options', + 'create_posts' => 'edit_theme_options', + 'publish_posts' => 'edit_theme_options', + 'edit_post' => 'edit_theme_options', + 'edit_posts' => 'edit_theme_options', + 'edit_others_posts' => 'edit_theme_options', + 'edit_published_posts' => 'edit_theme_options', + 'delete_post' => 'edit_theme_options', + 'delete_posts' => 'edit_theme_options', + 'delete_others_posts' => 'edit_theme_options', + 'delete_published_posts' => 'edit_theme_options', + ), + 'map_meta_cap' => false, + 'query_var' => false, + 'show_in_rest' => true, + 'rest_base' => 'font-families/(?P[\d]+)/font-faces', + 'rest_controller_class' => 'WP_REST_Font_Faces_Controller', + // Disable autosave endpoints for font faces. + 'autosave_rest_controller_class' => 'stdClass', + ) + ); + // @core-merge: This code will go into Core's `create_initial_rest_routes()`. $font_collections_controller = new WP_REST_Font_Collections_Controller(); $font_collections_controller->register_routes(); @@ -79,7 +136,8 @@ function wp_unregister_font_collection( $collection_id ) { 'slug' => 'default-font-collection', 'name' => 'Google Fonts', 'description' => __( 'Add from Google Fonts. Fonts are copied to and served from your site.', 'gutenberg' ), - 'src' => 'https://s.w.org/images/fonts/16.7/collections/google-fonts-with-preview.json', + // TODO: This URL needs to be updated to the wporg hosted one prior to the Gutenberg 17.6 release. + 'src' => 'https://raw.githubusercontent.com/WordPress/google-fonts-to-wordpress-collection/main/releases/gutenberg-17.6/collections/google-fonts.json', ); wp_register_font_collection( $default_font_collection ); @@ -132,3 +190,141 @@ function wp_get_font_dir( $defaults = array() ) { return apply_filters( 'font_dir', $defaults ); } } + +// @core-merge: Filters should go in `src/wp-includes/default-filters.php`, +// functions in a general file for font library. +if ( ! function_exists( '_wp_after_delete_font_family' ) ) { + /** + * Deletes child font faces when a font family is deleted. + * + * @access private + * @since 6.5.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @return void + */ + function _wp_after_delete_font_family( $post_id, $post ) { + if ( 'wp_font_family' !== $post->post_type ) { + return; + } + + $font_faces = get_children( + array( + 'post_parent' => $post_id, + 'post_type' => 'wp_font_face', + ) + ); + + foreach ( $font_faces as $font_face ) { + wp_delete_post( $font_face->ID, true ); + } + } + add_action( 'deleted_post', '_wp_after_delete_font_family', 10, 2 ); +} + +if ( ! function_exists( '_wp_before_delete_font_face' ) ) { + /** + * Deletes associated font files when a font face is deleted. + * + * @access private + * @since 6.5.0 + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @return void + */ + function _wp_before_delete_font_face( $post_id, $post ) { + if ( 'wp_font_face' !== $post->post_type ) { + return; + } + + $font_files = get_post_meta( $post_id, '_wp_font_face_file', false ); + + foreach ( $font_files as $font_file ) { + wp_delete_file( wp_get_font_dir()['path'] . '/' . $font_file ); + } + } + add_action( 'before_delete_post', '_wp_before_delete_font_face', 10, 2 ); +} + +// @core-merge: Do not merge this back compat function, it is for supporting a legacy font family format only in Gutenberg. +/** + * Convert legacy font family posts to the new format. + * + * @return void + */ +function gutenberg_convert_legacy_font_family_format() { + if ( get_option( 'gutenberg_font_family_format_converted' ) ) { + return; + } + + $font_families = new WP_Query( + array( + 'post_type' => 'wp_font_family', + // Set a maximum, but in reality there will be far less than this. + 'posts_per_page' => 999, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + ) + ); + + foreach ( $font_families->get_posts() as $font_family ) { + $already_converted = get_post_meta( $font_family->ID, '_gutenberg_legacy_font_family', true ); + if ( $already_converted ) { + continue; + } + + // Stash the old font family content in a meta field just in case we need it. + update_post_meta( $font_family->ID, '_gutenberg_legacy_font_family', $font_family->post_content ); + + $font_family_json = json_decode( $font_family->post_content, true ); + if ( ! $font_family_json ) { + continue; + } + + $font_faces = $font_family_json['fontFace'] ?? array(); + unset( $font_family_json['fontFace'] ); + + // Save wp_font_face posts within the family. + foreach ( $font_faces as $font_face ) { + $args = array(); + $args['post_type'] = 'wp_font_face'; + $args['post_title'] = WP_Font_Family_Utils::get_font_face_slug( $font_face ); + $args['post_name'] = sanitize_title( $args['post_title'] ); + $args['post_status'] = 'publish'; + $args['post_parent'] = $font_family->ID; + $args['post_content'] = wp_json_encode( $font_face ); + + $font_face_id = wp_insert_post( wp_slash( $args ) ); + + $file_urls = (array) $font_face['src'] ?? array(); + + foreach ( $file_urls as $file_url ) { + // continue if the file is not local. + if ( false === strpos( $file_url, site_url() ) ) { + continue; + } + + $relative_path = basename( $file_url ); + update_post_meta( $font_face_id, '_wp_font_face_file', $relative_path ); + } + } + + // Update the font family post to remove the font face data. + $args = array(); + $args['ID'] = $font_family->ID; + $args['post_title'] = $font_family_json['name'] ?? ''; + $args['post_name'] = sanitize_title( $font_family_json['slug'] ); + + unset( $font_family_json['name'] ); + unset( $font_family_json['slug'] ); + + $args['post_content'] = wp_json_encode( $font_family_json ); + + wp_update_post( wp_slash( $args ) ); + } + + update_option( 'gutenberg_font_family_format_converted', true ); +} +add_action( 'init', 'gutenberg_convert_legacy_font_family_format' ); diff --git a/lib/load.php b/lib/load.php index 8c9c8532d573c..2dd33962e355b 100644 --- a/lib/load.php +++ b/lib/load.php @@ -145,8 +145,8 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/experimental/fonts/font-library/class-wp-font-family-utils.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-font-family.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-font-families-controller.php'; +require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-font-faces-controller.php'; require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-font-collections-controller.php'; -require __DIR__ . '/experimental/fonts/font-library/class-wp-rest-autosave-font-families-controller.php'; require __DIR__ . '/experimental/fonts/font-library/font-library.php'; // Load the Font Face and Font Face Resolver, if not already loaded by WordPress Core. diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/context.js b/packages/edit-site/src/components/global-styles/font-library-modal/context.js index 364742901bd5f..864d703f260b4 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/context.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/context.js @@ -9,13 +9,15 @@ import { useEntityRecords, store as coreStore, } from '@wordpress/core-data'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import { - fetchInstallFont, - fetchUninstallFonts, + fetchGetFontFamilyBySlug, + fetchInstallFontFamily, + fetchUninstallFontFamily, fetchFontCollections, fetchFontCollection, } from './resolvers'; @@ -26,10 +28,12 @@ import { mergeFontFamilies, loadFontFaceInBrowser, getDisplaySrcFromFontFace, - makeFormDataFromFontFamily, + makeFontFacesFormData, + makeFontFamilyFormData, + batchInstallFontFaces, + checkFontFaceInstalled, } from './utils'; import { toggleFont } from './utils/toggleFont'; -import getIntersectingFontFaces from './utils/get-intersecting-font-faces'; export const FontLibraryContext = createContext( {} ); @@ -60,12 +64,22 @@ function FontLibraryProvider( { children } ) { records: libraryPosts = [], isResolving: isResolvingLibrary, hasResolved: hasResolvedLibrary, - } = useEntityRecords( 'postType', 'wp_font_family', { refreshKey } ); + } = useEntityRecords( 'postType', 'wp_font_family', { + refreshKey, + _embed: true, + } ); const libraryFonts = - ( libraryPosts || [] ).map( ( post ) => - JSON.parse( post.content.raw ) - ) || []; + ( libraryPosts || [] ).map( ( fontFamilyPost ) => { + return { + id: fontFamilyPost.id, + ...fontFamilyPost.font_family_settings, + fontFace: + fontFamilyPost?._embedded?.font_faces.map( + ( face ) => face.font_face_settings + ) || [], + }; + } ) || []; // Global Styles (settings) font families const [ fontFamilies, setFontFamilies ] = useGlobalSetting( @@ -157,11 +171,12 @@ function FontLibraryProvider( { children } ) { const getAvailableFontsOutline = ( availableFontFamilies ) => { const outline = availableFontFamilies.reduce( ( acc, font ) => { - const availableFontFaces = Array.isArray( font?.fontFace ) - ? font?.fontFace.map( - ( face ) => `${ face.fontStyle + face.fontWeight }` - ) - : [ 'normal400' ]; // If the font doesn't have fontFace, we assume it is a system font and we add the defaults: normal 400 + const availableFontFaces = + font?.fontFace && font.fontFace?.length > 0 + ? font?.fontFace.map( + ( face ) => `${ face.fontStyle + face.fontWeight }` + ) + : [ 'normal400' ]; // If the font doesn't have fontFace, we assume it is a system font and we add the defaults: normal 400 acc[ font.slug ] = availableFontFaces; return acc; @@ -192,44 +207,133 @@ function FontLibraryProvider( { children } ) { return getActivatedFontsOutline( source )[ slug ] || []; }; - async function installFont( font ) { + async function installFont( fontFamilyToInstall ) { setIsInstalling( true ); try { - // Prepare formData to install. - const formData = makeFormDataFromFontFamily( font ); + // Get the font family if it already exists. + let installedFontFamily = await fetchGetFontFamilyBySlug( + fontFamilyToInstall.slug + ); + + // Otherwise create it. + if ( ! installedFontFamily ) { + // Prepare font family form data to install. + installedFontFamily = await fetchInstallFontFamily( + makeFontFamilyFormData( fontFamilyToInstall ) + ); + } + + // Collect font faces that have already been installed (to be activated later) + const alreadyInstalledFontFaces = + installedFontFamily.fontFace && fontFamilyToInstall.fontFace + ? installedFontFamily.fontFace.filter( + ( fontFaceToInstall ) => + checkFontFaceInstalled( + fontFaceToInstall, + fontFamilyToInstall.fontFace + ) + ) + : []; + + // Filter out Font Faces that have already been installed (so that they are not re-installed) + if ( + installedFontFamily.fontFace && + fontFamilyToInstall.fontFace + ) { + fontFamilyToInstall.fontFace = + fontFamilyToInstall.fontFace.filter( + ( fontFaceToInstall ) => + ! checkFontFaceInstalled( + fontFaceToInstall, + installedFontFamily.fontFace + ) + ); + } + // Install the fonts (upload the font files to the server and create the post in the database). - const response = await fetchInstallFont( formData ); - const fontsInstalled = response?.successes || []; - // Get intersecting font faces between the fonts we tried to installed and the fonts that were installed - // (to avoid activating a non installed font). - const fontToBeActivated = getIntersectingFontFaces( - fontsInstalled, - [ font ] + let sucessfullyInstalledFontFaces = []; + let unsucessfullyInstalledFontFaces = []; + if ( fontFamilyToInstall?.fontFace?.length > 0 ) { + const response = await batchInstallFontFaces( + installedFontFamily.id, + makeFontFacesFormData( fontFamilyToInstall ) + ); + sucessfullyInstalledFontFaces = response?.successes; + unsucessfullyInstalledFontFaces = response?.errors; + } + + const detailedErrorMessage = unsucessfullyInstalledFontFaces.reduce( + ( errorMessageCollection, error ) => { + return `${ errorMessageCollection } ${ error.message }`; + }, + '' ); - // Activate the font families (add the font families to the global styles). - activateCustomFontFamilies( fontToBeActivated ); + + // If there were no successes and nothing already installed then we don't need to activate anything and can bounce now. + if ( + fontFamilyToInstall?.fontFace?.length > 0 && + sucessfullyInstalledFontFaces.length === 0 && + alreadyInstalledFontFaces.length === 0 + ) { + throw new Error( + sprintf( + /* translators: %s: Specific error message returned from server. */ + __( 'No font faces were installed. %s' ), + detailedErrorMessage + ) + ); + } + + // Use the sucessfully installed font faces + // As well as any font faces that were already installed (those will be activated) + if ( + sucessfullyInstalledFontFaces?.length > 0 || + alreadyInstalledFontFaces?.length > 0 + ) { + fontFamilyToInstall.fontFace = [ + ...sucessfullyInstalledFontFaces, + ...alreadyInstalledFontFaces, + ]; + } + + // Activate the font family (add the font family to the global styles). + activateCustomFontFamilies( [ fontFamilyToInstall ] ); + // Save the global styles to the database. saveSpecifiedEntityEdits( 'root', 'globalStyles', globalStylesId, [ 'settings.typography.fontFamilies', ] ); + refreshLibrary(); - return response; - } catch ( error ) { - return { - errors: [ error ], - }; + + if ( unsucessfullyInstalledFontFaces.length > 0 ) { + throw new Error( + sprintf( + /* translators: %s: Specific error message returned from server. */ + __( + 'Some font faces were installed. There were some errors. %s' + ), + detailedErrorMessage + ) + ); + } } finally { setIsInstalling( false ); } } - async function uninstallFont( font ) { + async function uninstallFontFamily( fontFamilyToUninstall ) { try { - // Uninstall the font (remove the font files from the server and the post from the database). - const response = await fetchUninstallFonts( [ font ] ); - // Deactivate the font family (remove the font family from the global styles). - if ( 0 === response.errors.length ) { - deactivateFontFamily( font ); + // Uninstall the font family. + // (Removes the font files from the server and the posts from the database). + const uninstalledFontFamily = await fetchUninstallFontFamily( + fontFamilyToUninstall.id + ); + + // Deactivate the font family if delete request is successful + // (Removes the font family from the global styles). + if ( uninstalledFontFamily.deleted ) { + deactivateFontFamily( fontFamilyToUninstall ); // Save the global styles to the database. await saveSpecifiedEntityEdits( 'root', @@ -238,15 +342,18 @@ function FontLibraryProvider( { children } ) { [ 'settings.typography.fontFamilies' ] ); } - // Refresh the library (the the library font families from database). + + // Refresh the library (the library font families from database). refreshLibrary(); - return response; + + return uninstalledFontFamily; } catch ( error ) { // eslint-disable-next-line no-console - console.error( error ); - return { - errors: [ error ], - }; + console.error( + `There was an error uninstalling the font family:`, + error + ); + throw error; } } @@ -321,16 +428,16 @@ function FontLibraryProvider( { children } ) { const response = await fetchFontCollections(); setFontCollections( response ); }; - const getFontCollection = async ( id ) => { + const getFontCollection = async ( slug ) => { try { const hasData = !! collections.find( - ( collection ) => collection.id === id - )?.data; + ( collection ) => collection.slug === slug + )?.font_families; if ( hasData ) return; - const response = await fetchFontCollection( id ); + const response = await fetchFontCollection( slug ); const updatedCollections = collections.map( ( collection ) => - collection.id === id - ? { ...collection, data: { ...response?.data } } + collection.slug === slug + ? { ...collection, ...response } : collection ); setFontCollections( updatedCollections ); @@ -358,7 +465,7 @@ function FontLibraryProvider( { children } ) { getFontFacesActivated, loadFontFaceAsset, installFont, - uninstallFont, + uninstallFontFamily, toggleActivateFont, getAvailableFontsOutline, modalTabOpen, diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js index f7f33032f1e3f..46363365363bb 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/font-collection.js @@ -30,15 +30,14 @@ import CollectionFontDetails from './collection-font-details'; import { toggleFont } from './utils/toggleFont'; import { getFontsOutline } from './utils/fonts-outline'; import GoogleFontsConfirmDialog from './google-fonts-confirm-dialog'; -import { getNoticeFromInstallResponse } from './utils/get-notice-from-response'; import { downloadFontFaceAsset } from './utils'; const DEFAULT_CATEGORY = { - id: 'all', + slug: 'all', name: __( 'All' ), }; -function FontCollection( { id } ) { - const requiresPermission = id === 'default-font-collection'; +function FontCollection( { slug } ) { + const requiresPermission = slug === 'default-font-collection'; const getGoogleFontsPermissionFromStorage = () => { return ( @@ -58,7 +57,7 @@ function FontCollection( { id } ) { const { collections, getFontCollection, installFont } = useContext( FontLibraryContext ); const selectedCollection = collections.find( - ( collection ) => collection.id === id + ( collection ) => collection.slug === slug ); useEffect( () => { @@ -70,12 +69,12 @@ function FontCollection( { id } ) { handleStorage(); window.addEventListener( 'storage', handleStorage ); return () => window.removeEventListener( 'storage', handleStorage ); - }, [ id, requiresPermission ] ); + }, [ slug, requiresPermission ] ); useEffect( () => { const fetchFontCollection = async () => { try { - await getFontCollection( id ); + await getFontCollection( slug ); resetFilters(); } catch ( e ) { setNotice( { @@ -86,12 +85,12 @@ function FontCollection( { id } ) { } }; fetchFontCollection(); - }, [ id, getFontCollection ] ); + }, [ slug, getFontCollection ] ); useEffect( () => { setSelectedFont( null ); setNotice( null ); - }, [ id ] ); + }, [ slug ] ); useEffect( () => { // If the selected fonts change, reset the selected fonts to install @@ -109,10 +108,10 @@ function FontCollection( { id } ) { }, [ notice ] ); const collectionFonts = useMemo( - () => selectedCollection?.data?.fontFamilies ?? [], + () => selectedCollection?.font_families ?? [], [ selectedCollection ] ); - const collectionCategories = selectedCollection?.data?.categories ?? []; + const collectionCategories = selectedCollection?.categories ?? []; const categories = [ DEFAULT_CATEGORY, ...collectionCategories ]; @@ -161,11 +160,10 @@ function FontCollection( { id } ) { if ( fontFamily?.fontFace ) { await Promise.all( fontFamily.fontFace.map( async ( fontFace ) => { - if ( fontFace.downloadFromUrl ) { + if ( fontFace.src ) { fontFace.file = await downloadFontFaceAsset( - fontFace.downloadFromUrl + fontFace.src ); - delete fontFace.downloadFromUrl; } } ) ); @@ -182,9 +180,18 @@ function FontCollection( { id } ) { return; } - const response = await installFont( fontFamily ); - const installNotice = getNoticeFromInstallResponse( response ); - setNotice( installNotice ); + try { + await installFont( fontFamily ); + setNotice( { + type: 'success', + message: __( 'Fonts were installed successfully.' ), + } ); + } catch ( error ) { + setNotice( { + type: 'error', + message: error.message, + } ); + } resetFontsToInstall(); }; @@ -256,8 +263,8 @@ function FontCollection( { id } ) { { categories && categories.map( ( category ) => ( @@ -269,11 +276,11 @@ function FontCollection( { id } ) { { ! renderConfirmDialog && - ! selectedCollection?.data?.fontFamilies && + ! selectedCollection?.font_families && ! notice && } { ! renderConfirmDialog && - !! selectedCollection?.data?.fontFamilies?.length && + !! selectedCollection?.font_families?.length && ! fonts.length && ( { __( @@ -294,10 +301,10 @@ function FontCollection( { id } ) { { fonts.map( ( font ) => ( { - setSelectedFont( font ); + setSelectedFont( font.font_family_settings ); } } /> ) ) } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/fonts-grid.js b/packages/edit-site/src/components/global-styles/font-library-modal/fonts-grid.js index 55e7a8a5cf393..9700831a7adef 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/fonts-grid.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/fonts-grid.js @@ -42,9 +42,13 @@ function FontsGrid( { title, children, pageSize = 32 } ) {
{ items.map( ( child, i ) => { if ( i === itemsLimit - 1 ) { - return
{ child }
; + return ( +
+ { child } +
+ ); } - return child; + return
{ child }
; } ) }
diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/index.js b/packages/edit-site/src/components/global-styles/font-library-modal/index.js index 65a284560687c..a68c42ec01041 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/index.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/index.js @@ -31,10 +31,10 @@ const DEFAULT_TABS = [ ]; const tabsFromCollections = ( collections ) => - collections.map( ( { id, name } ) => ( { - id, + collections.map( ( { slug, name } ) => ( { + id: slug, title: - collections.length === 1 && id === 'default-font-collection' + collections.length === 1 && slug === 'default-font-collection' ? __( 'Install Fonts' ) : name, } ) ); @@ -76,7 +76,7 @@ function FontLibraryModal( { contents = ; break; default: - contents = ; + contents = ; } return ( { - const response = await uninstallFont( libraryFontSelected ); - const uninstallNotice = getNoticeFromUninstallResponse( response ); - setNotice( uninstallNotice ); - // If the font was succesfully uninstalled it is unselected - if ( ! response?.errors?.length ) { + try { + await uninstallFontFamily( libraryFontSelected ); + setNotice( { + type: 'success', + message: __( 'Font family uninstalled successfully.' ), + } ); + + // If the font was succesfully uninstalled it is unselected. handleUnselectFont(); + setIsConfirmDeleteOpen( false ); + } catch ( error ) { + setNotice( { + type: 'error', + message: + __( 'There was an error uninstalling the font family. ' ) + + error.message, + } ); } - setIsConfirmDeleteOpen( false ); }; const handleUninstallClick = async () => { diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-card.js b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-card.js index 16454595dc7f2..e37a28a5c9528 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-card.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-card.js @@ -13,7 +13,8 @@ import { FontLibraryContext } from './context'; function LibraryFontCard( { font, ...props } ) { const { getFontFacesActivated } = useContext( FontLibraryContext ); - const variantsInstalled = font.fontFace?.length || 1; + const variantsInstalled = + font?.fontFace?.length > 0 ? font.fontFace.length : 1; const variantsActive = getFontFacesActivated( font.slug, font.source diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js index d74a5f74f019b..94fee2852478e 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/library-font-variant.js @@ -20,17 +20,18 @@ function LibraryFontVariant( { face, font } ) { const { isFontActivated, toggleActivateFont } = useContext( FontLibraryContext ); - const isIstalled = font?.fontFace - ? isFontActivated( - font.slug, - face.fontStyle, - face.fontWeight, - font.source - ) - : isFontActivated( font.slug, null, null, font.source ); + const isIstalled = + font?.fontFace?.length > 0 + ? isFontActivated( + font.slug, + face.fontStyle, + face.fontWeight, + font.source + ) + : isFontActivated( font.slug, null, null, font.source ); const handleToggleActivation = () => { - if ( font?.fontFace ) { + if ( font?.fontFace?.length > 0 ) { toggleActivateFont( font, face ); return; } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/local-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/local-fonts.js index d4221b420cb61..145f4164a8760 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/local-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/local-fonts.js @@ -23,7 +23,6 @@ import { FontLibraryContext } from './context'; import { Font } from '../../../../lib/lib-font.browser'; import makeFamiliesFromFaces from './utils/make-families-from-faces'; import { loadFontFaceInBrowser } from './utils'; -import { getNoticeFromInstallResponse } from './utils/get-notice-from-response'; import { unlock } from '../../../lock-unlock'; const { ProgressBar } = unlock( componentsPrivateApis ); @@ -161,12 +160,23 @@ function LocalFonts() { 'Variants from only one font family can be uploaded at a time.' ), } ); + setIsUploading( false ); return; } - const response = await installFont( fontFamilies[ 0 ] ); - const installNotice = getNoticeFromInstallResponse( response ); - setNotice( installNotice ); + try { + await installFont( fontFamilies[ 0 ] ); + setNotice( { + type: 'success', + message: __( 'Fonts were installed successfully.' ), + } ); + } catch ( error ) { + setNotice( { + type: 'error', + message: error.message, + } ); + } + setIsUploading( false ); }; diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/resolvers.js b/packages/edit-site/src/components/global-styles/font-library-modal/resolvers.js index 2e7f413a6fa45..a75fc6cbe78ff 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/resolvers.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/resolvers.js @@ -1,45 +1,78 @@ -/** - * WordPress dependencies - * - */ /** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; -export async function fetchInstallFont( data ) { +const FONT_FAMILIES_URL = '/wp/v2/font-families'; +const FONT_COLLECTIONS_URL = '/wp/v2/font-collections'; + +export async function fetchInstallFontFamily( data ) { const config = { - path: '/wp/v2/font-families', + path: FONT_FAMILIES_URL, method: 'POST', body: data, }; - return apiFetch( config ); + const response = await apiFetch( config ); + return { + id: response.id, + ...response.font_family_settings, + fontFace: [], + }; } -export async function fetchUninstallFonts( fonts ) { - const data = { - font_families: fonts, +export async function fetchInstallFontFace( fontFamilyId, data ) { + const config = { + path: `${ FONT_FAMILIES_URL }/${ fontFamilyId }/font-faces`, + method: 'POST', + body: data, + }; + const response = await apiFetch( config ); + return { + id: response.id, + ...response.font_face_settings, }; +} + +export async function fetchGetFontFamilyBySlug( slug ) { + const config = { + path: `${ FONT_FAMILIES_URL }?slug=${ slug }&_embed=true`, + method: 'GET', + }; + const response = await apiFetch( config ); + if ( ! response || response.length === 0 ) { + return null; + } + const fontFamilyPost = response[ 0 ]; + return { + id: fontFamilyPost.id, + ...fontFamilyPost.font_family_settings, + fontFace: + fontFamilyPost?._embedded?.font_faces.map( + ( face ) => face.font_face_settings + ) || [], + }; +} + +export async function fetchUninstallFontFamily( fontFamilyId ) { const config = { - path: '/wp/v2/font-families', + path: `${ FONT_FAMILIES_URL }/${ fontFamilyId }?force=true`, method: 'DELETE', - data, }; - return apiFetch( config ); + return await apiFetch( config ); } export async function fetchFontCollections() { const config = { - path: '/wp/v2/font-collections', + path: FONT_COLLECTIONS_URL, method: 'GET', }; - return apiFetch( config ); + return await apiFetch( config ); } export async function fetchFontCollection( id ) { const config = { - path: `/wp/v2/font-collections/${ id }`, + path: `${ FONT_COLLECTIONS_URL }/${ id }`, method: 'GET', }; - return apiFetch( config ); + return await apiFetch( config ); } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss index d026563d3b73e..99599d179f019 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/style.scss +++ b/packages/edit-site/src/components/global-styles/font-library-modal/style.scss @@ -51,6 +51,7 @@ .font-library-modal__font-card { border: 1px solid #e5e5e5; + width: 100%; height: auto; padding: 1rem; margin-top: -1px; /* To collapse the margin with the previous element */ diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/filter-fonts.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/filter-fonts.js index 7348eb6b05497..e493a70197ace 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/filter-fonts.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/filter-fonts.js @@ -1,16 +1,33 @@ +/** + * Filters a list of fonts based on the specified filters. + * + * This function filters a given array of fonts based on the criteria provided in the filters object. + * It supports filtering by category and a search term. If the category is provided and not equal to 'all', + * the function filters the fonts array to include only those fonts that belong to the specified category. + * Additionally, if a search term is provided, it filters the fonts array to include only those fonts + * whose name includes the search term, case-insensitively. + * + * @param {Array} fonts Array of font objects in font-collection schema fashion to be filtered. Each font object should have a 'categories' property and a 'font_family_settings' property with a 'name' key. + * @param {Object} filters Object containing the filter criteria. It should have a 'category' key and/or a 'search' key. + * The 'category' key is a string representing the category to filter by. + * The 'search' key is a string representing the search term to filter by. + * @return {Array} Array of filtered font objects based on the provided criteria. + */ export default function filterFonts( fonts, filters ) { const { category, search } = filters; let filteredFonts = fonts || []; if ( category && category !== 'all' ) { filteredFonts = filteredFonts.filter( - ( font ) => font.category === category + ( font ) => font.categories.indexOf( category ) !== -1 ); } if ( search ) { filteredFonts = filteredFonts.filter( ( font ) => - font.name.toLowerCase().includes( search.toLowerCase() ) + font.font_family_settings.name + .toLowerCase() + .includes( search.toLowerCase() ) ); } diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/get-intersecting-font-faces.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/get-intersecting-font-faces.js deleted file mode 100644 index e21e72c58ed53..0000000000000 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/get-intersecting-font-faces.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Retrieves intersecting font faces between two sets of fonts. - * - * For each font in the `incoming` list, the function checks for a corresponding match - * in the `existing` list based on the `slug` property. If a match is found and both - * have `fontFace` properties, it further narrows down to matching font faces based on - * the `fontWeight` and `fontStyle`. The result includes the properties of the matched - * existing font but only with intersecting font faces. - * - * @param {Array.<{ slug: string, fontFace?: Array.<{ fontWeight: string, fontStyle: string }> }>} incoming - The list of fonts to compare. - * @param {Array.<{ slug: string, fontFace?: Array.<{ fontWeight: string, fontStyle: string }> }>} existing - The reference list of fonts. - * - * @return {Array.<{ slug: string, fontFace?: Array.<{ fontWeight: string, fontStyle: string }> }>} An array of fonts from the `existing` list with intersecting font faces. - * - * @example - * const incomingFonts = [ - * { slug: 'arial', fontFace: [{ fontWeight: '400', fontStyle: 'normal' }] }, - * { slug: 'times-new', fontFace: [{ fontWeight: '700', fontStyle: 'italic' }] } - * ]; - * - * const existingFonts = [ - * { slug: 'arial', fontFace: [{ fontWeight: '400', fontStyle: 'normal' }, { fontWeight: '700', fontStyle: 'italic' }] }, - * { slug: 'helvetica', fontFace: [{ fontWeight: '400', fontStyle: 'normal' }] } - * ]; - * - * getIntersectingFontFaces(incomingFonts, existingFonts); - * // Returns: - * // [{ slug: 'arial', fontFace: [{ fontWeight: '400', fontStyle: 'normal' }] }] - */ -export default function getIntersectingFontFaces( incoming, existing ) { - const matches = []; - - for ( const incomingFont of incoming ) { - const existingFont = existing.find( - ( f ) => f.slug === incomingFont.slug - ); - - if ( existingFont ) { - if ( incomingFont?.fontFace ) { - const matchingFaces = incomingFont.fontFace.filter( - ( face ) => { - return ( existingFont?.fontFace || [] ).find( ( f ) => { - return ( - f.fontWeight === face.fontWeight && - f.fontStyle === face.fontStyle - ); - } ); - } - ); - matches.push( { ...incomingFont, fontFace: matchingFaces } ); - } else { - matches.push( incomingFont ); - } - } - } - - return matches; -} diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/get-notice-from-response.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/get-notice-from-response.js deleted file mode 100644 index b22bd0afe2324..0000000000000 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/get-notice-from-response.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; - -export function getNoticeFromInstallResponse( response ) { - const { errors = [], successes = [] } = response; - // Everything failed. - if ( errors.length && ! successes.length ) { - return { - type: 'error', - message: __( 'Error installing the fonts.' ), - }; - } - - // Eveerything succeeded. - if ( ! errors.length && successes.length ) { - return { - type: 'success', - message: __( 'Fonts were installed successfully.' ), - }; - } - - // Some succeeded, some failed. - if ( errors.length && successes.length ) { - return { - type: 'warning', - message: __( - 'Some fonts were installed successfully and some failed.' - ), - }; - } -} - -export function getNoticeFromUninstallResponse( response ) { - const { errors = [], successes = [] } = response; - // Everything failed. - if ( errors.length && ! successes.length ) { - return { - type: 'error', - message: __( 'Error uninstalling the fonts.' ), - }; - } - - // Everything succeeded. - if ( ! errors.length && successes.length ) { - return { - type: 'success', - message: __( 'Fonts were uninstalled successfully.' ), - }; - } - - // Some succeeded, some failed. - if ( errors.length && successes.length ) { - return { - type: 'warning', - message: __( - 'Some fonts were uninstalled successfully and some failed.' - ), - }; - } -} diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js index 0aa0f7edb4aec..677b04f7e83d4 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/index.js @@ -8,6 +8,8 @@ import { privateApis as componentsPrivateApis } from '@wordpress/components'; */ import { FONT_WEIGHTS, FONT_STYLES } from './constants'; import { unlock } from '../../../../lock-unlock'; +import { fetchInstallFontFace } from '../resolvers'; +import { formatFontFamily } from './preview-styles'; /** * Browser dependencies @@ -92,13 +94,18 @@ export async function loadFontFaceInBrowser( fontFace, source, addTo = 'all' ) { // eslint-disable-next-line no-undef } else if ( source instanceof File ) { dataSource = await source.arrayBuffer(); + } else { + return; } - // eslint-disable-next-line no-undef - const newFont = new FontFace( fontFace.fontFamily, dataSource, { - style: fontFace.fontStyle, - weight: fontFace.fontWeight, - } ); + const newFont = new window.FontFace( + formatFontFamily( fontFace.fontFamily ), + dataSource, + { + style: fontFace.fontStyle, + weight: fontFace.fontWeight, + } + ); const loadedFace = await newFont.load(); @@ -135,39 +142,84 @@ export function getDisplaySrcFromFontFace( input, urlPrefix ) { return src; } -export function makeFormDataFromFontFamily( fontFamily ) { +export function makeFontFamilyFormData( fontFamily ) { const formData = new FormData(); const { kebabCase } = unlock( componentsPrivateApis ); - const newFontFamily = { - ...fontFamily, + const { fontFace, category, ...familyWithValidParameters } = fontFamily; + const fontFamilySettings = { + ...familyWithValidParameters, slug: kebabCase( fontFamily.slug ), }; - if ( newFontFamily?.fontFace ) { - const newFontFaces = newFontFamily.fontFace.map( - ( face, faceIndex ) => { - if ( face.file ) { - // Slugified file name because the it might contain spaces or characters treated differently on the server. - const fileId = `file-${ faceIndex }`; - // Add the files to the formData - formData.append( fileId, face.file, face.file.name ); - // remove the file object from the face object the file is referenced by the uploadedFile key - const { file, ...faceWithoutFileProperty } = face; - const newFace = { - ...faceWithoutFileProperty, - uploadedFile: fileId, - }; - return newFace; - } - return face; + formData.append( + 'font_family_settings', + JSON.stringify( fontFamilySettings ) + ); + return formData; +} + +export function makeFontFacesFormData( font ) { + if ( font?.fontFace ) { + const fontFacesFormData = font.fontFace.map( ( face, faceIndex ) => { + const formData = new FormData(); + if ( face.file ) { + // Slugified file name because the it might contain spaces or characters treated differently on the server. + const fileId = `file-${ faceIndex }`; + // Add the files to the formData + formData.append( fileId, face.file, face.file.name ); + // remove the file object from the face object the file is referenced in src + const { file, ...faceWithoutFileProperty } = face; + const fontFaceSettings = { + ...faceWithoutFileProperty, + src: fileId, + }; + formData.append( + 'font_face_settings', + JSON.stringify( fontFaceSettings ) + ); + } else { + formData.append( 'font_face_settings', JSON.stringify( face ) ); } - ); - newFontFamily.fontFace = newFontFaces; + return formData; + } ); + + return fontFacesFormData; } +} - formData.append( 'font_family_settings', JSON.stringify( newFontFamily ) ); - return formData; +export async function batchInstallFontFaces( fontFamilyId, fontFacesData ) { + const promises = fontFacesData.map( ( faceData ) => + fetchInstallFontFace( fontFamilyId, faceData ) + ); + const responses = await Promise.allSettled( promises ); + + const results = { + errors: [], + successes: [], + }; + + responses.forEach( ( result, index ) => { + if ( result.status === 'fulfilled' ) { + const response = result.value; + if ( response.id ) { + results.successes.push( response ); + } else { + results.errors.push( { + data: fontFacesData[ index ], + message: `Error: ${ response.message }`, + } ); + } + } else { + // Handle network errors or other fetch-related errors + results.errors.push( { + data: fontFacesData[ index ], + message: `Fetch error: ${ result.reason.message }`, + } ); + } + } ); + + return results; } /* @@ -199,3 +251,23 @@ export async function downloadFontFaceAsset( url ) { throw error; } ); } + +/* + * Determine if a given Font Face is present in a given collection. + * We determine that a font face has been installed by comparing the fontWeight and fontStyle + * + * @param {Object} fontFace The Font Face to seek + * @param {Array} collection The Collection to seek in + * @returns True if the font face is found in the collection. Otherwise False. + */ +export function checkFontFaceInstalled( fontFace, collection ) { + return ( + -1 !== + collection.findIndex( ( collectionFontFace ) => { + return ( + collectionFontFace.fontWeight === fontFace.fontWeight && + collectionFontFace.fontStyle === fontFace.fontStyle + ); + } ) + ); +} diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/preview-styles.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/preview-styles.js index b47ffb781f048..389cebde9249a 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/preview-styles.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/preview-styles.js @@ -35,9 +35,13 @@ export function formatFontFamily( input ) { .split( ',' ) .map( ( font ) => { font = font.trim(); // Remove any leading or trailing white spaces - // If the font doesn't have single quotes and contains a space, then add single quotes around it - if ( ! font.startsWith( "'" ) && font.indexOf( ' ' ) !== -1 ) { - return `'${ font }'`; + // If the font doesn't start with quotes and contains a space, then wrap in quotes. + // Check that string starts with a single or double quote and not a space + if ( + ! ( font.startsWith( '"' ) || font.startsWith( "'" ) ) && + font.indexOf( ' ' ) !== -1 + ) { + return `"${ font }"`; } return font; // Return font as is if no transformation is needed } ) diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/filter-fonts.spec.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/filter-fonts.spec.js index 4b171691d49d8..cd32e2b68f9cd 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/filter-fonts.spec.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/filter-fonts.spec.js @@ -5,11 +5,26 @@ import filterFonts from '../filter-fonts'; describe( 'filterFonts', () => { const mockFonts = [ - { name: 'Arial', category: 'sans-serif' }, - { name: 'Arial Condensed', category: 'sans-serif' }, - { name: 'Times New Roman', category: 'serif' }, - { name: 'Courier New', category: 'monospace' }, - { name: 'Romantic', category: 'cursive' }, + { + font_family_settings: { name: 'Arial' }, + categories: [ 'sans-serif' ], + }, + { + font_family_settings: { name: 'Arial Condensed' }, + categories: [ 'sans-serif' ], + }, + { + font_family_settings: { name: 'Times New Roman' }, + categories: [ 'serif' ], + }, + { + font_family_settings: { name: 'Courier New' }, + categories: [ 'monospace' ], + }, + { + font_family_settings: { name: 'Romantic' }, + categories: [ 'cursive' ], + }, ]; it( 'should return all fonts if no filters are provided', () => { @@ -20,7 +35,10 @@ describe( 'filterFonts', () => { it( 'should filter by category', () => { const result = filterFonts( mockFonts, { category: 'serif' } ); expect( result ).toEqual( [ - { name: 'Times New Roman', category: 'serif' }, + { + font_family_settings: { name: 'Times New Roman' }, + categories: [ 'serif' ], + }, ] ); } ); @@ -32,15 +50,24 @@ describe( 'filterFonts', () => { it( 'should filter by search', () => { const result = filterFonts( mockFonts, { search: 'ari' } ); expect( result ).toEqual( [ - { name: 'Arial', category: 'sans-serif' }, - { name: 'Arial Condensed', category: 'sans-serif' }, + { + font_family_settings: { name: 'Arial' }, + categories: [ 'sans-serif' ], + }, + { + font_family_settings: { name: 'Arial Condensed' }, + categories: [ 'sans-serif' ], + }, ] ); } ); it( 'should be case insensitive when filtering by search', () => { const result = filterFonts( mockFonts, { search: 'RoMANtic' } ); expect( result ).toEqual( [ - { name: 'Romantic', category: 'cursive' }, + { + font_family_settings: { name: 'Romantic' }, + categories: [ 'cursive' ], + }, ] ); } ); @@ -50,7 +77,10 @@ describe( 'filterFonts', () => { search: 'Times', } ); expect( result ).toEqual( [ - { name: 'Times New Roman', category: 'serif' }, + { + font_family_settings: { name: 'Times New Roman' }, + categories: [ 'serif' ], + }, ] ); } ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getIntersectingFontFaces.spec.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getIntersectingFontFaces.spec.js deleted file mode 100644 index 9899005ad65b8..0000000000000 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/getIntersectingFontFaces.spec.js +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Internal dependencies - */ -import getIntersectingFontFaces from '../get-intersecting-font-faces'; - -describe( 'getIntersectingFontFaces', () => { - it( 'returns matching font faces for matching font family', () => { - const incomingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const existingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - - expect( result ).toEqual( incomingFontFamilies ); - } ); - - it( 'returns empty array when there is no match', () => { - const incomingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const existingFontFamilies = [ - { - slug: 'montserrat', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - - expect( result ).toEqual( [] ); - } ); - - it( 'returns matching font faces', () => { - const incomingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - { - fontWeight: '700', - fontStyle: 'italic', - }, - ], - }, - { - slug: 'times', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const existingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - { - fontWeight: '800', - fontStyle: 'italic', - }, - { - fontWeight: '900', - fontStyle: 'italic', - }, - ], - }, - ]; - - const expectedOutput = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - - expect( result ).toEqual( expectedOutput ); - } ); - - it( 'returns empty array when the first list is empty', () => { - const incomingFontFamilies = []; - - const existingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - - expect( result ).toEqual( [] ); - } ); - - it( 'returns empty array when the second list is empty', () => { - const incomingFontFamilies = [ - { - slug: 'lobster', - fontFace: [ - { - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }, - ]; - - const existingFontFamilies = []; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - - expect( result ).toEqual( [] ); - } ); - - it( 'returns intersecting font family when there are no fonfaces', () => { - const incomingFontFamilies = [ - { - slug: 'piazzolla', - fontFace: [ { fontStyle: 'normal', fontWeight: '400' } ], - }, - { - slug: 'lobster', - }, - ]; - - const existingFontFamilies = [ - { - slug: 'lobster', - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - - expect( result ).toEqual( existingFontFamilies ); - } ); - - it( 'returns intersecting if there is an intended font face and is not present in the returning it should not be returned', () => { - const incomingFontFamilies = [ - { - slug: 'piazzolla', - fontFace: [ { fontStyle: 'normal', fontWeight: '400' } ], - }, - { - slug: 'lobster', - fontFace: [ { fontStyle: 'normal', fontWeight: '400' } ], - }, - ]; - - const existingFontFamilies = [ - { - slug: 'lobster', - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - const expected = [ - { - slug: 'lobster', - fontFace: [], - }, - ]; - expect( result ).toEqual( expected ); - } ); - - it( 'updates font family definition using the incoming data', () => { - const incomingFontFamilies = [ - { - slug: 'gothic-a1', - fontFace: [ { fontStyle: 'normal', fontWeight: '400' } ], - fontFamily: "'Gothic A1', serif", - }, - ]; - - const existingFontFamilies = [ - { - slug: 'gothic-a1', - fontFace: [ { fontStyle: 'normal', fontWeight: '400' } ], - fontFamily: 'Gothic A1, serif', - }, - ]; - - const result = getIntersectingFontFaces( - incomingFontFamilies, - existingFontFamilies - ); - const expected = [ - { - slug: 'gothic-a1', - fontFace: [ { fontStyle: 'normal', fontWeight: '400' } ], - fontFamily: "'Gothic A1', serif", - }, - ]; - expect( result ).toEqual( expected ); - } ); -} ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/makeFormDataFromFontFamily.spec.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/makeFormDataFromFontFamily.spec.js deleted file mode 100644 index 9f38903c89759..0000000000000 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/makeFormDataFromFontFamily.spec.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Internal dependencies - */ -import { makeFormDataFromFontFamily } from '../index'; - -/* global File */ - -describe( 'makeFormDataFromFontFamily', () => { - it( 'should process fontFamilies and return FormData', () => { - const mockFontFamily = { - slug: 'bebas', - name: 'Bebas', - fontFamily: 'Bebas', - fontFace: [ - { - file: new File( [ 'content' ], 'test-font1.woff2' ), - fontWeight: '500', - fontStyle: 'normal', - }, - { - file: new File( [ 'content' ], 'test-font2.woff2' ), - fontWeight: '400', - fontStyle: 'normal', - }, - ], - }; - - const formData = makeFormDataFromFontFamily( mockFontFamily ); - - expect( formData instanceof FormData ).toBeTruthy(); - - // Check if files are added correctly - expect( formData.get( 'file-0' ).name ).toBe( 'test-font1.woff2' ); - expect( formData.get( 'file-1' ).name ).toBe( 'test-font2.woff2' ); - - // Check if 'fontFamilies' key in FormData is correct - const expectedFontFamily = { - fontFace: [ - { - fontWeight: '500', - fontStyle: 'normal', - uploadedFile: 'file-0', - }, - { - fontWeight: '400', - fontStyle: 'normal', - uploadedFile: 'file-1', - }, - ], - slug: 'bebas', - name: 'Bebas', - fontFamily: 'Bebas', - }; - expect( JSON.parse( formData.get( 'font_family_settings' ) ) ).toEqual( - expectedFontFamily - ); - } ); -} ); diff --git a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/preview-styles.spec.js b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/preview-styles.spec.js index f9f789a61fd6c..0273709502a43 100644 --- a/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/preview-styles.spec.js +++ b/packages/edit-site/src/components/global-styles/font-library-modal/utils/test/preview-styles.spec.js @@ -123,13 +123,13 @@ describe( 'getFamilyPreviewStyle', () => { describe( 'formatFontFamily', () => { it( 'should transform "Baloo 2, system-ui" correctly', () => { expect( formatFontFamily( 'Baloo 2, system-ui' ) ).toBe( - "'Baloo 2', system-ui" + '"Baloo 2", system-ui' ); } ); it( 'should ignore extra spaces', () => { expect( formatFontFamily( ' Baloo 2 , system-ui' ) ).toBe( - "'Baloo 2', system-ui" + '"Baloo 2", system-ui' ); } ); @@ -144,18 +144,18 @@ describe( 'formatFontFamily', () => { } ); it( 'should wrap single font name with spaces in quotes', () => { - expect( formatFontFamily( 'Baloo 2' ) ).toBe( "'Baloo 2'" ); + expect( formatFontFamily( 'Baloo 2' ) ).toBe( '"Baloo 2"' ); } ); it( 'should wrap multiple font names with spaces in quotes', () => { expect( formatFontFamily( 'Baloo Bhai 2, Baloo 2' ) ).toBe( - "'Baloo Bhai 2', 'Baloo 2'" + '"Baloo Bhai 2", "Baloo 2"' ); } ); it( 'should wrap only those font names with spaces which are not already quoted', () => { expect( formatFontFamily( 'Baloo Bhai 2, Arial' ) ).toBe( - "'Baloo Bhai 2', Arial" + '"Baloo Bhai 2", Arial' ); } ); } ); diff --git a/phpunit/data/fonts/OpenSans-Regular.otf b/phpunit/data/fonts/OpenSans-Regular.otf new file mode 100644 index 0000000000000..8db0f64c67ddd Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.otf differ diff --git a/phpunit/data/fonts/OpenSans-Regular.ttf b/phpunit/data/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000000000..ae716936e9e4c Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.ttf differ diff --git a/phpunit/data/fonts/OpenSans-Regular.woff b/phpunit/data/fonts/OpenSans-Regular.woff new file mode 100644 index 0000000000000..bd0f824b207d6 Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.woff differ diff --git a/phpunit/data/fonts/OpenSans-Regular.woff2 b/phpunit/data/fonts/OpenSans-Regular.woff2 new file mode 100644 index 0000000000000..f778f9c8455f2 Binary files /dev/null and b/phpunit/data/fonts/OpenSans-Regular.woff2 differ diff --git a/phpunit/tests/fonts/font-library/fontFamilyBackwardsCompatibility.php b/phpunit/tests/fonts/font-library/fontFamilyBackwardsCompatibility.php new file mode 100644 index 0000000000000..a971bd5123430 --- /dev/null +++ b/phpunit/tests/fonts/font-library/fontFamilyBackwardsCompatibility.php @@ -0,0 +1,164 @@ +create_font_family( $legacy_content ); + + gutenberg_convert_legacy_font_family_format(); + + $font_family = get_post( $font_family_id ); + $font_faces = $this->get_font_faces( $font_family_id ); + + list( $font_face1, $font_face2, $font_face3 ) = $font_faces; + + // Updated font family post. + $this->assertSame( 'wp_font_family', $font_family->post_type ); + $this->assertSame( 'publish', $font_family->post_status ); + + $font_family_title = 'Open Sans'; + $this->assertSame( $font_family_title, $font_family->post_title ); + + $font_family_slug = 'open-sans'; + $this->assertSame( $font_family_slug, $font_family->post_name ); + + $font_family_content = wp_json_encode( json_decode( '{"fontFamily":"\'Open Sans\', sans-serif","preview":"https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans.svg"}', true ) ); + $this->assertSame( $font_family_content, $font_family->post_content ); + + $meta = get_post_meta( $font_family_id, '_gutenberg_legacy_font_family', true ); + $this->assertSame( $legacy_content, $meta ); + + // First font face post. + $this->assertSame( 'wp_font_face', $font_face1->post_type ); + $this->assertSame( $font_family_id, $font_face1->post_parent ); + $this->assertSame( 'publish', $font_face1->post_status ); + + $font_face1_title = 'open sans;normal;400;100%;U+0-10FFFF'; + $this->assertSame( $font_face1_title, $font_face1->post_title ); + $this->assertSame( sanitize_title( $font_face1_title ), $font_face1->post_name ); + + $font_face1_content = wp_json_encode( json_decode( '{"fontFamily":"Open Sans","fontStyle":"normal","fontWeight":"400","preview":"https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg","src":"https://fonts.gstatic.com/s/opensans/v35/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0C4nY1M2xLER.ttf"}' ) ); + $this->assertSame( $font_face1_content, $font_face1->post_content ); + + // With a remote url, file post meta should not be set. + $meta = get_post_meta( $font_face1->ID, '_wp_font_face_file', true ); + $this->assertSame( '', $meta ); + + // Second font face post. + $this->assertSame( 'wp_font_face', $font_face2->post_type ); + $this->assertSame( $font_family_id, $font_face2->post_parent ); + $this->assertSame( 'publish', $font_face2->post_status ); + + $font_face2_title = 'open sans;italic;400;100%;U+0-10FFFF'; + $this->assertSame( $font_face2_title, $font_face2->post_title ); + $this->assertSame( sanitize_title( $font_face2_title ), $font_face2->post_name ); + + $font_face2_content = wp_json_encode( json_decode( '{"fontFamily":"Open Sans","fontStyle":"italic","fontWeight":"400","preview":"https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-italic.svg","src":"https://fonts.gstatic.com/s/opensans/v35/memQYaGs126MiZpBA-UFUIcVXSCEkx2cmqvXlWq8tWZ0Pw86hd0Rk8ZkaVcUwaERZjA.ttf"}' ) ); + $this->assertSame( $font_face2_content, $font_face2->post_content ); + + // With a remote url, file post meta should not be set. + $meta = get_post_meta( $font_face2->ID, '_wp_font_face_file', true ); + $this->assertSame( '', $meta ); + + // Third font face post. + $this->assertSame( 'wp_font_face', $font_face3->post_type ); + $this->assertSame( $font_family_id, $font_face3->post_parent ); + $this->assertSame( 'publish', $font_face3->post_status ); + + $font_face3_title = 'open sans;normal;700;100%;U+0-10FFFF'; + $this->assertSame( $font_face3_title, $font_face3->post_title ); + $this->assertSame( sanitize_title( $font_face3_title ), $font_face3->post_name ); + + $font_face3_content = wp_json_encode( json_decode( '{"fontFamily":"Open Sans","fontStyle":"normal","fontWeight":"700","preview":"https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-700-normal.svg","src":"https://fonts.gstatic.com/s/opensans/v35/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsg-1y4nY1M2xLER.ttf"}' ) ); + $this->assertSame( $font_face3_content, $font_face3->post_content ); + + // With a remote url, file post meta should not be set. + $meta = get_post_meta( $font_face3->ID, '_wp_font_face_file', true ); + $this->assertSame( '', $meta ); + + wp_delete_post( $font_family_id, true ); + wp_delete_post( $font_face1->ID, true ); + wp_delete_post( $font_face2->ID, true ); + wp_delete_post( $font_face3->ID, true ); + } + + public function test_font_faces_with_local_src() { + $legacy_content = '{"fontFace":[{"fontFamily":"Open Sans","fontStyle":"normal","fontWeight":"400","preview":"https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg","src":"' . site_url() . '/wp-content/fonts/open-sans_normal_400.ttf"}],"fontFamily":"\'Open Sans\', sans-serif","name":"Open Sans","preview":"https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans.svg","slug":"open-sans"}'; + + $font_family_id = $this->create_font_family( $legacy_content ); + + gutenberg_convert_legacy_font_family_format(); + + $font_faces = $this->get_font_faces( $font_family_id ); + $this->assertCount( 1, $font_faces ); + $font_face = reset( $font_faces ); + + // Check that file meta is present. + $file_path = 'open-sans_normal_400.ttf'; + $meta = get_post_meta( $font_face->ID, '_wp_font_face_file', true ); + $this->assertSame( $file_path, $meta ); + + wp_delete_post( $font_family_id, true ); + wp_delete_post( $font_face->ID, true ); + } + + public function test_migration_only_runs_once() { + $legacy_content = '{"fontFace":[],"fontFamily":"\'Open Sans\', sans-serif","name":"Open Sans","preview":"","slug":"open-sans"}'; + + // Simulate that the migration has already run. + update_option( 'gutenberg_font_family_format_converted', true ); + + $font_family_id = $this->create_font_family( $legacy_content ); + + gutenberg_convert_legacy_font_family_format(); + + // Meta with backup content will not be present if migration isn't triggered. + $meta = get_post_meta( $font_family_id, '_gutenberg_legacy_font_family', true ); + $this->assertSame( '', $meta ); + + wp_delete_post( $font_family_id, true ); + } + + protected function create_font_family( $content ) { + return wp_insert_post( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_title' => 'Open Sans', + 'post_name' => 'open-sans', + 'post_content' => $content, + ) + ); + } + + protected function get_font_faces( $font_family_id ) { + return get_posts( + array( + 'post_parent' => $font_family_id, + 'post_type' => 'wp_font_face', + 'order' => 'ASC', + 'orderby' => 'id', + ) + ); + } +} diff --git a/phpunit/tests/fonts/font-library/fontLibraryHooks.php b/phpunit/tests/fonts/font-library/fontLibraryHooks.php new file mode 100644 index 0000000000000..2c471e2a9759c --- /dev/null +++ b/phpunit/tests/fonts/font-library/fontLibraryHooks.php @@ -0,0 +1,85 @@ +post->create( + array( + 'post_type' => 'wp_font_family', + ) + ); + $font_face_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => $font_family_id, + ) + ); + $other_font_family_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_family', + ) + ); + $other_font_face_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => $other_font_family_id, + ) + ); + + wp_delete_post( $font_family_id, true ); + + $this->assertNull( get_post( $font_face_id ) ); + $this->assertNotNull( get_post( $other_font_face_id ) ); + } + + public function test_deleting_font_faces_deletes_associated_font_files() { + list( $font_face_id, $font_path ) = $this->create_font_face_with_file( 'OpenSans-Regular.woff2' ); + list( , $other_font_path ) = $this->create_font_face_with_file( 'OpenSans-Regular.ttf' ); + + wp_delete_post( $font_face_id, true ); + + $this->assertFalse( file_exists( $font_path ) ); + $this->assertTrue( file_exists( $other_font_path ) ); + } + + protected function create_font_face_with_file( $filename ) { + $font_face_id = self::factory()->post->create( + array( + 'post_type' => 'wp_font_face', + ) + ); + + $font_file = $this->upload_font_file( $filename ); + + // Make sure the font file uploaded successfully. + $this->assertFalse( $font_file['error'] ); + + $font_path = $font_file['file']; + $font_filename = basename( $font_path ); + add_post_meta( $font_face_id, '_wp_font_face_file', $font_filename ); + + return array( $font_face_id, $font_path ); + } + + protected function upload_font_file( $font_filename ) { + // @core-merge Use `DIR_TESTDATA` instead of `GUTENBERG_DIR_TESTDATA`. + $font_file_path = GUTENBERG_DIR_TESTDATA . 'fonts/' . $font_filename; + + add_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); + add_filter( 'upload_dir', 'wp_get_font_dir' ); + $font_file = wp_upload_bits( + $font_filename, + null, + file_get_contents( $font_file_path ) + ); + remove_filter( 'upload_dir', 'wp_get_font_dir' ); + remove_filter( 'upload_mimes', array( 'WP_Font_Library', 'set_allowed_mime_types' ) ); + + return $font_file; + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php b/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php index 380226ee8af8a..8e9cf7bca08e5 100644 --- a/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php +++ b/phpunit/tests/fonts/font-library/wpFontCollection/__construct.php @@ -13,32 +13,50 @@ class Tests_Fonts_WpFontCollection_Construct extends WP_UnitTestCase { public function test_should_initialize_data() { - $property = new ReflectionProperty( WP_Font_Collection::class, 'config' ); - $property->setAccessible( true ); + $slug = new ReflectionProperty( WP_Font_Collection::class, 'slug' ); + $slug->setAccessible( true ); - $config = array( + $name = new ReflectionProperty( WP_Font_Collection::class, 'name' ); + $name->setAccessible( true ); + + $description = new ReflectionProperty( WP_Font_Collection::class, 'description' ); + $description->setAccessible( true ); + + $src = new ReflectionProperty( WP_Font_Collection::class, 'src' ); + $src->setAccessible( true ); + + $config = array( 'slug' => 'my-collection', 'name' => 'My Collection', 'description' => 'My collection description', 'src' => 'my-collection-data.json', ); - $font_collection = new WP_Font_Collection( $config ); + $collection = new WP_Font_Collection( $config ); - $actual = $property->getValue( $font_collection ); - $property->setAccessible( false ); + $actual_slug = $slug->getValue( $collection ); + $this->assertSame( 'my-collection', $actual_slug, 'Provided slug and initialized slug should match.' ); + $slug->setAccessible( false ); - $this->assertSame( $config, $actual ); + $actual_name = $name->getValue( $collection ); + $this->assertSame( 'My Collection', $actual_name, 'Provided name and initialized name should match.' ); + $name->setAccessible( false ); + + $actual_description = $description->getValue( $collection ); + $this->assertSame( 'My collection description', $actual_description, 'Provided description and initialized description should match.' ); + $description->setAccessible( false ); + + $actual_src = $src->getValue( $collection ); + $this->assertSame( 'my-collection-data.json', $actual_src, 'Provided src and initialized src should match.' ); + $src->setAccessible( false ); } /** - * @dataProvider data_should_throw_exception + * @dataProvider data_should_do_ti_wrong * * @param mixed $config Config of the font collection. - * @param string $expected_exception_message Expected exception message. */ - public function test_should_throw_exception( $config, $expected_exception_message ) { - $this->expectException( 'Exception' ); - $this->expectExceptionMessage( $expected_exception_message ); + public function test_should_do_ti_wrong( $config ) { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::is_config_valid' ); new WP_Font_Collection( $config ); } @@ -47,7 +65,7 @@ public function test_should_throw_exception( $config, $expected_exception_messag * * @return array */ - public function data_should_throw_exception() { + public function data_should_do_ti_wrong() { return array( 'no id' => array( array( @@ -55,27 +73,22 @@ public function data_should_throw_exception() { 'description' => 'My collection description', 'src' => 'my-collection-data.json', ), - 'Font Collection config slug is required as a non-empty string.', ), 'no config' => array( '', - 'Font Collection config options is required as a non-empty array.', ), 'empty array' => array( array(), - 'Font Collection config options is required as a non-empty array.', ), 'boolean instead of config array' => array( false, - 'Font Collection config options is required as a non-empty array.', ), 'null instead of config array' => array( null, - 'Font Collection config options is required as a non-empty array.', ), 'missing src' => array( @@ -84,9 +97,7 @@ public function data_should_throw_exception() { 'name' => 'My Collection', 'description' => 'My collection description', ), - 'Font Collection config "src" option OR "data" option is required.', ), - ); } } diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php b/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php index 5f1f082297d41..393de7d22614d 100644 --- a/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php +++ b/phpunit/tests/fonts/font-library/wpFontCollection/getConfig.php @@ -32,7 +32,7 @@ public function data_should_get_config() { file_put_contents( $mock_file, '{"this is mock data":true}' ); return array( - 'with a file' => array( + 'with a file' => array( 'config' => array( 'slug' => 'my-collection', 'name' => 'My Collection', @@ -45,7 +45,7 @@ public function data_should_get_config() { 'description' => 'My collection description', ), ), - 'with a url' => array( + 'with a url' => array( 'config' => array( 'slug' => 'my-collection-with-url', 'name' => 'My Collection with URL', @@ -58,12 +58,12 @@ public function data_should_get_config() { 'description' => 'My collection description', ), ), - 'with data' => array( + 'with font_families' => array( 'config' => array( - 'slug' => 'my-collection', - 'name' => 'My Collection', - 'description' => 'My collection description', - 'data' => array( 'this is mock data' => true ), + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'font_families' => array( array() ), ), 'expected_data' => array( 'slug' => 'my-collection', diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/getConfigAndData.php b/phpunit/tests/fonts/font-library/wpFontCollection/getContent.php similarity index 52% rename from phpunit/tests/fonts/font-library/wpFontCollection/getConfigAndData.php rename to phpunit/tests/fonts/font-library/wpFontCollection/getContent.php index 885b0a0b9036c..ab0e87cde000e 100644 --- a/phpunit/tests/fonts/font-library/wpFontCollection/getConfigAndData.php +++ b/phpunit/tests/fonts/font-library/wpFontCollection/getContent.php @@ -1,6 +1,6 @@ 'mock', - 'categories' => 'mock', + 'font_families' => array( 'mock' ), + 'categories' => array( 'mock' ), ); return array( @@ -47,14 +47,14 @@ public function mock_request( $preempt, $args, $url ) { } /** - * @dataProvider data_should_get_config_and_data + * @dataProvider data_should_get_content * * @param array $config Font collection config options. - * @param array $expected_data Expected data. + * @param array $expected_data Expected output data. */ - public function test_should_get_config_and_data( $config, $expected_data ) { + public function test_should_get_content( $config, $expected_data ) { $collection = new WP_Font_Collection( $config ); - $this->assertSame( $expected_data, $collection->get_config_and_data() ); + $this->assertSame( $expected_data, $collection->get_content() ); } /** @@ -62,12 +62,12 @@ public function test_should_get_config_and_data( $config, $expected_data ) { * * @return array[] */ - public function data_should_get_config_and_data() { + public function data_should_get_content() { $mock_file = wp_tempnam( 'my-collection-data-' ); - file_put_contents( $mock_file, '{"this is mock data":true}' ); + file_put_contents( $mock_file, '{"font_families":[ "mock" ], "categories":[ "mock" ] }' ); return array( - 'with a file' => array( + 'with a file' => array( 'config' => array( 'slug' => 'my-collection', 'name' => 'My Collection', @@ -75,13 +75,11 @@ public function data_should_get_config_and_data() { 'src' => $mock_file, ), 'expected_data' => array( - 'slug' => 'my-collection', - 'name' => 'My Collection', - 'description' => 'My collection description', - 'data' => array( 'this is mock data' => true ), + 'font_families' => array( 'mock' ), + 'categories' => array( 'mock' ), ), ), - 'with a url' => array( + 'with a url' => array( 'config' => array( 'slug' => 'my-collection-with-url', 'name' => 'My Collection with URL', @@ -89,27 +87,33 @@ public function data_should_get_config_and_data() { 'src' => 'https://localhost/fonts/mock-font-collection.json', ), 'expected_data' => array( - 'slug' => 'my-collection-with-url', - 'name' => 'My Collection with URL', - 'description' => 'My collection description', - 'data' => array( - 'fontFamilies' => 'mock', - 'categories' => 'mock', - ), + 'font_families' => array( 'mock' ), + 'categories' => array( 'mock' ), ), ), - 'with data' => array( + 'with font_families and categories' => array( 'config' => array( - 'slug' => 'my-collection', - 'name' => 'My Collection', - 'description' => 'My collection description', - 'data' => array( 'this is mock data' => true ), + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'font_families' => array( 'mock' ), + 'categories' => array( 'mock' ), ), 'expected_data' => array( - 'slug' => 'my-collection', - 'name' => 'My Collection', - 'description' => 'My collection description', - 'data' => array( 'this is mock data' => true ), + 'font_families' => array( 'mock' ), + 'categories' => array( 'mock' ), + ), + ), + 'with font_families without categories' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'font_families' => array( 'mock' ), + ), + 'expected_data' => array( + 'font_families' => array( 'mock' ), + 'categories' => array(), ), ), ); diff --git a/phpunit/tests/fonts/font-library/wpFontCollection/isConfigValid.php b/phpunit/tests/fonts/font-library/wpFontCollection/isConfigValid.php new file mode 100644 index 0000000000000..7cfdfc829ab86 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontCollection/isConfigValid.php @@ -0,0 +1,103 @@ +assertTrue( WP_Font_Collection::is_config_valid( $config ) ); + } + + public function data_is_config_valid() { + return array( + 'with src' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'src' => 'my-collection-data.json', + ), + ), + 'with font families' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'font_families' => array( 'mock' ), + ), + ), + + ); + } + + /** + * @dataProvider data_is_config_valid_should_call_doing_ti_wrong + * + * @param mixed $config Config of the font collection. + */ + public function test_is_config_valid_should_call_doing_ti_wrong( $config ) { + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::is_config_valid', 'Should call _doing_it_wrong if the config is not valid.' ); + $this->assertFalse( WP_Font_Collection::is_config_valid( $config ), 'Should return false if the config is not valid.' ); + } + + public function data_is_config_valid_should_call_doing_ti_wrong() { + return array( + 'with missing slug' => array( + 'config' => array( + 'name' => 'My Collection', + 'description' => 'My collection description', + 'src' => 'my-collection-data.json', + ), + ), + 'with missing name' => array( + 'config' => array( + 'slug' => 'my-collection', + 'description' => 'My collection description', + 'src' => 'my-collection-data.json', + ), + ), + 'with missing src' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + ), + ), + 'with both src and font_families' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + 'src' => 'my-collection-data.json', + 'font_families' => array( 'mock' ), + ), + ), + 'without src or font_families' => array( + 'config' => array( + 'slug' => 'my-collection', + 'name' => 'My Collection', + 'description' => 'My collection description', + ), + ), + 'with empty config' => array( + 'config' => array(), + ), + 'without an array' => array( + 'config' => 'not an array', + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php b/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php index 3a1e387c3651b..cee0628dad310 100644 --- a/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php +++ b/phpunit/tests/fonts/font-library/wpFontFamily/__construct.php @@ -30,11 +30,11 @@ public function test_should_initialize_data() { } /** - * @dataProvider data_should_throw_exception + * @dataProvider data_should_do_it_wrong * * @param mixed $font_data Data to test. */ - public function test_should_throw_exception( $font_data ) { + public function test_should_do_it_wrong( $font_data ) { $this->expectException( 'Exception' ); $this->expectExceptionMessage( 'Font family data is missing the slug.' ); @@ -46,7 +46,7 @@ public function test_should_throw_exception( $font_data ) { * * @return array */ - public function data_should_throw_exception() { + public function data_should_do_it_wrong() { return array( 'no slug' => array( array( diff --git a/phpunit/tests/fonts/font-library/wpFontFamilyUtils/formatFontFamily.php b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/formatFontFamily.php index 4f247c5219feb..19987010d80a7 100644 --- a/phpunit/tests/fonts/font-library/wpFontFamilyUtils/formatFontFamily.php +++ b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/formatFontFamily.php @@ -36,11 +36,11 @@ public function data_should_format_font_family() { return array( 'data_families_with_spaces_and_numbers' => array( 'font_family' => 'Rock 3D , Open Sans,serif', - 'expected' => "'Rock 3D', 'Open Sans', serif", + 'expected' => '"Rock 3D", "Open Sans", serif', ), 'data_single_font_family' => array( 'font_family' => 'Rock 3D', - 'expected' => "'Rock 3D'", + 'expected' => '"Rock 3D"', ), 'data_no_spaces' => array( 'font_family' => 'Rock3D', @@ -48,7 +48,7 @@ public function data_should_format_font_family() { ), 'data_many_spaces_and_existing_quotes' => array( 'font_family' => 'Rock 3D serif, serif,sans-serif, "Open Sans"', - 'expected' => "'Rock 3D serif', serif, sans-serif, \"Open Sans\"", + 'expected' => '"Rock 3D serif", serif, sans-serif, "Open Sans"', ), 'data_empty_family' => array( 'font_family' => ' ', diff --git a/phpunit/tests/fonts/font-library/wpFontFamilyUtils/getFontFaceSlug.php b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/getFontFaceSlug.php new file mode 100644 index 0000000000000..1f87d0d2fd5a1 --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpFontFamilyUtils/getFontFaceSlug.php @@ -0,0 +1,81 @@ +assertSame( $expected_slug, $slug ); + } + + public function data_get_font_face_slug_normalizes_values() { + return array( + 'Sets defaults' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Converts normal weight to 400' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + 'fontWeight' => 'normal', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Converts bold weight to 700' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + 'fontWeight' => 'bold', + ), + 'expected_slug' => 'open sans;normal;700;100%;U+0-10FFFF', + ), + 'Converts normal font-stretch to 100%' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans', + 'fontStretch' => 'normal', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes double quotes from fontFamilies' => array( + 'settings' => array( + 'fontFamily' => '"Open Sans"', + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes single quotes from fontFamilies' => array( + 'settings' => array( + 'fontFamily' => "'Open Sans'", + ), + 'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF', + ), + 'Removes spaces between comma separated font families' => array( + 'settings' => array( + 'fontFamily' => 'Open Sans, serif', + ), + 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', + ), + 'Removes tabs between comma separated font families' => array( + 'settings' => array( + 'fontFamily' => "Open Sans,\tserif", + ), + 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', + ), + 'Removes new lines between comma separated font families' => array( + 'settings' => array( + 'fontFamily' => "Open Sans,\nserif", + ), + 'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF', + ), + ); + } +} diff --git a/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php b/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php index a7ea2870957e9..b06ae3c8d5354 100644 --- a/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php +++ b/phpunit/tests/fonts/font-library/wpFontLibrary/registerFontCollection.php @@ -29,9 +29,9 @@ public function test_should_return_error_if_slug_is_missing() { 'description' => 'My Collection Description', 'src' => 'my-collection-data.json', ); - $this->expectException( 'Exception' ); - $this->expectExceptionMessage( 'Font Collection config slug is required as a non-empty string.' ); - WP_Font_Library::register_font_collection( $config ); + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::is_config_valid' ); + $collection = WP_Font_Library::register_font_collection( $config ); + $this->assertWPError( $collection, 'A WP_Error should be returned.' ); } public function test_should_return_error_if_name_is_missing() { @@ -40,16 +40,16 @@ public function test_should_return_error_if_name_is_missing() { 'description' => 'My Collection Description', 'src' => 'my-collection-data.json', ); - $this->expectException( 'Exception' ); - $this->expectExceptionMessage( 'Font Collection config name is required as a non-empty string.' ); - WP_Font_Library::register_font_collection( $config ); + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::is_config_valid' ); + $collection = WP_Font_Library::register_font_collection( $config ); + $this->assertWPError( $collection, 'A WP_Error should be returned.' ); } public function test_should_return_error_if_config_is_empty() { $config = array(); - $this->expectException( 'Exception' ); - $this->expectExceptionMessage( 'Font Collection config options is required as a non-empty array.' ); - WP_Font_Library::register_font_collection( $config ); + $this->setExpectedIncorrectUsage( 'WP_Font_Collection::is_config_valid' ); + $collection = WP_Font_Library::register_font_collection( $config ); + $this->assertWPError( $collection, 'A WP_Error should be returned.' ); } public function test_should_return_error_if_slug_is_repeated() { diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php new file mode 100644 index 0000000000000..164f88f3f7b4b --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController.php @@ -0,0 +1,163 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + $mock_file = wp_tempnam( 'my-collection-data-' ); + file_put_contents( $mock_file, '{"font_families": [ "mock" ], "categories": [ "mock" ] }' ); + + wp_register_font_collection( + array( + 'name' => 'My Collection', + 'slug' => 'mock-col-slug', + 'src' => $mock_file, + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + wp_unregister_font_collection( 'mock-col-slug' ); + } + + + /** + * @covers WP_REST_Font_Collections_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertCount( 1, $routes['/wp/v2/font-collections'], 'Rest server has not the collections path initialized.' ); + $this->assertCount( 1, $routes['/wp/v2/font-collections/(?P[\/\w-]+)'], 'Rest server has not the collection path initialized.' ); + + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections'][0]['methods'], 'Rest server has not the GET method for collections intialized.' ); + $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections/(?P[\/\w-]+)'][0]['methods'], 'Rest server has not the GET method for collection intialized.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections' ); + $response = rest_get_server()->dispatch( $request ); + $content = $response->get_data(); + $this->assertIsArray( $content ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/mock-col-slug' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status(), 'Response code is not 200' ); + + $response_data = $response->get_data(); + $this->assertArrayHasKey( 'name', $response_data, 'Response data does not have the name key.' ); + $this->assertArrayHasKey( 'slug', $response_data, 'Response data does not have the slug key.' ); + $this->assertArrayHasKey( 'description', $response_data, 'Response data does not have the description key.' ); + $this->assertArrayHasKey( 'font_families', $response_data, 'Response data does not have the font_families key.' ); + $this->assertArrayHasKey( 'categories', $response_data, 'Response data does not have the categories key.' ); + + $this->assertIsString( $response_data['name'], 'name is not a string.' ); + $this->assertIsString( $response_data['slug'], 'slug is not a string.' ); + $this->assertIsString( $response_data['description'], 'description is not a string.' ); + + $this->assertIsArray( $response_data['font_families'], 'font_families is not an array.' ); + $this->assertIsArray( $response_data['categories'], 'categories is not an array.' ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/non-existing-collection' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'font_collection_not_found', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Collections_Controller::get_item + */ + public function test_get_item_invalid_id_permission() { + $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/mock-col-slug' ); + + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401, 'Response code should be 401 for non-authenticated users.' ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403, 'Response code should be 403 for users without the right permissions.' ); + } + + /** + * @doesNotPerformAssertions + */ + public function test_context_param() { + // Controller does not use get_context_param(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_create_item() { + // Controller does not use test_create_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_update_item() { + // Controller does not use test_update_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_delete_item() { + // Controller does not use test_delete_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_prepare_item() { + // Controller does not use test_prepare_item(). + } + + /** + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Controller does not use test_get_item_schema(). + } +} diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/base.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/base.php deleted file mode 100644 index 2469d71dc79ce..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/base.php +++ /dev/null @@ -1,42 +0,0 @@ -factory->user->create( - array( - 'role' => 'administrator', - ) - ); - wp_set_current_user( $admin_id ); - } - - /** - * Tear down each test method. - */ - public function tear_down() { - parent::tear_down(); - - // Reset $collections static property of WP_Font_Library class. - $reflection = new ReflectionClass( 'WP_Font_Library' ); - $property = $reflection->getProperty( 'collections' ); - $property->setAccessible( true ); - $property->setValue( null, array() ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php deleted file mode 100644 index c9d003389997b..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollection.php +++ /dev/null @@ -1,126 +0,0 @@ - 'one-collection', - 'name' => 'One Font Collection', - 'description' => 'Demo about how to a font collection to your WordPress Font Library.', - 'src' => $mock_file, - ); - wp_register_font_collection( $config_with_file ); - - $config_with_url = array( - 'slug' => 'collection-with-url', - 'name' => 'Another Font Collection', - 'description' => 'Demo about how to a font collection to your WordPress Font Library.', - 'src' => 'https://wordpress.org/fonts/mock-font-collection.json', - ); - - wp_register_font_collection( $config_with_url ); - - $config_with_non_existing_file = array( - 'slug' => 'collection-with-non-existing-file', - 'name' => 'Another Font Collection', - 'description' => 'Demo about how to a font collection to your WordPress Font Library.', - 'src' => '/home/non-existing-file.json', - ); - - wp_register_font_collection( $config_with_non_existing_file ); - - $config_with_non_existing_url = array( - 'slug' => 'collection-with-non-existing-url', - 'name' => 'Another Font Collection', - 'description' => 'Demo about how to a font collection to your WordPress Font Library.', - 'src' => 'https://non-existing-url-1234x.com.ar/fake-path/missing-file.json', - ); - - wp_register_font_collection( $config_with_non_existing_url ); - } - - public function mock_request( $preempt, $args, $url ) { - // Check if it's the URL you want to mock. - if ( 'https://wordpress.org/fonts/mock-font-collection.json' === $url ) { - - // Mock the response body. - $mock_collection_data = array( - 'fontFamilies' => 'mock', - 'categories' => 'mock', - ); - - return array( - 'body' => json_encode( $mock_collection_data ), - 'response' => array( - 'code' => 200, - ), - ); - } - - // For any other URL, return false which ensures the request is made as usual (or you can return other mock data). - return false; - } - - public function tear_down() { - // Remove the mock to not affect other tests. - remove_filter( 'pre_http_request', array( $this, 'mock_request' ) ); - - parent::tear_down(); - } - - public function test_get_font_collection_from_file() { - $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/one-collection' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); - $this->assertArrayHasKey( 'data', $data, 'The response data does not have the key with the file data.' ); - $this->assertSame( array( 'this is mock data' => true ), $data['data'], 'The response data does not have the expected file data.' ); - } - - public function test_get_font_collection_from_url() { - $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/collection-with-url' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); - $this->assertArrayHasKey( 'data', $data, 'The response data does not have the key with the file data.' ); - } - - public function test_get_non_existing_collection_should_return_404() { - $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/non-existing-collection-id' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 404, $response->get_status() ); - } - - public function test_get_non_existing_file_should_return_500() { - $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/collection-with-non-existing-file' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 500, $response->get_status() ); - } - - public function test_get_non_existing_url_should_return_500() { - $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections/collection-with-non-existing-url' ); - $response = rest_get_server()->dispatch( $request ); - $this->assertSame( 500, $response->get_status() ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php deleted file mode 100644 index 0a8d24e8f392b..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/getFontCollections.php +++ /dev/null @@ -1,45 +0,0 @@ -dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - $this->assertSame( array(), $response->get_data() ); - } - - public function test_get_font_collections() { - // Mock font collection data file. - $mock_file = wp_tempnam( 'my-collection-data-' ); - file_put_contents( $mock_file, '{"this is mock data":true}' ); - - // Add a font collection. - $config = array( - 'slug' => 'my-font-collection', - 'name' => 'My Font Collection', - 'description' => 'Demo about how to a font collection to your WordPress Font Library.', - 'src' => $mock_file, - ); - wp_register_font_collection( $config ); - - $request = new WP_REST_Request( 'GET', '/wp/v2/font-collections' ); - $response = rest_get_server()->dispatch( $request ); - $data = $response->get_data(); - $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); - $this->assertCount( 1, $data, 'The response data is not an array with one element.' ); - $this->assertArrayHasKey( 'slug', $data[0], 'The response data does not have the key with the collection slug.' ); - $this->assertArrayHasKey( 'name', $data[0], 'The response data does not have the key with the collection name.' ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php b/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php deleted file mode 100644 index fb100a400fb4c..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontCollectionsController/registerRoutes.php +++ /dev/null @@ -1,24 +0,0 @@ -get_routes(); - $this->assertCount( 1, $routes['/wp/v2/font-collections'], 'Rest server has not the collections path initialized.' ); - $this->assertCount( 1, $routes['/wp/v2/font-collections/(?P[\/\w-]+)'], 'Rest server has not the collection path initialized.' ); - - $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections'][0]['methods'], 'Rest server has not the GET method for collections intialized.' ); - $this->assertArrayHasKey( 'GET', $routes['/wp/v2/font-collections/(?P[\/\w-]+)'][0]['methods'], 'Rest server has not the GET method for collection intialized.' ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontFacesController.php b/phpunit/tests/fonts/font-library/wpRestFontFacesController.php new file mode 100644 index 0000000000000..1904a17228bdc --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpRestFontFacesController.php @@ -0,0 +1,887 @@ + '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ); + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$font_family_id = WP_REST_Font_Families_Controller_Test::create_font_family_post(); + self::$other_font_family_id = WP_REST_Font_Families_Controller_Test::create_font_family_post(); + + self::$font_face_id1 = self::create_font_face_post( + self::$font_family_id, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-medium.ttf' ), + ) + ); + self::$font_face_id2 = self::create_font_face_post( + self::$font_family_id, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '900', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-bold.ttf' ), + ) + ); + + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + } + + public static function create_font_face_post( $parent_id, $settings = array() ) { + $settings = array_merge( self::$default_settings, $settings ); + $title = WP_Font_Family_Utils::get_font_face_slug( $settings ); + return self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_face', + 'post_status' => 'publish', + 'post_title' => $title, + 'post_name' => sanitize_title( $title ), + 'post_content' => wp_json_encode( $settings ), + 'post_parent' => $parent_id, + ) + ) + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces', + $routes, + 'Font faces collection for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families/(?P[\d]+)/font-faces'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)', + $routes, + 'Single font face route for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + } + + public function test_font_faces_no_autosave_routes() { + // @core-merge: Enable this test. + $this->markTestSkipped( 'This test only works with WP 6.4 and above. Enable it once 6.5 is released.' ); + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)/autosaves', + $routes, + 'Font faces autosaves route exists.' + ); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/font-faces/(?P[\d]+)/autosaves/(?P[\d]+)', + $routes, + 'Font faces autosaves by id route exists.' + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_context_param + */ + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertArrayNotHasKey( 'allow_batch', $data['endpoints'][0] ); + $this->assertSame( 'edit', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertArrayNotHasKey( 'allow_batch', $data['endpoints'][0] ); + $this->assertSame( 'edit', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 2, $data ); + $this->assertArrayHasKey( '_links', $data[0] ); + $this->check_font_face_data( $data[0], self::$font_face_id2, $data[0]['_links'] ); + $this->assertArrayHasKey( '_links', $data[1] ); + $this->check_font_face_data( $data[1], self::$font_face_id1, $data[1]['_links'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->check_font_face_data( $data, self::$font_face_id1, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_removes_extra_settings() { + $font_face_id = self::create_font_face_post( self::$font_family_id, array( 'extra' => array() ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'extra', $data['font_face_settings'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_malformed_post_content_returns_empty_settings() { + $font_face_id = wp_insert_post( + array( + 'post_type' => 'wp_font_face', + 'post_parent' => self::$font_family_id, + 'post_status' => 'publish', + 'post_content' => 'invalid', + ) + ); + + $empty_settings = array( + 'fontFamily' => '', + 'src' => array(), + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $empty_settings, $data['font_face_settings'] ); + + wp_delete_post( $font_face_id, true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_font_face_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_valid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( self::$font_family_id, $data['parent'], 'The returned parent id should match the font family id.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$other_font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_font_face_parent_id_mismatch', $response, 404 ); + + $expected_message = 'The font face does not belong to the specified font family with id of "' . self::$other_font_family_id . '"'; + $this->assertSame( $expected_message, $response->as_error()->get_error_messages()[0], 'The message must contain the correct parent ID.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item() { + wp_set_current_user( self::$admin_id ); + $files = $this->setup_font_file_upload( array( 'woff2' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => array_keys( $files )[0], + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + $this->check_file_meta( $data['id'], array( $data['font_face_settings']['src'] ) ); + + $settings = $data['font_face_settings']; + unset( $settings['src'] ); + $this->assertSame( + $settings, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + ) + ); + + $this->assertSame( self::$font_family_id, $data['parent'], 'The returned parent id should match the font family id.' ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_multiple_font_files() { + wp_set_current_user( self::$admin_id ); + $files = $this->setup_font_file_upload( array( 'ttf', 'otf', 'woff', 'woff2' ) ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => array_keys( $files ), + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + $this->check_file_meta( $data['id'], $data['font_face_settings']['src'] ); + + $settings = $data['font_face_settings']; + $this->assertCount( 4, $settings['src'] ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_invalid_file_type() { + $image_file = DIR_TESTDATA . '/images/canola.jpg'; + $image_path = wp_tempnam( 'canola.jpg' ); + copy( $image_file, $image_path ); + + $files = array( + 'file-0' => array( + 'name' => 'canola.jpg', + 'full_path' => 'canola.jpg', + 'type' => 'font/woff2', + 'tmp_name' => $image_path, + 'error' => 0, + 'size' => filesize( $image_path ), + ), + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( + self::$default_settings, + array( + 'fontWeight' => '200', + 'src' => array_keys( $files )[0], + ) + ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_font_upload_invalid_file_type', $response, 400 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_url_src() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'normal', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_font_face_data( $data, $data['id'], $response->get_links() ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_all_properties() { + wp_set_current_user( self::$admin_id ); + + $properties = array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '300 500', + 'fontStyle' => 'oblique 30deg 50deg', + 'fontDisplay' => 'swap', + 'fontStretch' => 'expanded', + 'ascentOverride' => '70%', + 'descentOverride' => '30%', + 'fontVariant' => 'normal', + 'fontFeatureSettings' => '"swsh" 2', + 'fontVariationSettings' => '"xhgt" 0.7', + 'lineGapOverride' => '10%', + 'sizeAdjust' => '90%', + 'unicodeRange' => 'U+0025-00FF, U+4??', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ); + + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', wp_json_encode( $properties ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertArrayHasKey( 'font_face_settings', $data ); + $this->assertSame( $properties, $data['font_face_settings'] ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( array_merge( self::$default_settings, array( 'fontWeight' => '100' ) ) ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_with_duplicate_properties() { + $settings = array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'fontStyle' => 'italic', + 'src' => home_url( '/wp-content/fonts/open-sans-italic-light.ttf' ), + ); + $font_face_id = self::create_font_face_post( self::$font_family_id, $settings ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_duplicate_font_face', $response, 400 ); + $expected_message = 'A font face matching those settings already exists.'; + $message = $response->as_error()->get_error_messages()[0]; + $this->assertSame( $expected_message, $message ); + + wp_delete_post( $font_face_id, true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_request + */ + public function test_create_item_default_theme_json_version() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '200', + 'src' => 'https://fonts.gstatic.com/s/open-sans/v30/KFOkCnqEu92Fr1MmgWxPKTM1K9nz.ttf', + ) + ) + ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertArrayHasKey( 'theme_json_version', $data ); + $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @dataProvider data_create_item_invalid_theme_json_version + * + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_invalid_theme_json_version( $theme_json_version ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', $theme_json_version ); + $request->set_param( 'font_face_settings', '' ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function data_create_item_invalid_theme_json_version() { + return array( + array( 1 ), + array( 3 ), + ); + } + + /** + * @dataProvider data_create_item_invalid_settings + * + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function data_create_item_invalid_settings() { + return array( + 'Missing fontFamily' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Empty fontFamily' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Wrong fontFamily type' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => 1234 ) ), + ), + 'Invalid fontDisplay' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontDisplay' => 'invalid' ) ), + ), + 'Missing src' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'src' => '' ) ), + ), + 'Empty src string' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => '' ) ), + ), + 'Empty src array' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => array() ) ), + ), + 'Empty src array values' => array( + 'settings' => array_merge( self::$default_settings, array( '', '' ) ), + ), + 'Wrong src type' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => 1234 ) ), + ), + 'Wrong src array types' => array( + 'settings' => array_merge( self::$default_settings, array( 'src' => array( 1234, 5678 ) ) ), + ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_settings_json() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_face_settings', 'invalid' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $expected_message = 'font_face_settings parameter must be a valid JSON string.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_file_src() { + $files = $this->setup_font_file_upload( array( 'woff2' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( + 'font_face_settings', + wp_json_encode( + array_merge( self::$default_settings, array( 'src' => 'invalid' ) ) + ) + ); + $request->set_file_params( $files ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $expected_message = 'File ' . array_keys( $files )[0] . ' must be used in font_face_settings[src].'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_face_settings']; + $this->assertSame( $expected_message, $message ); + } + + /** + * @dataProvider data_create_item_santize_font_family + * + * @covers WP_REST_Font_Face_Controller::sanitize_font_face_settings + */ + public function test_create_item_santize_font_family( $font_family_setting, $expected ) { + $settings = array_merge( self::$default_settings, array( 'fontFamily' => $font_family_setting ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $request->set_param( 'font_face_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertSame( $expected, $data['font_face_settings']['fontFamily'] ); + } + + public function data_create_item_santize_font_family() { + return array( + array( 'Libre Barcode 128 Text', '"Libre Barcode 128 Text"' ), + array( 'B612 Mono', '"B612 Mono"' ), + array( 'Open Sans, Noto Sans, sans-serif', '"Open Sans", "Noto Sans", sans-serif' ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + // public function test_create_item_no_permission() {} + + public function test_update_item() { + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id1 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_no_route', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $font_face_id = self::create_font_face_post( self::$font_family_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $font_face_id ) ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_trash() { + wp_set_current_user( self::$admin_id ); + $font_face_id = self::create_font_face_post( self::$font_family_id ); + + // Attempt trashing. + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); + + $request->set_param( 'force', 'false' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); + + // Ensure the post still exists. + $post = get_post( $font_face_id ); + $this->assertNotEmpty( $post ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_invalid_font_face_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete + */ + public function test_delete_item_missing_parent() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER . '/font-faces/' . self::$font_face_id1 ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_post_invalid_parent', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_delete_item_invalid_parent_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$other_font_family_id . '/font-faces/' . self::$font_face_id1 ); + $request->set_param( 'force', true ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_font_face_parent_id_mismatch', $response, 404 ); + + $expected_message = 'The font face does not belong to the specified font family with id of "' . self::$other_font_family_id . '"'; + $this->assertSame( $expected_message, $response->as_error()->get_error_messages()[0], 'The message must contain the correct parent ID.' ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_permissions() { + $font_face_id = $this->create_font_face_post( self::$font_family_id ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . $font_face_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces/' . self::$font_face_id2 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->check_font_face_data( $data, self::$font_face_id2, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families/' . self::$font_family_id . '/font-faces' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties ); + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'theme_json_version', $properties ); + $this->assertArrayHasKey( 'parent', $properties ); + $this->assertArrayHasKey( 'font_face_settings', $properties ); + } + + protected function check_font_face_data( $data, $post_id, $links ) { + $post = get_post( $post_id ); + + $this->assertArrayHasKey( 'id', $data ); + $this->assertSame( $post->ID, $data['id'] ); + + $this->assertArrayHasKey( 'parent', $data ); + $this->assertSame( $post->post_parent, $data['parent'] ); + + $this->assertArrayHasKey( 'theme_json_version', $data ); + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'] ); + + $this->assertArrayHasKey( 'font_face_settings', $data ); + $this->assertSame( $post->post_content, wp_json_encode( $data['font_face_settings'] ) ); + + $this->assertNotEmpty( $links ); + $this->assertSame( rest_url( 'wp/v2/font-families/' . $post->post_parent . '/font-faces/' . $post->ID ), $links['self'][0]['href'] ); + $this->assertSame( rest_url( 'wp/v2/font-families/' . $post->post_parent . '/font-faces' ), $links['collection'][0]['href'] ); + $this->assertSame( rest_url( 'wp/v2/font-families/' . $post->post_parent ), $links['parent'][0]['href'] ); + } + + protected function check_file_meta( $font_face_id, $srcs ) { + $file_meta = get_post_meta( $font_face_id, '_wp_font_face_file' ); + + foreach ( $srcs as $src ) { + $file_name = basename( $src ); + $this->assertContains( $file_name, $file_meta, 'The uploaded font file path should be saved in the post meta.' ); + } + } + + protected function setup_font_file_upload( $formats ) { + $files = array(); + foreach ( $formats as $format ) { + // @core-merge Use `DIR_TESTDATA` instead of `GUTENBERG_DIR_TESTDATA`. + $font_file = GUTENBERG_DIR_TESTDATA . 'fonts/OpenSans-Regular.' . $format; + $font_path = wp_tempnam( 'OpenSans-Regular.' . $format ); + copy( $font_file, $font_path ); + + $files[ 'file-' . count( $files ) ] = array( + 'name' => 'OpenSans-Regular.' . $format, + 'full_path' => 'OpenSans-Regular.' . $format, + 'type' => 'font/' . $format, + 'tmp_name' => $font_path, + 'error' => 0, + 'size' => filesize( $font_path ), + ); + } + + return $files; + } +} diff --git a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php new file mode 100644 index 0000000000000..6e6a822a9881f --- /dev/null +++ b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController.php @@ -0,0 +1,866 @@ + 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ); + + public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) { + self::$admin_id = $factory->user->create( + array( + 'role' => 'administrator', + ) + ); + self::$editor_id = $factory->user->create( + array( + 'role' => 'editor', + ) + ); + + self::$font_family_id1 = self::create_font_family_post( + array( + 'name' => 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ) + ); + self::$font_family_id2 = self::create_font_family_post( + array( + 'name' => 'Helvetica', + 'slug' => 'helvetica', + 'fontFamily' => 'Helvetica, Arial, sans-serif', + ) + ); + self::$font_face_id1 = WP_REST_Font_Faces_Controller_Test::create_font_face_post( + self::$font_family_id1, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '400', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-medium.ttf' ), + ) + ); + self::$font_face_id2 = WP_REST_Font_Faces_Controller_Test::create_font_face_post( + self::$font_family_id1, + array( + 'fontFamily' => '"Open Sans"', + 'fontWeight' => '900', + 'fontStyle' => 'normal', + 'src' => home_url( '/wp-content/fonts/open-sans-bold.ttf' ), + ) + ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + self::delete_user( self::$editor_id ); + } + + public static function create_font_family_post( $settings = array() ) { + $settings = array_merge( self::$default_settings, $settings ); + return self::factory()->post->create( + wp_slash( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_title' => $settings['name'], + 'post_name' => $settings['slug'], + 'post_content' => wp_json_encode( + array( + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ) + ), + ) + ) + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( + '/wp/v2/font-families', + $routes, + 'Font faces collection for the given font family does not exist' + ); + $this->assertCount( + 2, + $routes['/wp/v2/font-families'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + $this->assertArrayHasKey( + '/wp/v2/font-families/(?P[\d]+)', + $routes, + 'Single font face route for the given font family does not exist' + ); + $this->assertCount( + 3, + $routes['/wp/v2/font-families/(?P[\d]+)'], + 'Font faces collection for the given font family does not have exactly two elements' + ); + } + + public function test_font_families_no_autosave_routes() { + // @core-merge: Enable this test. + $this->markTestSkipped( 'This test only works with WP 6.4 and above. Enable it once 6.5 is released.' ); + $routes = rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/autosaves', + $routes, + 'Font families autosaves route exists.' + ); + $this->assertArrayNotHasKey( + '/wp/v2/font-families/(?P[\d]+)/autosaves/(?P[\d]+)', + $routes, + 'Font families autosaves by id route exists.' + ); + } + + /** + * @covers WP_REST_Font_Families_Controller::get_context_param + */ + public function test_context_param() { + // Collection. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertArrayNotHasKey( 'allow_batch', $data['endpoints'][0] ); + $this->assertSame( 'edit', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + + // Single. + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families/' . self::$font_family_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertArrayNotHasKey( 'allow_batch', $data['endpoints'][0] ); + $this->assertSame( 'edit', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 2, $data ); + $this->assertArrayHasKey( '_links', $data[0] ); + $this->check_font_family_data( $data[0], self::$font_family_id2, $data[0]['_links'] ); + $this->assertArrayHasKey( '_links', $data[1] ); + $this->check_font_family_data( $data[1], self::$font_family_id1, $data[1]['_links'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_by_slug() { + $font_family = get_post( self::$font_family_id2 ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $request->set_param( 'slug', $font_family->post_name ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $data ); + $this->assertSame( $font_family->ID, $data[0]['id'] ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_items + */ + public function test_get_items_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->check_font_family_data( $data, self::$font_family_id1, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_get_item_embedded_font_faces() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( '_embed', true ); + $response = rest_get_server()->dispatch( $request ); + $data = rest_get_server()->response_to_data( $response, true ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayHasKey( '_embedded', $data ); + $this->assertArrayHasKey( 'font_faces', $data['_embedded'] ); + $this->assertCount( 2, $data['_embedded']['font_faces'] ); + + foreach ( $data['_embedded']['font_faces'] as $font_face ) { + $this->assertArrayHasKey( 'id', $font_face ); + + $font_face_request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 . '/font-faces/' . $font_face['id'] ); + $font_face_response = rest_get_server()->dispatch( $font_face_request ); + $font_face_data = rest_get_server()->response_to_data( $font_face_response, true ); + + $this->assertSame( $font_face_data, $font_face ); + } + } + + /** + * @covers WP_REST_Font_Families_Controller::get_item + */ + public function test_get_item_removes_extra_settings() { + $font_family_id = self::create_font_family_post( array( 'fontFace' => array() ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertArrayNotHasKey( 'fontFace', $data['font_family_settings'] ); + + wp_delete_post( $font_family_id, true ); + } + + /** + * @covers WP_REST_Font_Families_Controller::prepare_item_for_response + */ + public function test_get_item_malformed_post_content_returns_empty_settings() { + $font_family_id = wp_insert_post( + array( + 'post_type' => 'wp_font_family', + 'post_status' => 'publish', + 'post_content' => 'invalid', + ) + ); + + $empty_settings = array( + 'name' => '', + // Slug will default to the post id. + 'slug' => (string) $font_family_id, + 'fontFamily' => '', + 'preview' => '', + ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $empty_settings, $data['font_family_settings'] ); + + wp_delete_post( $font_family_id, true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_invalid_font_family_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item + */ + public function test_get_item_no_permission() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id1 ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_read', $response, 403 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_font_family_data( $data, $data['id'], $response->get_links() ); + + $reponse_settings = $data['font_family_settings']; + $this->assertSame( $settings, $reponse_settings ); + $this->assertEmpty( $data['font_faces'] ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_request + */ + public function test_create_item_default_theme_json_version() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->assertArrayHasKey( 'theme_json_version', $data ); + $this->assertSame( 2, $data['theme_json_version'], 'The default theme.json version should be 2.' ); + + wp_delete_post( $data['id'], true ); + } + + /** + * @dataProvider data_create_item_invalid_theme_json_version + * + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_invalid_theme_json_version( $theme_json_version ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', $theme_json_version ); + $request->set_param( 'font_family_settings', wp_json_encode( self::$default_settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function data_create_item_invalid_theme_json_version() { + return array( + array( 1 ), + array( 3 ), + ); + } + + /** + * @dataProvider data_create_item_with_default_preview + * + * @covers WP_REST_Font_Faces_Controller::sanitize_font_family_settings + */ + public function test_create_item_with_default_preview( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $response_settings = $data['font_family_settings']; + $this->assertArrayHasKey( 'preview', $response_settings ); + $this->assertSame( '', $response_settings['preview'] ); + + wp_delete_post( $data['id'], true ); + } + + public function data_create_item_with_default_preview() { + $default_settings = array( + 'name' => 'Open Sans', + 'slug' => 'open-sans-2', + 'fontFamily' => '"Open Sans", sans-serif', + ); + return array( + 'No preview param' => array( + 'settings' => $default_settings, + ), + 'Empty preview' => array( + 'settings' => array_merge( $default_settings, array( 'preview' => '' ) ), + ), + ); + } + + /** + * @dataProvider data_create_item_invalid_settings + * + * @covers WP_REST_Font_Faces_Controller::validate_create_font_face_settings + */ + public function test_create_item_invalid_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function data_create_item_invalid_settings() { + return array( + 'Missing name' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'name' => '' ) ), + ), + 'Empty name' => array( + 'settings' => array_merge( self::$default_settings, array( 'name' => '' ) ), + ), + 'Wrong name type' => array( + 'settings' => array_merge( self::$default_settings, array( 'name' => 1234 ) ), + ), + 'Missing slug' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'slug' => '' ) ), + ), + 'Empty slug' => array( + 'settings' => array_merge( self::$default_settings, array( 'slug' => '' ) ), + ), + 'Wrong slug type' => array( + 'settings' => array_merge( self::$default_settings, array( 'slug' => 1234 ) ), + ), + 'Missing fontFamily' => array( + 'settings' => array_diff_key( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Empty fontFamily' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => '' ) ), + ), + 'Wrong fontFamily type' => array( + 'settings' => array_merge( self::$default_settings, array( 'fontFamily' => 1234 ) ), + ), + ); + } + + /** + * @covers WP_REST_Font_Family_Controller::validate_font_family_settings + */ + public function test_create_item_invalid_settings_json() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', 'invalid' ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $expected_message = 'font_family_settings parameter must be a valid JSON string.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_family_settings']; + $this->assertSame( $expected_message, $message ); + } + + /** + * @covers WP_REST_Font_Family_Controller::create_item + */ + public function test_create_item_with_duplicate_slug() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'theme_json_version', 2 ); + $request->set_param( 'font_family_settings', wp_json_encode( array_merge( self::$default_settings, array( 'slug' => 'helvetica' ) ) ) ); + + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_duplicate_font_family', $response, 400 ); + $expected_message = 'A font family with slug "helvetica" already exists.'; + $message = $response->as_error()->get_error_messages()[0]; + $this->assertSame( $expected_message, $message ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::create_item + */ + public function test_create_item_no_permission() { + $settings = array_merge( self::$default_settings, array( 'slug' => 'open-sans-2' ) ); + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_create', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); + $request->set_param( + 'font_family_settings', + wp_json_encode( + array( + 'name' => 'Open Sans', + 'slug' => 'open-sans', + 'fontFamily' => '"Open Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.7/previews/open-sans/open-sans-400-normal.svg', + ) + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_create', $response, 403 ); + } + + /** + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item() { + wp_set_current_user( self::$admin_id ); + + $settings = array( + 'name' => 'Open Sans', + 'fontFamily' => '"Open Sans, "Noto Sans", sans-serif', + 'preview' => 'https://s.w.org/images/fonts/16.9/previews/open-sans/open-sans-400-normal.svg', + ); + + $font_family_id = self::create_font_family_post( array( 'slug' => 'open-sans-2' ) ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( + 'font_family_settings', + wp_json_encode( $settings ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->check_font_family_data( $data, $font_family_id, $response->get_links() ); + + $expected_settings = array( + 'name' => $settings['name'], + 'slug' => 'open-sans-2', + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ); + $this->assertSame( $expected_settings, $data['font_family_settings'] ); + + wp_delete_post( $font_family_id, true ); + } + + /** + * @dataProvider data_update_item_individual_settings + * @covers WP_REST_Font_Families_Controller::update_item + */ + public function test_update_item_individual_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $key = key( $settings ); + $value = current( $settings ); + $this->assertArrayHasKey( $key, $data['font_family_settings'] ); + $this->assertSame( $value, $data['font_family_settings'][ $key ] ); + + wp_delete_post( $font_family_id, true ); + } + + public function data_update_item_individual_settings() { + return array( + array( array( 'name' => 'Opened Sans' ) ), + array( array( 'fontFamily' => '"Opened Sans", sans-serif' ) ), + array( array( 'preview' => 'https://s.w.org/images/fonts/16.7/previews/opened-sans/opened-sans-400-normal.svg' ) ), + // Empty preview is allowed. + array( array( 'preview' => '' ) ), + ); + } + + /** + * @dataProvider data_update_item_santize_font_family + * + * @covers WP_REST_Font_Families_Controller::sanitize_font_face_settings + */ + public function test_update_item_santize_font_family( $font_family_setting, $expected ) { + wp_set_current_user( self::$admin_id ); + + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . $font_family_id ); + $request->set_param( 'font_family_settings', wp_json_encode( array( 'fontFamily' => $font_family_setting ) ) ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( $expected, $data['font_family_settings']['fontFamily'] ); + + wp_delete_post( $font_family_id, true ); + } + + public function data_update_item_santize_font_family() { + return array( + array( 'Libre Barcode 128 Text', '"Libre Barcode 128 Text"' ), + array( 'B612 Mono', '"B612 Mono"' ), + array( 'Open Sans, Noto Sans, sans-serif', '"Open Sans", "Noto Sans", sans-serif' ), + ); + } + + /** + * @dataProvider data_update_item_invalid_settings + * + * @covers WP_REST_Font_Faces_Controller::update_item + */ + public function test_update_item_empty_settings( $settings ) { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( + 'font_family_settings', + wp_json_encode( $settings ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + } + + public function data_update_item_invalid_settings() { + return array( + 'Empty name' => array( + array( 'name' => '' ), + ), + 'Wrong name type' => array( + array( 'name' => 1234 ), + ), + 'Empty fontFamily' => array( + array( 'fontFamily' => '' ), + ), + 'Wrong fontFamily type' => array( + array( 'fontFamily' => 1234 ), + ), + ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::update_item + */ + public function test_update_item_update_slug_not_allowed() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( + 'font_family_settings', + wp_json_encode( array( 'slug' => 'new-slug' ) ) + ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertErrorResponse( 'rest_invalid_param', $response, 400 ); + $expected_message = 'font_family_settings[slug] cannot be updated.'; + $message = $response->as_error()->get_all_error_data()[0]['params']['font_family_settings']; + $this->assertSame( $expected_message, $message ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::update_item + */ + public function test_update_item_invalid_font_family_id() { + $settings = array_diff_key( self::$default_settings, array( 'slug' => '' ) ); + + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::update_item + */ + public function test_update_item_no_permission() { + $settings = array_diff_key( self::$default_settings, array( 'slug' => '' ) ); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'POST', '/wp/v2/font-families/' . self::$font_family_id1 ); + $request->set_param( 'font_family_settings', wp_json_encode( $settings ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_edit', $response, 403 ); + } + + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item() { + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post(); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $request['force'] = true; + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNull( get_post( $font_family_id ) ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_trash() { + wp_set_current_user( self::$admin_id ); + $font_family_id = self::create_font_family_post(); + + // Attempt trashing. + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); + + $request->set_param( 'force', 'false' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_trash_not_supported', $response, 501 ); + + // Ensure the post still exists. + $post = get_post( $font_family_id ); + $this->assertNotEmpty( $post ); + + wp_delete_post( $font_family_id, true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_invalid_font_family_id() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . REST_TESTS_IMPOSSIBLY_HIGH_NUMBER ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_post_invalid_id', $response, 404 ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::delete_item + */ + public function test_delete_item_no_permissions() { + $font_family_id = self::create_font_family_post(); + + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 401 ); + + wp_set_current_user( self::$editor_id ); + $request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families/' . $font_family_id ); + $response = rest_get_server()->dispatch( $request ); + $this->assertErrorResponse( 'rest_cannot_delete', $response, 403 ); + + wp_delete_post( $font_family_id, true ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::prepare_item_for_response + */ + public function test_prepare_item() { + wp_set_current_user( self::$admin_id ); + $request = new WP_REST_Request( 'GET', '/wp/v2/font-families/' . self::$font_family_id2 ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $this->check_font_family_data( $data, self::$font_family_id2, $response->get_links() ); + } + + /** + * @covers WP_REST_Font_Faces_Controller::get_item_schema + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', '/wp/v2/font-families' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertSame( 200, $response->get_status() ); + $properties = $data['schema']['properties']; + $this->assertCount( 4, $properties ); + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'theme_json_version', $properties ); + $this->assertArrayHasKey( 'font_faces', $properties ); + $this->assertArrayHasKey( 'font_family_settings', $properties ); + } + + protected function check_font_family_data( $data, $post_id, $links ) { + $post = get_post( $post_id ); + + $this->assertArrayHasKey( 'id', $data ); + $this->assertSame( $post->ID, $data['id'] ); + + $this->assertArrayHasKey( 'theme_json_version', $data ); + $this->assertSame( WP_Theme_JSON::LATEST_SCHEMA, $data['theme_json_version'] ); + + $font_face_ids = get_children( + array( + 'fields' => 'ids', + 'post_parent' => $post_id, + 'post_type' => 'wp_font_face', + 'order' => 'ASC', + 'orderby' => 'ID', + ) + ); + $this->assertArrayHasKey( 'font_faces', $data ); + + foreach ( $font_face_ids as $font_face_id ) { + $this->assertContains( $font_face_id, $data['font_faces'] ); + } + + $this->assertArrayHasKey( 'font_family_settings', $data ); + $settings = $data['font_family_settings']; + $expected_settings = array( + 'name' => $post->post_title, + 'slug' => $post->post_name, + 'fontFamily' => $settings['fontFamily'], + 'preview' => $settings['preview'], + ); + $this->assertSame( $expected_settings, $settings ); + + $this->assertNotEmpty( $links ); + $this->assertSame( rest_url( 'wp/v2/font-families/' . $post->ID ), $links['self'][0]['href'] ); + $this->assertSame( rest_url( 'wp/v2/font-families' ), $links['collection'][0]['href'] ); + + if ( ! $font_face_ids ) { + return; + } + + // Check font_face links, if present. + $this->assertArrayHasKey( 'font_faces', $links ); + foreach ( $links['font_faces'] as $index => $link ) { + $this->assertSame( rest_url( 'wp/v2/font-families/' . $post->ID . '/font-faces/' . $font_face_ids[ $index ] ), $link['href'] ); + + $embeddable = isset( $link['attributes']['embeddable'] ) + ? $link['attributes']['embeddable'] + : $link['embeddable']; + $this->assertTrue( $embeddable ); + } + } +} diff --git a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php deleted file mode 100644 index e2d190cd76af1..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/base.php +++ /dev/null @@ -1,43 +0,0 @@ -factory->user->create( - array( - 'role' => 'administrator', - ) - ); - wp_set_current_user( $admin_id ); - } - - /** - * Tear down each test method. - */ - public function tear_down() { - parent::tear_down(); - - // Clean up the /fonts directory. - foreach ( $this->files_in_dir( static::$fonts_dir ) as $file ) { - @unlink( $file ); - } - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/installFonts.php b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/installFonts.php deleted file mode 100644 index 98c1cb6e13fe5..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/installFonts.php +++ /dev/null @@ -1,334 +0,0 @@ -set_param( 'font_family_settings', $font_family_json ); - $install_request->set_file_params( $files ); - $response = rest_get_server()->dispatch( $install_request ); - $data = $response->get_data(); - $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); - $this->assertCount( count( $expected_response['successes'] ), $data['successes'], 'Not all the font families were installed correctly.' ); - - // Checks that the font families were installed correctly. - for ( $family_index = 0; $family_index < count( $data['successes'] ); $family_index++ ) { - $installed_font = $data['successes'][ $family_index ]; - $expected_font = $expected_response['successes'][ $family_index ]; - - if ( isset( $installed_font['fontFace'] ) || isset( $expected_font['fontFace'] ) ) { - for ( $face_index = 0; $face_index < count( $installed_font['fontFace'] ); $face_index++ ) { - // Checks that the font asset were created correctly. - if ( isset( $installed_font['fontFace'][ $face_index ]['src'] ) ) { - $this->assertStringEndsWith( $expected_font['fontFace'][ $face_index ]['src'], $installed_font['fontFace'][ $face_index ]['src'], 'The src of the fonts were not updated as expected.' ); - } - // Removes the src from the response to compare the rest of the data. - unset( $installed_font['fontFace'][ $face_index ]['src'] ); - unset( $expected_font['fontFace'][ $face_index ]['src'] ); - unset( $installed_font['fontFace'][ $face_index ]['uploadedFile'] ); - } - } - - // Compares if the rest of the data is the same. - $this->assertEquals( $expected_font, $installed_font, 'The endpoint answer is not as expected.' ); - } - } - - /** - * Data provider for test_install_fonts - */ - public function data_install_fonts() { - - $temp_file_path1 = wp_tempnam( 'Piazzola1-' ); - copy( __DIR__ . '/../../../data/fonts/Merriweather.ttf', $temp_file_path1 ); - - $temp_file_path2 = wp_tempnam( 'Monteserrat-' ); - copy( __DIR__ . '/../../../data/fonts/Merriweather.ttf', $temp_file_path2 ); - - return array( - - 'google_fonts_to_download' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - ), - ), - ), - 'files' => array(), - 'expected_response' => array( - 'successes' => array( - array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => '/wp-content/fonts/piazzolla_normal_400.ttf', - ), - ), - ), - ), - 'errors' => array(), - ), - ), - - 'google_fonts_to_use_as_is' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - ), - ), - ), - 'files' => array(), - 'expected_response' => array( - 'successes' => array( - array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - ), - ), - ), - ), - 'errors' => array(), - ), - ), - - 'fonts_without_font_faces' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Arial', - 'slug' => 'arial', - 'name' => 'Arial', - ), - 'files' => array(), - 'expected_response' => array( - 'successes' => array( - array( - 'fontFamily' => 'Arial', - 'slug' => 'arial', - 'name' => 'Arial', - ), - ), - 'errors' => array(), - ), - ), - - 'fonts_with_local_fonts_assets' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'uploadedFile' => 'files0', - ), - ), - ), - 'files' => array( - 'files0' => array( - 'name' => 'piazzola1.ttf', - 'type' => 'font/ttf', - 'tmp_name' => $temp_file_path1, - 'error' => 0, - 'size' => 123, - ), - 'files1' => array( - 'name' => 'montserrat1.ttf', - 'type' => 'font/ttf', - 'tmp_name' => $temp_file_path2, - 'error' => 0, - 'size' => 123, - ), - ), - 'expected_response' => array( - 'successes' => array( - array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => '/wp-content/fonts/piazzolla_normal_400.ttf', - ), - ), - ), - ), - 'errors' => array(), - ), - ), - ); - } - - /** - * Tests failure when fonfaces has improper inputs - * - * @dataProvider data_install_with_improper_inputs - * - * @param array $font_families Font families to install in theme.json format. - * @param array $files Font files to install. - */ - public function test_install_with_improper_inputs( $font_families, $files = array() ) { - $install_request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $font_families_json = json_encode( $font_families ); - $install_request->set_param( 'font_families', $font_families_json ); - $install_request->set_file_params( $files ); - - $response = rest_get_server()->dispatch( $install_request ); - $this->assertSame( 400, $response->get_status() ); - } - - /** - * Data provider for test_install_with_improper_inputs - */ - public function data_install_with_improper_inputs() { - $temp_file_path1 = wp_tempnam( 'Piazzola1-' ); - file_put_contents( $temp_file_path1, 'Mocking file content' ); - - return array( - 'not a font families array' => array( - 'font_family_settings' => 'This is not an array', - ), - - 'empty array' => array( - 'font_family_settings' => array(), - ), - - 'without slug' => array( - 'font_family_settings' => array( - array( - 'fontFamily' => 'Piazzolla', - 'name' => 'Piazzolla', - ), - ), - ), - - 'with improper font face property' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'name' => 'Piazzolla', - 'slug' => 'piazzolla', - 'fontFace' => 'This is not an array', - ), - ), - - 'with empty font face property' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'name' => 'Piazzolla', - 'slug' => 'piazzolla', - 'fontFace' => array(), - ), - ), - - 'fontface referencing uploaded file without uploaded files' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'name' => 'Piazzolla', - 'slug' => 'piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'uploadedFile' => 'files0', - ), - ), - ), - 'files' => array(), - ), - - 'fontface referencing uploaded file without uploaded files' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'name' => 'Piazzolla', - 'slug' => 'piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'uploadedFile' => 'files666', - ), - ), - ), - 'files' => array( - 'files0' => array( - 'name' => 'piazzola1.ttf', - 'type' => 'font/ttf', - 'tmp_name' => $temp_file_path1, - 'error' => 0, - 'size' => 123, - ), - ), - ), - - 'fontface with incompatible properties (downloadFromUrl and uploadedFile together)' => array( - 'font_family_settings' => array( - 'fontFamily' => 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - 'uploadedFile' => 'files0', - ), - ), - ), - ), - ); - } -} diff --git a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/uninstallFonts.php b/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/uninstallFonts.php deleted file mode 100644 index 241f26284fe5d..0000000000000 --- a/phpunit/tests/fonts/font-library/wpRestFontFamiliesController/uninstallFonts.php +++ /dev/null @@ -1,96 +0,0 @@ - 'Piazzolla', - 'slug' => 'piazzolla', - 'name' => 'Piazzolla', - 'fontFace' => array( - array( - 'fontFamily' => 'Piazzolla', - 'fontStyle' => 'normal', - 'fontWeight' => '400', - 'src' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - 'downloadFromUrl' => 'http://fonts.gstatic.com/s/piazzolla/v33/N0b72SlTPu5rIkWIZjVgI-TckS03oGpPETyEJ88Rbvi0_TzOzKcQhZqx3gX9BRy5m5M.ttf', - ), - ), - ), - array( - 'fontFamily' => 'Montserrat', - 'slug' => 'montserrat', - 'name' => 'Montserrat', - 'fontFace' => array( - array( - 'fontFamily' => 'Montserrat', - 'fontStyle' => 'normal', - 'fontWeight' => '100', - 'src' => 'http://fonts.gstatic.com/s/montserrat/v25/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf', - 'downloadFromUrl' => 'http://fonts.gstatic.com/s/montserrat/v25/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtr6Uw-Y3tcoqK5.ttf', - ), - ), - ), - ); - - $install_request = new WP_REST_Request( 'POST', '/wp/v2/font-families' ); - $font_families_json = json_encode( $mock_families ); - $install_request->set_param( 'font_families', $font_families_json ); - rest_get_server()->dispatch( $install_request ); - } - - public function test_uninstall() { - $font_families_to_uninstall = array( - array( - 'slug' => 'piazzolla', - ), - array( - 'slug' => 'montserrat', - ), - ); - - $uninstall_request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families' ); - $uninstall_request->set_param( 'font_families', $font_families_to_uninstall ); - $response = rest_get_server()->dispatch( $uninstall_request ); - $this->assertSame( 200, $response->get_status(), 'The response status is not 200.' ); - } - - - public function test_uninstall_non_existing_fonts() { - $uninstall_request = new WP_REST_Request( 'DELETE', '/wp/v2/font-families' ); - - $non_existing_font_data = array( - array( - 'slug' => 'non-existing-font', - 'name' => 'Non existing font', - ), - array( - 'slug' => 'another-not-installed-font', - 'name' => 'Another not installed font', - ), - ); - - $uninstall_request->set_param( 'font_families', $non_existing_font_data ); - $response = rest_get_server()->dispatch( $uninstall_request ); - $data = $response->get_data(); - $this->assertCount( 2, $data['errors'], 'The response should have 2 errors, one for each font family uninstall failure.' ); - } -}