class-wp-theme-json-resolver.php (11464B)
1 <?php 2 /** 3 * WP_Theme_JSON_Resolver class 4 * 5 * @package WordPress 6 * @subpackage Theme 7 * @since 5.8.0 8 */ 9 10 /** 11 * Class that abstracts the processing of the different data sources 12 * for site-level config and offers an API to work with them. 13 * 14 * @access private 15 */ 16 class WP_Theme_JSON_Resolver { 17 18 /** 19 * Container for data coming from core. 20 * 21 * @since 5.8.0 22 * @var WP_Theme_JSON 23 */ 24 private static $core = null; 25 26 /** 27 * Container for data coming from the theme. 28 * 29 * @since 5.8.0 30 * @var WP_Theme_JSON 31 */ 32 private static $theme = null; 33 34 /** 35 * Whether or not the theme supports theme.json. 36 * 37 * @since 5.8.0 38 * @var bool 39 */ 40 private static $theme_has_support = null; 41 42 /** 43 * Structure to hold i18n metadata. 44 * 45 * @since 5.8.0 46 * @var array 47 */ 48 private static $theme_json_i18n = null; 49 50 /** 51 * Processes a file that adheres to the theme.json schema 52 * and returns an array with its contents, or a void array if none found. 53 * 54 * @since 5.8.0 55 * 56 * @param string $file_path Path to file. Empty if no file. 57 * @return array Contents that adhere to the theme.json schema. 58 */ 59 private static function read_json_file( $file_path ) { 60 $config = array(); 61 if ( $file_path ) { 62 $decoded_file = json_decode( 63 file_get_contents( $file_path ), 64 true 65 ); 66 67 $json_decoding_error = json_last_error(); 68 if ( JSON_ERROR_NONE !== $json_decoding_error ) { 69 trigger_error( "Error when decoding a theme.json schema at path $file_path " . json_last_error_msg() ); 70 return $config; 71 } 72 73 if ( is_array( $decoded_file ) ) { 74 $config = $decoded_file; 75 } 76 } 77 return $config; 78 } 79 80 /** 81 * Converts a tree as in i18n-theme.json into a linear array 82 * containing metadata to translate a theme.json file. 83 * 84 * For example, given this input: 85 * 86 * { 87 * "settings": { 88 * "*": { 89 * "typography": { 90 * "fontSizes": [ { "name": "Font size name" } ], 91 * "fontStyles": [ { "name": "Font size name" } ] 92 * } 93 * } 94 * } 95 * } 96 * 97 * will return this output: 98 * 99 * [ 100 * 0 => [ 101 * 'path' => [ 'settings', '*', 'typography', 'fontSizes' ], 102 * 'key' => 'name', 103 * 'context' => 'Font size name' 104 * ], 105 * 1 => [ 106 * 'path' => [ 'settings', '*', 'typography', 'fontStyles' ], 107 * 'key' => 'name', 108 * 'context' => 'Font style name' 109 * ] 110 * ] 111 * 112 * @since 5.8.0 113 * 114 * @param array $i18n_partial A tree that follows the format of i18n-theme.json. 115 * @param array $current_path Optional. Keeps track of the path as we walk down the given tree. 116 * Default empty array. 117 * @return array A linear array containing the paths to translate. 118 */ 119 private static function extract_paths_to_translate( $i18n_partial, $current_path = array() ) { 120 $result = array(); 121 foreach ( $i18n_partial as $property => $partial_child ) { 122 if ( is_numeric( $property ) ) { 123 foreach ( $partial_child as $key => $context ) { 124 $result[] = array( 125 'path' => $current_path, 126 'key' => $key, 127 'context' => $context, 128 ); 129 } 130 return $result; 131 } 132 $result = array_merge( 133 $result, 134 self::extract_paths_to_translate( $partial_child, array_merge( $current_path, array( $property ) ) ) 135 ); 136 } 137 return $result; 138 } 139 140 /** 141 * Returns a data structure used in theme.json translation. 142 * 143 * @since 5.8.0 144 * 145 * @return array An array of theme.json fields that are translatable and the keys that are translatable. 146 */ 147 public static function get_fields_to_translate() { 148 if ( null === self::$theme_json_i18n ) { 149 $file_structure = self::read_json_file( __DIR__ . '/theme-i18n.json' ); 150 self::$theme_json_i18n = self::extract_paths_to_translate( $file_structure ); 151 } 152 return self::$theme_json_i18n; 153 } 154 155 /** 156 * Translates a chunk of the loaded theme.json structure. 157 * 158 * @since 5.8.0 159 * 160 * @param array $array_to_translate The chunk of theme.json to translate. 161 * @param string $key The key of the field that contains the string to translate. 162 * @param string $context The context to apply in the translation call. 163 * @param string $domain Text domain. Unique identifier for retrieving translated strings. 164 * @return array Returns the modified $theme_json chunk. 165 */ 166 private static function translate_theme_json_chunk( array $array_to_translate, $key, $context, $domain ) { 167 foreach ( $array_to_translate as $item_key => $item_to_translate ) { 168 if ( empty( $item_to_translate[ $key ] ) ) { 169 continue; 170 } 171 172 // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralContext,WordPress.WP.I18n.NonSingularStringLiteralDomain 173 $array_to_translate[ $item_key ][ $key ] = translate_with_gettext_context( $array_to_translate[ $item_key ][ $key ], $context, $domain ); 174 } 175 176 return $array_to_translate; 177 } 178 179 /** 180 * Given a theme.json structure modifies it in place to update certain values 181 * by its translated strings according to the language set by the user. 182 * 183 * @since 5.8.0 184 * 185 * @param array $theme_json The theme.json to translate. 186 * @param string $domain Optional. Text domain. Unique identifier for retrieving translated strings. 187 * Default 'default'. 188 * @return array Returns the modified $theme_json_structure. 189 */ 190 private static function translate( $theme_json, $domain = 'default' ) { 191 $fields = self::get_fields_to_translate(); 192 foreach ( $fields as $field ) { 193 $path = $field['path']; 194 $key = $field['key']; 195 $context = $field['context']; 196 197 /* 198 * We need to process the paths that include '*' separately. 199 * One example of such a path would be: 200 * [ 'settings', 'blocks', '*', 'color', 'palette' ] 201 */ 202 $nodes_to_iterate = array_keys( $path, '*', true ); 203 if ( ! empty( $nodes_to_iterate ) ) { 204 /* 205 * At the moment, we only need to support one '*' in the path, so take it directly. 206 * - base will be [ 'settings', 'blocks' ] 207 * - data will be [ 'color', 'palette' ] 208 */ 209 $base_path = array_slice( $path, 0, $nodes_to_iterate[0] ); 210 $data_path = array_slice( $path, $nodes_to_iterate[0] + 1 ); 211 $base_tree = _wp_array_get( $theme_json, $base_path, array() ); 212 foreach ( $base_tree as $node_name => $node_data ) { 213 $array_to_translate = _wp_array_get( $node_data, $data_path, null ); 214 if ( is_null( $array_to_translate ) ) { 215 continue; 216 } 217 218 // Whole path will be [ 'settings', 'blocks', 'core/paragraph', 'color', 'palette' ]. 219 $whole_path = array_merge( $base_path, array( $node_name ), $data_path ); 220 $translated_array = self::translate_theme_json_chunk( $array_to_translate, $key, $context, $domain ); 221 _wp_array_set( $theme_json, $whole_path, $translated_array ); 222 } 223 } else { 224 $array_to_translate = _wp_array_get( $theme_json, $path, null ); 225 if ( is_null( $array_to_translate ) ) { 226 continue; 227 } 228 229 $translated_array = self::translate_theme_json_chunk( $array_to_translate, $key, $context, $domain ); 230 _wp_array_set( $theme_json, $path, $translated_array ); 231 } 232 } 233 234 return $theme_json; 235 } 236 237 /** 238 * Return core's origin config. 239 * 240 * @since 5.8.0 241 * 242 * @return WP_Theme_JSON Entity that holds core data. 243 */ 244 public static function get_core_data() { 245 if ( null !== self::$core ) { 246 return self::$core; 247 } 248 249 $config = self::read_json_file( __DIR__ . '/theme.json' ); 250 $config = self::translate( $config ); 251 self::$core = new WP_Theme_JSON( $config, 'core' ); 252 253 return self::$core; 254 } 255 256 /** 257 * Returns the theme's data. 258 * 259 * Data from theme.json can be augmented via the $theme_support_data variable. 260 * This is useful, for example, to backfill the gaps in theme.json that a theme 261 * has declared via add_theme_supports. 262 * 263 * Note that if the same data is present in theme.json and in $theme_support_data, 264 * the theme.json's is not overwritten. 265 * 266 * @since 5.8.0 267 * 268 * @param array $theme_support_data Optional. Theme support data in theme.json format. 269 * Default empty array. 270 * @return WP_Theme_JSON Entity that holds theme data. 271 */ 272 public static function get_theme_data( $theme_support_data = array() ) { 273 if ( null === self::$theme ) { 274 $theme_json_data = self::read_json_file( self::get_file_path_from_theme( 'theme.json' ) ); 275 $theme_json_data = self::translate( $theme_json_data, wp_get_theme()->get( 'TextDomain' ) ); 276 self::$theme = new WP_Theme_JSON( $theme_json_data ); 277 } 278 279 if ( empty( $theme_support_data ) ) { 280 return self::$theme; 281 } 282 283 /* 284 * We want the presets and settings declared in theme.json 285 * to override the ones declared via add_theme_support. 286 */ 287 $with_theme_supports = new WP_Theme_JSON( $theme_support_data ); 288 $with_theme_supports->merge( self::$theme ); 289 290 return $with_theme_supports; 291 } 292 293 /** 294 * There are different sources of data for a site: core and theme. 295 * 296 * While the getters {@link get_core_data}, {@link get_theme_data} return the raw data 297 * from the respective origins, this method merges them all together. 298 * 299 * If the same piece of data is declared in different origins (core and theme), 300 * the last origin overrides the previous. For example, if core disables custom colors 301 * but a theme enables them, the theme config wins. 302 * 303 * @since 5.8.0 304 * 305 * @param array $settings Optional. Existing block editor settings. Default empty array. 306 * @return WP_Theme_JSON 307 */ 308 public static function get_merged_data( $settings = array() ) { 309 $theme_support_data = WP_Theme_JSON::get_from_editor_settings( $settings ); 310 311 $result = new WP_Theme_JSON(); 312 $result->merge( self::get_core_data() ); 313 $result->merge( self::get_theme_data( $theme_support_data ) ); 314 315 return $result; 316 } 317 318 /** 319 * Whether the current theme has a theme.json file. 320 * 321 * @since 5.8.0 322 * 323 * @return bool 324 */ 325 public static function theme_has_support() { 326 if ( ! isset( self::$theme_has_support ) ) { 327 self::$theme_has_support = (bool) self::get_file_path_from_theme( 'theme.json' ); 328 } 329 330 return self::$theme_has_support; 331 } 332 333 /** 334 * Builds the path to the given file and checks that it is readable. 335 * 336 * If it isn't, returns an empty string, otherwise returns the whole file path. 337 * 338 * @since 5.8.0 339 * 340 * @param string $file_name Name of the file. 341 * @return string The whole file path or empty if the file doesn't exist. 342 */ 343 private static function get_file_path_from_theme( $file_name ) { 344 /* 345 * This used to be a locate_template call. However, that method proved problematic 346 * due to its use of constants (STYLESHEETPATH) that threw errors in some scenarios. 347 * 348 * When the theme.json merge algorithm properly supports child themes, 349 * this should also fall back to the template path, as locate_template did. 350 */ 351 $located = ''; 352 $candidate = get_stylesheet_directory() . '/' . $file_name; 353 if ( is_readable( $candidate ) ) { 354 $located = $candidate; 355 } 356 return $located; 357 } 358 359 /** 360 * Cleans the cached data so it can be recalculated. 361 * 362 * @since 5.8.0 363 */ 364 public static function clean_cached_data() { 365 self::$core = null; 366 self::$theme = null; 367 self::$theme_has_support = null; 368 self::$theme_json_i18n = null; 369 } 370 371 }