Blocks: Add support for variations in block.json` file

We integrated variations with block types and the corresponding REST API endpoint in #52688. It's a follow-up patch to add missing support to the `block.json` metadata file when using `register_block_type`.

Some fields for variations are translatable.Therefore, i18n schema was copied over from Gutenberg: https://github.com/WordPress/gutenberg/blob/trunk/packages/blocks/src/api/i18n-block.json. The accompanying implementation was adapted as `translate_settings_using_i18n_schema`.

Props: gwwar, swissspidy, schlessera, jorgefilipecosta.
Fixes #53238.



git-svn-id: https://develop.svn.wordpress.org/trunk@51599 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Greg Ziółkowski 2021-08-11 09:06:31 +00:00
parent 9b565f7907
commit d9599addd1
11 changed files with 326 additions and 244 deletions

View File

@ -0,0 +1,17 @@
{
"title": "block title",
"description": "block description",
"keywords": [ "block keyword" ],
"styles": [
{
"label": "block style label"
}
],
"variations": [
{
"title": "block variation title",
"description": "block variation description",
"keywords": [ "block variation keyword" ]
}
]
}

View File

@ -187,11 +187,29 @@ function register_block_style_handle( $metadata, $field_name ) {
return $result ? $style_handle : false;
}
/**
* Gets i18n schema for block's metadata read from `block.json` file.
*
* @since 5.9.0
*
* @return array The schema for block's metadata.
*/
function get_block_metadata_i18n_schema() {
static $i18n_block_schema;
if ( ! isset( $i18n_block_schema ) ) {
$i18n_block_schema = wp_json_file_decode( __DIR__ . '/block-i18n.json' );
}
return $i18n_block_schema;
}
/**
* Registers a block type from the metadata stored in the `block.json` file.
*
* @since 5.5.0
* @since 5.9.0 Added support for the `viewScript` field.
* @since 5.7.0 Added support for `textdomain` field and i18n handling for all translatable fields.
* @since 5.9.0 Added support for `variations` and `viewScript` fields.
*
* @param string $file_or_folder Path to the JSON file with metadata definition for
* the block or path to the folder where the `block.json` file is located.
@ -209,7 +227,7 @@ function register_block_type_from_metadata( $file_or_folder, $args = array() ) {
return false;
}
$metadata = json_decode( file_get_contents( $metadata_file ), true );
$metadata = wp_json_file_decode( $metadata_file, array( 'associative' => true ) );
if ( ! is_array( $metadata ) || empty( $metadata['name'] ) ) {
return false;
}
@ -238,6 +256,7 @@ function register_block_type_from_metadata( $file_or_folder, $args = array() ) {
$settings = array();
$property_mappings = array(
'apiVersion' => 'api_version',
'title' => 'title',
'category' => 'category',
'parent' => 'parent',
@ -249,53 +268,17 @@ function register_block_type_from_metadata( $file_or_folder, $args = array() ) {
'usesContext' => 'uses_context',
'supports' => 'supports',
'styles' => 'styles',
'variations' => 'variations',
'example' => 'example',
'apiVersion' => 'api_version',
);
$textdomain = ! empty( $metadata['textdomain'] ) ? $metadata['textdomain'] : null;
$i18n_schema = get_block_metadata_i18n_schema();
foreach ( $property_mappings as $key => $mapped_key ) {
if ( isset( $metadata[ $key ] ) ) {
$value = $metadata[ $key ];
if ( empty( $metadata['textdomain'] ) ) {
$settings[ $mapped_key ] = $value;
continue;
}
$textdomain = $metadata['textdomain'];
switch ( $key ) {
case 'title':
case 'description':
// phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralContext,WordPress.WP.I18n.NonSingularStringLiteralDomain
$settings[ $mapped_key ] = translate_with_gettext_context( $value, sprintf( 'block %s', $key ), $textdomain );
break;
case 'keywords':
$settings[ $mapped_key ] = array();
if ( ! is_array( $value ) ) {
continue 2;
}
foreach ( $value as $keyword ) {
// phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain
$settings[ $mapped_key ][] = translate_with_gettext_context( $keyword, 'block keyword', $textdomain );
}
break;
case 'styles':
$settings[ $mapped_key ] = array();
if ( ! is_array( $value ) ) {
continue 2;
}
foreach ( $value as $style ) {
if ( ! empty( $style['label'] ) ) {
// phpcs:ignore WordPress.WP.I18n.LowLevelTranslationFunction,WordPress.WP.I18n.NonSingularStringLiteralText,WordPress.WP.I18n.NonSingularStringLiteralDomain
$style['label'] = translate_with_gettext_context( $style['label'], 'block style label', $textdomain );
}
$settings[ $mapped_key ][] = $style;
}
break;
default:
$settings[ $mapped_key ] = $value;
$settings[ $mapped_key ] = $metadata[ $key ];
if ( $textdomain && isset( $i18n_schema->$key ) ) {
$settings[ $mapped_key ] = translate_settings_using_i18n_schema( $i18n_schema->$key, $settings[ $key ], $textdomain );
}
}
}

View File

@ -40,12 +40,12 @@ class WP_Theme_JSON_Resolver {
private static $theme_has_support = null;
/**
* Structure to hold i18n metadata.
* Container to keep loaded i18n schema for `theme.json`.
*
* @since 5.8.0
* @since 5.9.0
* @var array
*/
private static $theme_json_i18n = null;
private static $i18n_schema = null;
/**
* Processes a file that adheres to the theme.json schema
@ -59,17 +59,7 @@ class WP_Theme_JSON_Resolver {
private 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;
}
@ -77,103 +67,17 @@ class WP_Theme_JSON_Resolver {
return $config;
}
/**
* 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();
}
/**
@ -188,50 +92,12 @@ class WP_Theme_JSON_Resolver {
* @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 );
}
if ( null === self::$i18n_schema ) {
$i18n_schema = wp_json_file_decode( __DIR__ . '/theme-i18n.json' );
self::$i18n_schema = null === $i18n_schema ? array() : $i18n_schema;
}
return $theme_json;
return translate_settings_using_i18n_schema( self::$i18n_schema, $theme_json, $domain );
}
/**
@ -365,7 +231,6 @@ class WP_Theme_JSON_Resolver {
self::$core = null;
self::$theme = null;
self::$theme_has_support = null;
self::$theme_json_i18n = null;
}
}

View File

@ -4267,6 +4267,54 @@ function wp_check_jsonp_callback( $callback ) {
return 0 === $illegal_char_count;
}
/**
* Reads and decodes a JSON file.
*
* @since 5.9.0
*
* @param string $filename Path to the JSON file.
* @param array $options {
* Optional. Options to be used with `json_decode()`.
*
* @type bool associative Optional. When `true`, JSON objects will be returned as associative arrays.
* When `false`, JSON objects will be returned as objects.
* }
*
* @return mixed Returns the value encoded in JSON in appropriate PHP type.
* `null` is returned if the file is not found, or its content can't be decoded.
*/
function wp_json_file_decode( $filename, $options = array() ) {
$result = null;
$filename = wp_normalize_path( realpath( $filename ) );
if ( ! file_exists( $filename ) ) {
trigger_error(
sprintf(
/* translators: %s: Path to the JSON file. */
__( "File %s doesn't exist!" ),
$filename
)
);
return $result;
}
$options = wp_parse_args( $options, array( 'associative' => false ) );
$decoded_file = json_decode( file_get_contents( $filename ), $options['associative'] );
if ( JSON_ERROR_NONE !== json_last_error() ) {
trigger_error(
sprintf(
/* translators: 1: Path to the JSON file, 2: Error message. */
__( 'Error when decoding a JSON file at path %1$s: %2$s' ),
$filename,
json_last_error_msg()
)
);
return $result;
}
return $decoded_file;
}
/**
* Retrieve the WordPress home page URL.
*

View File

@ -1712,3 +1712,47 @@ function is_locale_switched() {
return $wp_locale_switcher->is_switched();
}
/**
* Translates the provided settings value using its i18n schema.
*
* @since 5.9.0
* @access private
*
* @param string|string[]|array[]|object $i18n_schema I18n schema for the setting.
* @param string|string[]|array[] $settings Value for the settings.
* @param string $textdomain Textdomain to use with translations.
*
* @return string|string[]|array[] Translated settings.
*/
function translate_settings_using_i18n_schema( $i18n_schema, $settings, $textdomain ) {
if ( empty( $i18n_schema ) || empty( $settings ) || empty( $textdomain ) ) {
return $settings;
}
if ( is_string( $i18n_schema ) && is_string( $settings ) ) {
return translate_with_gettext_context( $settings, $i18n_schema, $textdomain );
}
if ( is_array( $i18n_schema ) && is_array( $settings ) ) {
$translated_settings = array();
foreach ( $settings as $value ) {
$translated_settings[] = translate_settings_using_i18n_schema( $i18n_schema[0], $value, $textdomain );
}
return $translated_settings;
}
if ( is_object( $i18n_schema ) && is_array( $settings ) ) {
$group_key = '*';
$translated_settings = array();
foreach ( $settings as $key => $value ) {
if ( isset( $i18n_schema->$key ) ) {
$translated_settings[ $key ] = translate_settings_using_i18n_schema( $i18n_schema->$key, $value, $textdomain );
} elseif ( isset( $i18n_schema->$group_key ) ) {
$translated_settings[ $key ] = translate_settings_using_i18n_schema( $i18n_schema->$group_key, $value, $textdomain );
} else {
$translated_settings[ $key ] = $value;
}
}
return $translated_settings;
}
return $settings;
}

View File

@ -41,6 +41,14 @@
"label": "Other"
}
],
"variations": [
{
"name": "error",
"title": "Error",
"description": "Shows error.",
"keywords": [ "failure" ]
}
],
"example": {
"attributes": {
"message": "This is a notice!"

View File

@ -2,12 +2,12 @@ msgid ""
msgstr ""
"Project-Id-Version: \n"
"POT-Creation-Date: 2015-12-31 16:31+0100\n"
"PO-Revision-Date: 2021-01-14 18:26+0100\n"
"PO-Revision-Date: 2021-07-15 13:36+0200\n"
"Language: pl_PL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.4.2\n"
"X-Generator: Poedit 3.0\n"
"X-Poedit-Basepath: .\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-KeywordsList: __;_e;_x:1,2c;_ex:1,2c;_n:1,2;_nx:1,2,4c;_n_noop:1,2;"
@ -41,3 +41,15 @@ msgstr "Domyślny"
msgctxt "block style label"
msgid "Other"
msgstr "Inny"
msgctxt "block variation title"
msgid "Error"
msgstr "Błąd"
msgctxt "block variation description"
msgid "Shows error."
msgstr "Wyświetla błąd."
msgctxt "block variation keyword"
msgid "failure"
msgstr "niepowodzenie"

View File

@ -69,6 +69,15 @@ class Tests_Blocks_Register extends WP_UnitTestCase {
parent::tear_down();
}
/**
* Returns Polish locale string.
*
* @return string
*/
function filter_set_locale_to_polish() {
return 'pl_PL';
}
/**
* @ticket 45109
*/
@ -370,6 +379,17 @@ class Tests_Blocks_Register extends WP_UnitTestCase {
),
$result->styles
);
$this->assertSame(
array(
array(
'name' => 'error',
'title' => 'Error',
'description' => 'Shows error.',
'keywords' => array( 'failure' ),
),
),
$result->variations
);
$this->assertSame(
array(
'attributes' => array(
@ -407,10 +427,7 @@ class Tests_Blocks_Register extends WP_UnitTestCase {
* @ticket 52301
*/
function test_block_registers_with_metadata_i18n_support() {
function filter_set_locale_to_polish() {
return 'pl_PL';
}
add_filter( 'locale', 'filter_set_locale_to_polish' );
add_filter( 'locale', array( $this, 'filter_set_locale_to_polish' ) );
load_textdomain( 'notice', WP_LANG_DIR . '/plugins/notice-pl_PL.mo' );
$result = register_block_type_from_metadata(
@ -418,7 +435,7 @@ class Tests_Blocks_Register extends WP_UnitTestCase {
);
unload_textdomain( 'notice' );
remove_filter( 'locale', 'filter_set_locale_to_polish' );
remove_filter( 'locale', array( $this, 'filter_set_locale_to_polish' ) );
$this->assertInstanceOf( 'WP_Block_Type', $result );
$this->assertSame( 'tests/notice', $result->name );
@ -439,6 +456,17 @@ class Tests_Blocks_Register extends WP_UnitTestCase {
),
$result->styles
);
$this->assertSame(
array(
array(
'name' => 'error',
'title' => 'Błąd',
'description' => 'Wyświetla błąd.',
'keywords' => array( 'niepowodzenie' ),
),
),
$result->variations
);
}
/**

View File

@ -1037,6 +1037,31 @@ class Tests_Functions extends WP_UnitTestCase {
$this->assertFalse( $json );
}
/**
* @ticket 53238
*/
function test_wp_json_file_decode() {
$result = wp_json_file_decode(
DIR_TESTDATA . '/blocks/notice/block.json'
);
$this->assertIsObject( $result );
$this->assertSame( 'tests/notice', $result->name );
}
/**
* @ticket 53238
*/
function test_wp_json_file_decode_associative_array() {
$result = wp_json_file_decode(
DIR_TESTDATA . '/blocks/notice/block.json',
array( 'associative' => true )
);
$this->assertIsArray( $result );
$this->assertSame( 'tests/notice', $result['name'] );
}
/**
* @ticket 36054
* @dataProvider datetime_provider

View File

@ -0,0 +1,99 @@
<?php
/**
* @group l10n
* @group i18n
*/
class Tests_L10n_TranslateSettingsUsingI18nSchema extends WP_UnitTestCase {
/**
* Returns Polish locale string.
*
* @return string
*/
function filter_set_locale_to_polish() {
return 'pl_PL';
}
/**
* @ticket 53238
*/
function test_translate_settings_using_i18n_schema() {
$textdomain = 'notice';
add_filter( 'locale', array( $this, 'filter_set_locale_to_polish' ) );
load_textdomain( $textdomain, WP_LANG_DIR . '/plugins/notice-pl_PL.mo' );
$i18n_schema = (object) array(
'title' => 'block title',
'keywords' => array( 'block keyword' ),
'styles' => array(
(object) array( 'label' => 'block style label' ),
),
'context' => (object) array(
'*' => (object) array(
'variations' => array(
(object) array(
'title' => 'block variation title',
'description' => 'block variation description',
'keywords' => array( 'block variation keyword' ),
),
),
),
),
);
$settings = array(
'title' => 'Notice',
'keywords' => array(
'alert',
'message',
),
'styles' => array(
array( 'label' => 'Default' ),
array( 'label' => 'Other' ),
),
'context' => array(
'namespace' => array(
'variations' => array(
array(
'title' => 'Error',
'description' => 'Shows error.',
'keywords' => array( 'failure' ),
),
),
),
),
);
$result = translate_settings_using_i18n_schema(
$i18n_schema,
$settings,
$textdomain
);
unload_textdomain( $textdomain );
remove_filter( 'locale', array( $this, 'filter_set_locale_to_polish' ) );
$this->assertSame( 'Powiadomienie', $result['title'] );
$this->assertSameSets( array( 'ostrzeżenie', 'wiadomość' ), $result['keywords'] );
$this->assertSame(
array(
array(
'label' => 'Domyślny',
),
array(
'label' => 'Inny',
),
),
$result['styles']
);
$this->assertSame(
array(
array(
'title' => 'Błąd',
'description' => 'Wyświetla błąd.',
'keywords' => array( 'niepowodzenie' ),
),
),
$result['context']['namespace']['variations']
);
}
}

View File

@ -44,53 +44,6 @@ class Tests_Theme_wpThemeJsonResolver extends WP_UnitTestCase {
return 'pl_PL';
}
/**
* @ticket 52991
*/
public function test_fields_are_extracted() {
$actual = WP_Theme_JSON_Resolver::get_fields_to_translate();
$expected = array(
array(
'path' => array( 'settings', 'typography', 'fontSizes' ),
'key' => 'name',
'context' => 'Font size name',
),
array(
'path' => array( 'settings', 'color', 'palette' ),
'key' => 'name',
'context' => 'Color name',
),
array(
'path' => array( 'settings', 'color', 'gradients' ),
'key' => 'name',
'context' => 'Gradient name',
),
array(
'path' => array( 'settings', 'color', 'duotone' ),
'key' => 'name',
'context' => 'Duotone name',
),
array(
'path' => array( 'settings', 'blocks', '*', 'typography', 'fontSizes' ),
'key' => 'name',
'context' => 'Font size name',
),
array(
'path' => array( 'settings', 'blocks', '*', 'color', 'palette' ),
'key' => 'name',
'context' => 'Color name',
),
array(
'path' => array( 'settings', 'blocks', '*', 'color', 'gradients' ),
'key' => 'name',
'context' => 'Gradient name',
),
);
$this->assertSame( $expected, $actual );
}
/**
* @ticket 52991
*/