1<?php
2/**
3 * Font Collection class.
4 *
5 * This file contains the Font Collection class definition.
6 *
7 * @package WordPress
8 * @subpackage Fonts
9 * @since 6.5.0
10 */
11
12/**
13 * Font Collection class.
14 *
15 * @since 6.5.0
16 *
17 * @see wp_register_font_collection()
18 */
19final class WP_Font_Collection {
20 /**
21 * The unique slug for the font collection.
22 *
23 * @since 6.5.0
24 * @var string
25 */
26 public $slug;
27
28 /**
29 * Font collection data.
30 *
31 * @since 6.5.0
32 * @var array|WP_Error|null
33 */
34 private $data;
35
36 /**
37 * Font collection JSON file path or URL.
38 *
39 * @since 6.5.0
40 * @var string|null
41 */
42 private $src;
43
44 /**
45 * WP_Font_Collection constructor.
46 *
47 * @since 6.5.0
48 *
49 * @param string $slug Font collection slug. May only contain alphanumeric characters, dashes,
50 * and underscores. See sanitize_title().
51 * @param array $args Font collection data. See wp_register_font_collection() for information on accepted arguments.
52 */
53 public function __construct( string $slug, array $args ) {
54 $this->slug = sanitize_title( $slug );
55 if ( $this->slug !== $slug ) {
56 _doing_it_wrong(
57 __METHOD__,
58 /* translators: %s: Font collection slug. */
59 sprintf( __( 'Font collection slug "%s" is not valid. Slugs must use only alphanumeric characters, dashes, and underscores.' ), $slug ),
60 '6.5.0'
61 );
62 }
63
64 $required_properties = array( 'name', 'font_families' );
65
66 if ( isset( $args['font_families'] ) && is_string( $args['font_families'] ) ) {
67 // JSON data is lazy loaded by ::get_data().
68 $this->src = $args['font_families'];
69 unset( $args['font_families'] );
70
71 $required_properties = array( 'name' );
72 }
73
74 $this->data = $this->sanitize_and_validate_data( $args, $required_properties );
75 }
76
77 /**
78 * Retrieves the font collection data.
79 *
80 * @since 6.5.0
81 *
82 * @return array|WP_Error An array containing the font collection data, or a WP_Error on failure.
83 */
84 public function get_data() {
85 if ( is_wp_error( $this->data ) ) {
86 return $this->data;
87 }
88
89 // If the collection uses JSON data, load it and cache the data/error.
90 if ( isset( $this->src ) ) {
91 $this->data = $this->load_from_json( $this->src );
92 }
93
94 if ( is_wp_error( $this->data ) ) {
95 return $this->data;
96 }
97
98 // Set defaults for optional properties.
99 $defaults = array(
100 'description' => '',
101 'categories' => array(),
102 );
103
104 return wp_parse_args( $this->data, $defaults );
105 }
106
107 /**
108 * Loads font collection data from a JSON file or URL.
109 *
110 * @since 6.5.0
111 *
112 * @param string $file_or_url File path or URL to a JSON file containing the font collection data.
113 * @return array|WP_Error An array containing the font collection data on success,
114 * else an instance of WP_Error on failure.
115 */
116 private function load_from_json( $file_or_url ) {
117 $url = wp_http_validate_url( $file_or_url );
118 $file = file_exists( $file_or_url ) ? wp_normalize_path( realpath( $file_or_url ) ) : false;
119
120 if ( ! $url && ! $file ) {
121 // translators: %s: File path or URL to font collection JSON file.
122 $message = __( 'Font collection JSON file is invalid or does not exist.' );
123 _doing_it_wrong( __METHOD__, $message, '6.5.0' );
124 return new WP_Error( 'font_collection_json_missing', $message );
125 }
126
127 $data = $url ? $this->load_from_url( $url ) : $this->load_from_file( $file );
128
129 if ( is_wp_error( $data ) ) {
130 return $data;
131 }
132
133 $data = array(
134 'name' => $this->data['name'],
135 'font_families' => $data['font_families'],
136 );
137
138 if ( isset( $this->data['description'] ) ) {
139 $data['description'] = $this->data['description'];
140 }
141
142 if ( isset( $this->data['categories'] ) ) {
143 $data['categories'] = $this->data['categories'];
144 }
145
146 return $data;
147 }
148
149 /**
150 * Loads the font collection data from a JSON file path.
151 *
152 * @since 6.5.0
153 *
154 * @param string $file File path to a JSON file containing the font collection data.
155 * @return array|WP_Error An array containing the font collection data on success,
156 * else an instance of WP_Error on failure.
157 */
158 private function load_from_file( $file ) {
159 $data = wp_json_file_decode( $file, array( 'associative' => true ) );
160 if ( empty( $data ) ) {
161 return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection JSON file contents.' ) );
162 }
163
164 return $this->sanitize_and_validate_data( $data, array( 'font_families' ) );
165 }
166
167 /**
168 * Loads the font collection data from a JSON file URL.
169 *
170 * @since 6.5.0
171 *
172 * @param string $url URL to a JSON file containing the font collection data.
173 * @return array|WP_Error An array containing the font collection data on success,
174 * else an instance of WP_Error on failure.
175 */
176 private function load_from_url( $url ) {
177 // Limit key to 167 characters to avoid failure in the case of a long URL.
178 $transient_key = substr( 'wp_font_collection_url_' . $url, 0, 167 );
179 $data = get_site_transient( $transient_key );
180
181 if ( false === $data ) {
182 $response = wp_safe_remote_get( $url );
183 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
184 return new WP_Error(
185 'font_collection_request_error',
186 sprintf(
187 // translators: %s: Font collection URL.
188 __( 'Error fetching the font collection data from "%s".' ),
189 $url
190 )
191 );
192 }
193
194 $data = json_decode( wp_remote_retrieve_body( $response ), true );
195 if ( empty( $data ) ) {
196 return new WP_Error( 'font_collection_decode_error', __( 'Error decoding the font collection data from the HTTP response JSON.' ) );
197 }
198
199 // Make sure the data is valid before storing it in a transient.
200 $data = $this->sanitize_and_validate_data( $data, array( 'font_families' ) );
201 if ( is_wp_error( $data ) ) {
202 return $data;
203 }
204
205 set_site_transient( $transient_key, $data, DAY_IN_SECONDS );
206 }
207
208 return $data;
209 }
210
211 /**
212 * Sanitizes and validates the font collection data.
213 *
214 * @since 6.5.0
215 *
216 * @param array $data Font collection data to sanitize and validate.
217 * @param array $required_properties Required properties that must exist in the passed data.
218 * @return array|WP_Error Sanitized data if valid, otherwise a WP_Error instance.
219 */
220 private function sanitize_and_validate_data( $data, $required_properties = array() ) {
221 $schema = self::get_sanitization_schema();
222 $data = WP_Font_Utils::sanitize_from_schema( $data, $schema );
223
224 foreach ( $required_properties as $property ) {
225 if ( empty( $data[ $property ] ) ) {
226 $message = sprintf(
227 // translators: 1: Font collection slug, 2: Missing property name, e.g. "font_families".
228 __( 'Font collection "%1$s" has missing or empty property: "%2$s".' ),
229 $this->slug,
230 $property
231 );
232 _doing_it_wrong( __METHOD__, $message, '6.5.0' );
233 return new WP_Error( 'font_collection_missing_property', $message );
234 }
235 }
236
237 return $data;
238 }
239
240 /**
241 * Retrieves the font collection sanitization schema.
242 *
243 * @since 6.5.0
244 *
245 * @return array Font collection sanitization schema.
246 */
247 private static function get_sanitization_schema() {
248 return array(
249 'name' => 'sanitize_text_field',
250 'description' => 'sanitize_text_field',
251 'font_families' => array(
252 array(
253 'font_family_settings' => array(
254 'name' => 'sanitize_text_field',
255 'slug' => static function ( $value ) {
256 return _wp_to_kebab_case( sanitize_title( $value ) );
257 },
258 'fontFamily' => 'WP_Font_Utils::sanitize_font_family',
259 'preview' => 'sanitize_url',
260 'fontFace' => array(
261 array(
262 'fontFamily' => 'sanitize_text_field',
263 'fontStyle' => 'sanitize_text_field',
264 'fontWeight' => 'sanitize_text_field',
265 'src' => static function ( $value ) {
266 return is_array( $value )
267 ? array_map( 'sanitize_text_field', $value )
268 : sanitize_text_field( $value );
269 },
270 'preview' => 'sanitize_url',
271 'fontDisplay' => 'sanitize_text_field',
272 'fontStretch' => 'sanitize_text_field',
273 'ascentOverride' => 'sanitize_text_field',
274 'descentOverride' => 'sanitize_text_field',
275 'fontVariant' => 'sanitize_text_field',
276 'fontFeatureSettings' => 'sanitize_text_field',
277 'fontVariationSettings' => 'sanitize_text_field',
278 'lineGapOverride' => 'sanitize_text_field',
279 'sizeAdjust' => 'sanitize_text_field',
280 'unicodeRange' => 'sanitize_text_field',
281 ),
282 ),
283 ),
284 'categories' => array( 'sanitize_title' ),
285 ),
286 ),
287 'categories' => array(
288 array(
289 'name' => 'sanitize_text_field',
290 'slug' => 'sanitize_title',
291 ),
292 ),
293 );
294 }
295}
296