diff -r be944660c56a -r 3d72ae0968f4 wp/wp-includes/class-wp-theme-json-resolver.php --- a/wp/wp-includes/class-wp-theme-json-resolver.php Wed Sep 21 18:19:35 2022 +0200 +++ b/wp/wp-includes/class-wp-theme-json-resolver.php Tue Sep 27 16:37:53 2022 +0200 @@ -11,6 +11,10 @@ * Class that abstracts the processing of the different data sources * for site-level config and offers an API to work with them. * + * This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes). + * This is a low-level API that may need to do breaking changes. Please, + * use get_global_settings, get_global_styles, and get_global_stylesheet instead. + * * @access private */ class WP_Theme_JSON_Resolver { @@ -21,7 +25,7 @@ * @since 5.8.0 * @var WP_Theme_JSON */ - private static $core = null; + protected static $core = null; /** * Container for data coming from the theme. @@ -29,7 +33,7 @@ * @since 5.8.0 * @var WP_Theme_JSON */ - private static $theme = null; + protected static $theme = null; /** * Whether or not the theme supports theme.json. @@ -37,15 +41,33 @@ * @since 5.8.0 * @var bool */ - private static $theme_has_support = null; + protected static $theme_has_support = null; + + /** + * Container for data coming from the user. + * + * @since 5.9.0 + * @var WP_Theme_JSON + */ + protected static $user = null; /** - * Structure to hold i18n metadata. + * Stores the ID of the custom post type + * that holds the user data. * - * @since 5.8.0 + * @since 5.9.0 + * @var int + */ + protected static $user_custom_post_type_id = null; + + /** + * Container to keep loaded i18n schema for `theme.json`. + * + * @since 5.8.0 As `$theme_json_i18n`. + * @since 5.9.0 Renamed from `$theme_json_i18n` to `$i18n_schema`. * @var array */ - private static $theme_json_i18n = null; + protected static $i18n_schema = null; /** * Processes a file that adheres to the theme.json schema @@ -56,20 +78,10 @@ * @param string $file_path Path to file. Empty if no file. * @return array Contents that adhere to the theme.json schema. */ - private static function read_json_file( $file_path ) { + protected static function read_json_file( $file_path ) { $config = array(); if ( $file_path ) { - $decoded_file = json_decode( - file_get_contents( $file_path ), - true - ); - - $json_decoding_error = json_last_error(); - if ( JSON_ERROR_NONE !== $json_decoding_error ) { - trigger_error( "Error when decoding a theme.json schema at path $file_path " . json_last_error_msg() ); - return $config; - } - + $decoded_file = wp_json_file_decode( $file_path, array( 'associative' => true ) ); if ( is_array( $decoded_file ) ) { $config = $decoded_file; } @@ -78,102 +90,16 @@ } /** - * Converts a tree as in i18n-theme.json into a linear array - * containing metadata to translate a theme.json file. - * - * For example, given this input: - * - * { - * "settings": { - * "*": { - * "typography": { - * "fontSizes": [ { "name": "Font size name" } ], - * "fontStyles": [ { "name": "Font size name" } ] - * } - * } - * } - * } - * - * will return this output: - * - * [ - * 0 => [ - * 'path' => [ 'settings', '*', 'typography', 'fontSizes' ], - * 'key' => 'name', - * 'context' => 'Font size name' - * ], - * 1 => [ - * 'path' => [ 'settings', '*', 'typography', 'fontStyles' ], - * 'key' => 'name', - * 'context' => 'Font style name' - * ] - * ] - * - * @since 5.8.0 - * - * @param array $i18n_partial A tree that follows the format of i18n-theme.json. - * @param array $current_path Optional. Keeps track of the path as we walk down the given tree. - * Default empty array. - * @return array A linear array containing the paths to translate. - */ - private static function extract_paths_to_translate( $i18n_partial, $current_path = array() ) { - $result = array(); - foreach ( $i18n_partial as $property => $partial_child ) { - if ( is_numeric( $property ) ) { - foreach ( $partial_child as $key => $context ) { - $result[] = array( - 'path' => $current_path, - 'key' => $key, - 'context' => $context, - ); - } - return $result; - } - $result = array_merge( - $result, - self::extract_paths_to_translate( $partial_child, array_merge( $current_path, array( $property ) ) ) - ); - } - return $result; - } - - /** * Returns a data structure used in theme.json translation. * * @since 5.8.0 + * @deprecated 5.9.0 * * @return array An array of theme.json fields that are translatable and the keys that are translatable. */ public static function get_fields_to_translate() { - if ( null === self::$theme_json_i18n ) { - $file_structure = self::read_json_file( __DIR__ . '/theme-i18n.json' ); - self::$theme_json_i18n = self::extract_paths_to_translate( $file_structure ); - } - return self::$theme_json_i18n; - } - - /** - * Translates a chunk of the loaded theme.json structure. - * - * @since 5.8.0 - * - * @param array $array_to_translate The chunk of theme.json to translate. - * @param string $key The key of the field that contains the string to translate. - * @param string $context The context to apply in the translation call. - * @param string $domain Text domain. Unique identifier for retrieving translated strings. - * @return array Returns the modified $theme_json chunk. - */ - private static function translate_theme_json_chunk( array $array_to_translate, $key, $context, $domain ) { - foreach ( $array_to_translate as $item_key => $item_to_translate ) { - if ( empty( $item_to_translate[ $key ] ) ) { - continue; - } - - // phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralContext,WordPress.WP.I18n.NonSingularStringLiteralDomain - $array_to_translate[ $item_key ][ $key ] = translate_with_gettext_context( $array_to_translate[ $item_key ][ $key ], $context, $domain ); - } - - return $array_to_translate; + _deprecated_function( __METHOD__, '5.9.0' ); + return array(); } /** @@ -187,147 +113,322 @@ * Default 'default'. * @return array Returns the modified $theme_json_structure. */ - private static function translate( $theme_json, $domain = 'default' ) { - $fields = self::get_fields_to_translate(); - foreach ( $fields as $field ) { - $path = $field['path']; - $key = $field['key']; - $context = $field['context']; - - /* - * We need to process the paths that include '*' separately. - * One example of such a path would be: - * [ 'settings', 'blocks', '*', 'color', 'palette' ] - */ - $nodes_to_iterate = array_keys( $path, '*', true ); - if ( ! empty( $nodes_to_iterate ) ) { - /* - * At the moment, we only need to support one '*' in the path, so take it directly. - * - base will be [ 'settings', 'blocks' ] - * - data will be [ 'color', 'palette' ] - */ - $base_path = array_slice( $path, 0, $nodes_to_iterate[0] ); - $data_path = array_slice( $path, $nodes_to_iterate[0] + 1 ); - $base_tree = _wp_array_get( $theme_json, $base_path, array() ); - foreach ( $base_tree as $node_name => $node_data ) { - $array_to_translate = _wp_array_get( $node_data, $data_path, null ); - if ( is_null( $array_to_translate ) ) { - continue; - } - - // Whole path will be [ 'settings', 'blocks', 'core/paragraph', 'color', 'palette' ]. - $whole_path = array_merge( $base_path, array( $node_name ), $data_path ); - $translated_array = self::translate_theme_json_chunk( $array_to_translate, $key, $context, $domain ); - _wp_array_set( $theme_json, $whole_path, $translated_array ); - } - } else { - $array_to_translate = _wp_array_get( $theme_json, $path, null ); - if ( is_null( $array_to_translate ) ) { - continue; - } - - $translated_array = self::translate_theme_json_chunk( $array_to_translate, $key, $context, $domain ); - _wp_array_set( $theme_json, $path, $translated_array ); - } + protected static function translate( $theme_json, $domain = 'default' ) { + if ( null === static::$i18n_schema ) { + $i18n_schema = wp_json_file_decode( __DIR__ . '/theme-i18n.json' ); + static::$i18n_schema = null === $i18n_schema ? array() : $i18n_schema; } - return $theme_json; + return translate_settings_using_i18n_schema( static::$i18n_schema, $theme_json, $domain ); } /** - * Return core's origin config. + * Returns core's origin config. * * @since 5.8.0 * * @return WP_Theme_JSON Entity that holds core data. */ public static function get_core_data() { - if ( null !== self::$core ) { - return self::$core; + if ( null !== static::$core ) { + return static::$core; } - $config = self::read_json_file( __DIR__ . '/theme.json' ); - $config = self::translate( $config ); - self::$core = new WP_Theme_JSON( $config, 'core' ); + $config = static::read_json_file( __DIR__ . '/theme.json' ); + $config = static::translate( $config ); + static::$core = new WP_Theme_JSON( $config, 'default' ); - return self::$core; + return static::$core; } /** * Returns the theme's data. * - * Data from theme.json can be augmented via the $theme_support_data variable. - * This is useful, for example, to backfill the gaps in theme.json that a theme - * has declared via add_theme_supports. - * - * Note that if the same data is present in theme.json and in $theme_support_data, - * the theme.json's is not overwritten. + * Data from theme.json will be backfilled from existing + * theme supports, if any. Note that if the same data + * is present in theme.json and in theme supports, + * the theme.json takes precedence. * * @since 5.8.0 + * @since 5.9.0 Theme supports have been inlined and the `$theme_support_data` argument removed. + * @since 6.0.0 Added an `$options` parameter to allow the theme data to be returned without theme supports. * - * @param array $theme_support_data Optional. Theme support data in theme.json format. - * Default empty array. + * @param array $deprecated Deprecated. Not used. + * @param array $options { + * Options arguments. + * + * @type bool $with_supports Whether to include theme supports in the data. Default true. + * } * @return WP_Theme_JSON Entity that holds theme data. */ - public static function get_theme_data( $theme_support_data = array() ) { - if ( null === self::$theme ) { - $theme_json_data = self::read_json_file( self::get_file_path_from_theme( 'theme.json' ) ); - $theme_json_data = self::translate( $theme_json_data, wp_get_theme()->get( 'TextDomain' ) ); - self::$theme = new WP_Theme_JSON( $theme_json_data ); + public static function get_theme_data( $deprecated = array(), $options = array() ) { + if ( ! empty( $deprecated ) ) { + _deprecated_argument( __METHOD__, '5.9.0' ); } - if ( empty( $theme_support_data ) ) { - return self::$theme; + $options = wp_parse_args( $options, array( 'with_supports' => true ) ); + + if ( null === static::$theme ) { + $theme_json_data = static::read_json_file( static::get_file_path_from_theme( 'theme.json' ) ); + $theme_json_data = static::translate( $theme_json_data, wp_get_theme()->get( 'TextDomain' ) ); + static::$theme = new WP_Theme_JSON( $theme_json_data ); + + if ( wp_get_theme()->parent() ) { + // Get parent theme.json. + $parent_theme_json_data = static::read_json_file( static::get_file_path_from_theme( 'theme.json', true ) ); + $parent_theme_json_data = static::translate( $parent_theme_json_data, wp_get_theme()->parent()->get( 'TextDomain' ) ); + $parent_theme = new WP_Theme_JSON( $parent_theme_json_data ); + + // Merge the child theme.json into the parent theme.json. + // The child theme takes precedence over the parent. + $parent_theme->merge( static::$theme ); + static::$theme = $parent_theme; + } + } + + if ( ! $options['with_supports'] ) { + return static::$theme; } /* * We want the presets and settings declared in theme.json - * to override the ones declared via add_theme_support. + * to override the ones declared via theme supports. + * So we take theme supports, transform it to theme.json shape + * and merge the static::$theme upon that. */ + $theme_support_data = WP_Theme_JSON::get_from_editor_settings( get_default_block_editor_settings() ); + if ( ! static::theme_has_support() ) { + if ( ! isset( $theme_support_data['settings']['color'] ) ) { + $theme_support_data['settings']['color'] = array(); + } + + $default_palette = false; + if ( current_theme_supports( 'default-color-palette' ) ) { + $default_palette = true; + } + if ( ! isset( $theme_support_data['settings']['color']['palette'] ) ) { + // If the theme does not have any palette, we still want to show the core one. + $default_palette = true; + } + $theme_support_data['settings']['color']['defaultPalette'] = $default_palette; + + $default_gradients = false; + if ( current_theme_supports( 'default-gradient-presets' ) ) { + $default_gradients = true; + } + if ( ! isset( $theme_support_data['settings']['color']['gradients'] ) ) { + // If the theme does not have any gradients, we still want to show the core ones. + $default_gradients = true; + } + $theme_support_data['settings']['color']['defaultGradients'] = $default_gradients; + + // Classic themes without a theme.json don't support global duotone. + $theme_support_data['settings']['color']['defaultDuotone'] = false; + } $with_theme_supports = new WP_Theme_JSON( $theme_support_data ); - $with_theme_supports->merge( self::$theme ); + $with_theme_supports->merge( static::$theme ); return $with_theme_supports; } /** - * There are different sources of data for a site: core and theme. + * Returns the custom post type that contains the user's origin config + * for the active theme or a void array if none are found. + * + * This can also create and return a new draft custom post type. + * + * @since 5.9.0 * - * While the getters {@link get_core_data}, {@link get_theme_data} return the raw data - * from the respective origins, this method merges them all together. + * @param WP_Theme $theme The theme object. If empty, it + * defaults to the active theme. + * @param bool $create_post Optional. Whether a new custom post + * type should be created if none are + * found. Default false. + * @param array $post_status_filter Optional. Filter custom post type by + * post status. Default `array( 'publish' )`, + * so it only fetches published posts. + * @return array Custom Post Type for the user's origin config. + */ + public static function get_user_data_from_wp_global_styles( $theme, $create_post = false, $post_status_filter = array( 'publish' ) ) { + if ( ! $theme instanceof WP_Theme ) { + $theme = wp_get_theme(); + } + $user_cpt = array(); + $post_type_filter = 'wp_global_styles'; + $args = array( + 'numberposts' => 1, + 'orderby' => 'date', + 'order' => 'desc', + 'post_type' => $post_type_filter, + 'post_status' => $post_status_filter, + 'tax_query' => array( + array( + 'taxonomy' => 'wp_theme', + 'field' => 'name', + 'terms' => $theme->get_stylesheet(), + ), + ), + ); + + $cache_key = sprintf( 'wp_global_styles_%s', md5( serialize( $args ) ) ); + $post_id = wp_cache_get( $cache_key ); + + if ( (int) $post_id > 0 ) { + return get_post( $post_id, ARRAY_A ); + } + + // Special case: '-1' is a results not found. + if ( -1 === $post_id && ! $create_post ) { + return $user_cpt; + } + + $recent_posts = wp_get_recent_posts( $args ); + if ( is_array( $recent_posts ) && ( count( $recent_posts ) === 1 ) ) { + $user_cpt = $recent_posts[0]; + } elseif ( $create_post ) { + $cpt_post_id = wp_insert_post( + array( + 'post_content' => '{"version": ' . WP_Theme_JSON::LATEST_SCHEMA . ', "isGlobalStylesUserThemeJSON": true }', + 'post_status' => 'publish', + 'post_title' => 'Custom Styles', + 'post_type' => $post_type_filter, + 'post_name' => 'wp-global-styles-' . urlencode( wp_get_theme()->get_stylesheet() ), + 'tax_input' => array( + 'wp_theme' => array( wp_get_theme()->get_stylesheet() ), + ), + ), + true + ); + $user_cpt = get_post( $cpt_post_id, ARRAY_A ); + } + $cache_expiration = $user_cpt ? DAY_IN_SECONDS : HOUR_IN_SECONDS; + wp_cache_set( $cache_key, $user_cpt ? $user_cpt['ID'] : -1, '', $cache_expiration ); + + return $user_cpt; + } + + /** + * Returns the user's origin config. * - * If the same piece of data is declared in different origins (core and theme), - * the last origin overrides the previous. For example, if core disables custom colors - * but a theme enables them, the theme config wins. + * @since 5.9.0 + * + * @return WP_Theme_JSON Entity that holds styles for user data. + */ + public static function get_user_data() { + if ( null !== static::$user ) { + return static::$user; + } + + $config = array(); + $user_cpt = static::get_user_data_from_wp_global_styles( wp_get_theme() ); + + if ( array_key_exists( 'post_content', $user_cpt ) ) { + $decoded_data = json_decode( $user_cpt['post_content'], true ); + + $json_decoding_error = json_last_error(); + if ( JSON_ERROR_NONE !== $json_decoding_error ) { + trigger_error( 'Error when decoding a theme.json schema for user data. ' . json_last_error_msg() ); + return new WP_Theme_JSON( $config, 'custom' ); + } + + // Very important to verify that the flag isGlobalStylesUserThemeJSON is true. + // If it's not true then the content was not escaped and is not safe. + if ( + is_array( $decoded_data ) && + isset( $decoded_data['isGlobalStylesUserThemeJSON'] ) && + $decoded_data['isGlobalStylesUserThemeJSON'] + ) { + unset( $decoded_data['isGlobalStylesUserThemeJSON'] ); + $config = $decoded_data; + } + } + static::$user = new WP_Theme_JSON( $config, 'custom' ); + + return static::$user; + } + + /** + * Returns the data merged from multiple origins. + * + * There are three sources of data (origins) for a site: + * default, theme, and custom. The custom's has higher priority + * than the theme's, and the theme's higher than default's. + * + * Unlike the getters + * {@link https://developer.wordpress.org/reference/classes/wp_theme_json_resolver/get_core_data/ get_core_data}, + * {@link https://developer.wordpress.org/reference/classes/wp_theme_json_resolver/get_theme_data/ get_theme_data}, + * and {@link https://developer.wordpress.org/reference/classes/wp_theme_json_resolver/get_user_data/ get_user_data}, + * this method returns data after it has been merged with the previous origins. + * This means that if the same piece of data is declared in different origins + * (user, theme, and core), the last origin overrides the previous. + * + * For example, if the user has set a background color + * for the paragraph block, and the theme has done it as well, + * the user preference wins. * * @since 5.8.0 + * @since 5.9.0 Added user data, removed the `$settings` parameter, + * added the `$origin` parameter. * - * @param array $settings Optional. Existing block editor settings. Default empty array. + * @param string $origin Optional. To what level should we merge data. + * Valid values are 'theme' or 'custom'. Default 'custom'. * @return WP_Theme_JSON */ - public static function get_merged_data( $settings = array() ) { - $theme_support_data = WP_Theme_JSON::get_from_editor_settings( $settings ); + public static function get_merged_data( $origin = 'custom' ) { + if ( is_array( $origin ) ) { + _deprecated_argument( __FUNCTION__, '5.9.0' ); + } $result = new WP_Theme_JSON(); - $result->merge( self::get_core_data() ); - $result->merge( self::get_theme_data( $theme_support_data ) ); + $result->merge( static::get_core_data() ); + $result->merge( static::get_theme_data() ); + + if ( 'custom' === $origin ) { + $result->merge( static::get_user_data() ); + } return $result; } /** - * Whether the current theme has a theme.json file. + * Returns the ID of the custom post type + * that stores user data. + * + * @since 5.9.0 + * + * @return integer|null + */ + public static function get_user_global_styles_post_id() { + if ( null !== static::$user_custom_post_type_id ) { + return static::$user_custom_post_type_id; + } + + $user_cpt = static::get_user_data_from_wp_global_styles( wp_get_theme(), true ); + + if ( array_key_exists( 'ID', $user_cpt ) ) { + static::$user_custom_post_type_id = $user_cpt['ID']; + } + + return static::$user_custom_post_type_id; + } + + /** + * Determines whether the active theme has a theme.json file. * * @since 5.8.0 + * @since 5.9.0 Added a check in the parent theme. * * @return bool */ public static function theme_has_support() { - if ( ! isset( self::$theme_has_support ) ) { - self::$theme_has_support = (bool) self::get_file_path_from_theme( 'theme.json' ); + if ( ! isset( static::$theme_has_support ) ) { + static::$theme_has_support = ( + is_readable( static::get_file_path_from_theme( 'theme.json' ) ) || + is_readable( static::get_file_path_from_theme( 'theme.json', true ) ) + ); } - return self::$theme_has_support; + return static::$theme_has_support; } /** @@ -336,36 +437,62 @@ * If it isn't, returns an empty string, otherwise returns the whole file path. * * @since 5.8.0 + * @since 5.9.0 Adapted to work with child themes, added the `$template` argument. * * @param string $file_name Name of the file. + * @param bool $template Optional. Use template theme directory. Default false. * @return string The whole file path or empty if the file doesn't exist. */ - private static function get_file_path_from_theme( $file_name ) { - /* - * This used to be a locate_template call. However, that method proved problematic - * due to its use of constants (STYLESHEETPATH) that threw errors in some scenarios. - * - * When the theme.json merge algorithm properly supports child themes, - * this should also fall back to the template path, as locate_template did. - */ - $located = ''; - $candidate = get_stylesheet_directory() . '/' . $file_name; - if ( is_readable( $candidate ) ) { - $located = $candidate; - } - return $located; + protected static function get_file_path_from_theme( $file_name, $template = false ) { + $path = $template ? get_template_directory() : get_stylesheet_directory(); + $candidate = $path . '/' . $file_name; + + return is_readable( $candidate ) ? $candidate : ''; } /** * Cleans the cached data so it can be recalculated. * * @since 5.8.0 + * @since 5.9.0 Added the `$user`, `$user_custom_post_type_id`, + * and `$i18n_schema` variables to reset. */ public static function clean_cached_data() { - self::$core = null; - self::$theme = null; - self::$theme_has_support = null; - self::$theme_json_i18n = null; + static::$core = null; + static::$theme = null; + static::$user = null; + static::$user_custom_post_type_id = null; + static::$theme_has_support = null; + static::$i18n_schema = null; + } + + /** + * Returns the style variations defined by the theme. + * + * @since 6.0.0 + * + * @return array + */ + public static function get_style_variations() { + $variations = array(); + $base_directory = get_stylesheet_directory() . '/styles'; + if ( is_dir( $base_directory ) ) { + $nested_files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base_directory ) ); + $nested_html_files = iterator_to_array( new RegexIterator( $nested_files, '/^.+\.json$/i', RecursiveRegexIterator::GET_MATCH ) ); + ksort( $nested_html_files ); + foreach ( $nested_html_files as $path => $file ) { + $decoded_file = wp_json_file_decode( $path, array( 'associative' => true ) ); + if ( is_array( $decoded_file ) ) { + $translated = static::translate( $decoded_file, wp_get_theme()->get( 'TextDomain' ) ); + $variation = ( new WP_Theme_JSON( $translated ) )->get_raw_data(); + if ( empty( $variation['title'] ) ) { + $variation['title'] = basename( $path, '.json' ); + } + $variations[] = $variation; + } + } + } + return $variations; } }