diff --git a/block-json/block.json b/block-json/block.json new file mode 100644 index 0000000..3f3444e --- /dev/null +++ b/block-json/block.json @@ -0,0 +1,34 @@ +{ + "name": "my-plugin/notice", + "title": "Notice", + "category": "text", + "parent": [ "core/group" ], + "icon": "star", + "description": "Shows warning, error or success notices ...", + "keywords": [ "alert", "message" ], + "textdomain": "my-plugin", + "attributes": { + "message": { + "type": "string", + "source": "html", + "selector": ".message" + } + }, + "supports": { + "align": true, + "lightBlockWrapper": true + }, + "styles": [ + { "name": "default", "label": "Default", "isDefault": true }, + { "name": "other", "label": "Other" } + ], + "example": { + "attributes": { + "message": "This is a notice!" + } + }, + "editorScript": "build/editor.js", + "script": "build/main.js", + "editorStyle": "build/editor.css", + "style": "build/style.css" +} diff --git a/block-json/class-parser.php b/block-json/class-parser.php new file mode 100644 index 0000000..9a5f45e --- /dev/null +++ b/block-json/class-parser.php @@ -0,0 +1,174 @@ +' . self::REQUIRED_FILENAME . '' + ) + ); + } + + $response = wp_safe_remote_get( $url ); + $response_code = wp_remote_retrieve_response_code( $response ); + + if ( is_wp_error( $response ) ) { + return $response; + } elseif ( 200 !== $response_code ) { + return new WP_Error( + 'resource_url_unexpected_response', + __( 'URL returned an unexpected status code.', 'wporg-plugins' ), + array( + 'status' => $response_code, + ) + ); + } + + return wp_remote_retrieve_body( $response ); + } + + /** + * Get the contents of a block.json file via a path in the filesystem. + * + * @param string $file + * + * @return string|WP_Error + */ + protected static function extract_content_from_file( $file ) { + $filename_length = strlen( self::REQUIRED_FILENAME ); + if ( strtolower( substr( $file, - $filename_length ) ) !== self::REQUIRED_FILENAME ) { + return new WP_Error( + 'resource_file_invalid', + sprintf( + /* translators: %s: file name */ + __( 'File must be named %s!', 'wporg-plugins' ), + '' . self::REQUIRED_FILENAME . '' + ) + ); + } + + if ( ! is_readable( $file ) ) { + return new WP_Error( + 'resource_file_unreadable', + __( 'The file could not be read.', 'wporg-plugins' ), + array( + 'file' => $file, + ) + ); + } + + $content = file_get_contents( $file ); + + if ( false === $content ) { + return new WP_Error( + 'resource_file_failed_retrieval', + __( 'Could not get the contents of the file.', 'wporg-plugins' ), + array( + 'file' => $file, + ) + ); + } + + return $content; + } + + /** + * Parse a JSON string into an object, and handle parsing errors. + * + * @param string $content + * + * @return object|WP_Error + */ + protected static function parse_content( $content ) { + $parsed = json_decode( $content ); + $error = json_last_error_msg(); + + // TODO Once we are on PHP 7.3 we can use the JSON_THROW_ON_ERROR option and catch an exception here. + if ( 'No error' !== $error ) { + return new WP_Error( + 'json_parse_error', + sprintf( + __( 'JSON Parser: %s', 'wporg-plugins' ), + esc_html( $error ) + ), + array( + 'error_code' => json_last_error(), + ) + ); + } + + return $parsed; + } +} diff --git a/block-json/class-validator.php b/block-json/class-validator.php new file mode 100644 index 0000000..e9a8b88 --- /dev/null +++ b/block-json/class-validator.php @@ -0,0 +1,554 @@ +messages = new WP_Error(); + } + + /** + * The schema for the block.json file. + * + * This attempts to follow the schema for the schema. + * See https://json-schema.org/understanding-json-schema/reference/index.html + * + * @return array + */ + public static function schema() { + return array( + 'type' => 'object', + 'properties' => array( + 'attributes' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'attribute' => array( + 'type' => 'string', + ), + 'meta' => array( + 'type' => 'string', + ), + 'multiline' => array( + 'type' => 'string', + ), + 'query' => array( + 'type' => 'object', + ), + 'selector' => array( + 'type' => 'string', + ), + 'source' => array( + 'type' => 'string', + 'enum' => array( 'attribute', 'text', 'html', 'query' ), + ), + 'type' => array( + 'type' => 'string', + 'enum' => array( 'null', 'boolean', 'object', 'array', 'number', 'string', 'integer' ), + ), + ), + 'required' => array( 'type' ), + ), + ), + 'category' => array( + 'type' => 'string', + ), + 'comment' => array( + 'type' => 'string', + ), + 'description' => array( + 'type' => 'string', + ), + 'editorScript' => array( + 'type' => 'string', + 'pattern' => '\.js$', + ), + 'editorStyle' => array( + 'type' => 'string', + 'pattern' => '\.css$', + ), + 'example' => array( + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + ), + ), + 'icon' => array( + 'type' => 'string', + ), + 'keywords' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'name' => array( + 'type' => 'string', + ), + 'parent' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'script' => array( + 'type' => 'string', + 'pattern' => '\.js$', + ), + 'style' => array( + 'type' => 'string', + 'pattern' => '\.css$', + ), + 'styles' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'isDefault' => array( + 'type' => 'boolean', + ), + 'label' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + ), + ), + ), + 'supports' => array( + 'type' => 'object', + 'properties' => array( + 'align' => array( + 'type' => array( 'boolean', 'array' ), + 'items' => array( + 'type' => 'string', + 'enum' => array( 'left', 'center', 'right', 'wide', 'full' ), + ), + ), + 'alignWide' => array( + 'type' => 'boolean', + ), + 'anchor' => array( + 'type' => 'boolean', + ), + 'className' => array( + 'type' => 'boolean', + ), + 'customClassName' => array( + 'type' => 'boolean', + ), + 'html' => array( + 'type' => 'boolean', + ), + 'inserter' => array( + 'type' => 'boolean', + ), + 'multiple' => array( + 'type' => 'boolean', + ), + 'reusable' => array( + 'type' => 'boolean', + ), + ), + ), + 'textdomain' => array( + 'type' => 'string', + ), + 'title' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'name', 'title' ), + 'additionalProperties' => false, + ); + } + + /** + * Validate a PHP object representation of a block.json file. + * + * @param object|WP_Error $block_json + * + * @return bool|WP_Error + */ + public function validate( $block_json ) { + // A WP_Error instance is technically an object, but shouldn't be validated. + if ( is_wp_error( $block_json ) ) { + return $block_json; + } + + $schema = self::schema(); + + $this->validate_object( $block_json, 'block.json', $schema ); + $this->check_conditional_properties( $block_json ); + + if ( $this->messages->has_errors() ) { + return $this->messages; + } + + return true; + } + + /** + * Check for properties that are conditionally required. + * + * @param object $block_json + * + * @return void + */ + protected function check_conditional_properties( $block_json ) { + if ( ! is_object( $block_json ) ) { + return; + } + + if ( ! isset( $block_json->script ) && ! isset( $block_json->editorScript ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'At least one of the following properties must be present: %s', 'wporg-plugins' ), + // translators: used between list items, there is a space after the comma. + 'script' . __( ', ', 'wporg-plugins' ) . 'editorScript' + ) + ); + $this->append_error_data( 'block.json:script', 'error' ); + $this->append_error_data( 'block.json:editorScript', 'error' ); + } + + if ( ! isset( $block_json->style ) && ! isset( $block_json->editorStyle ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'At least one of the following properties must be present: %s', 'wporg-plugins' ), + // translators: used between list items, there is a space after the comma. + 'style' . __( ', ', 'wporg-plugins' ) . 'editorStyle' + ) + ); + $this->append_error_data( 'block.json:style', 'error' ); + $this->append_error_data( 'block.json:editorStyle', 'error' ); + } + } + + /** + * Validate an object and its properties. + * + * @param object $object The value to validate as an object. + * @param string $prop The name of the property, used in error reporting. + * @param array $schema The schema for the property, used for validation. + * + * @return bool + */ + protected function validate_object( $object, $prop, $schema ) { + if ( ! is_object( $object ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'The %s property must contain an object value.', 'wporg-plugins' ), + '' . $prop . '' + ) + ); + $this->append_error_data( $prop, 'error' ); + + return false; + } + + $results = array(); + + if ( isset( $schema['required'] ) ) { + foreach ( $schema['required'] as $required_prop ) { + if ( ! property_exists( $object, $required_prop ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'The %1$s property is required in the %2$s object.', 'wporg-plugins' ), + '' . $required_prop . '', + '' . $prop . '' + ) + ); + $this->append_error_data( "$prop:$required_prop", 'error' ); + $results[] = false; + } + } + } + + if ( isset( $schema['properties'] ) ) { + foreach ( $schema['properties'] as $subprop => $subschema ) { + if ( ! isset( $object->$subprop ) ) { + continue; + } + + if ( isset( $subschema['type'] ) ) { + $results[] = $this->route_validation_for_type( + $subschema['type'], + $object->$subprop, + "$prop:$subprop", + $subschema + ); + } + } + } + + if ( isset( $schema['additionalProperties'] ) ) { + if ( false === $schema['additionalProperties'] ) { + foreach ( array_keys( get_object_vars( $object ) ) as $key ) { + if ( ! isset( $schema['properties'][ $key ] ) ) { + $this->messages->add( + 'warning', + sprintf( + __( '%1$s is not a valid property in the %2$s object.', 'wporg-plugins' ), + '' . $key . '', + '' . $prop . '' + ) + ); + $this->append_error_data( "$prop:$key", 'warning' ); + $results[] = false; + continue; + } + } + } elseif ( isset( $schema['additionalProperties']['type'] ) ) { + foreach ( $object as $subprop => $subvalue ) { + $results[] = $this->route_validation_for_type( + $schema['additionalProperties']['type'], + $subvalue, + "$prop:$subprop", + $schema['additionalProperties'] + ); + } + } + } + + return ! in_array( false, $results, true ); + } + + /** + * Validate an array and its items. + * + * @param array $array The value to validate as an array. + * @param string $prop The name of the property, used in error reporting. + * @param array $schema The schema for the property, used for validation. + * + * @return bool + */ + protected function validate_array( $array, $prop, $schema ) { + if ( ! is_array( $array ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'The %s property must contain an array value.', 'wporg-plugins' ), + '' . $prop . '' + ) + ); + $this->append_error_data( $prop, 'error' ); + + return false; + } + + if ( isset( $schema['items']['type'] ) ) { + $results = array(); + $index = 0; + + foreach ( $array as $item ) { + $results[] = $this->route_validation_for_type( + $schema['items']['type'], + $item, + $prop . "[$index]", + $schema['items'] + ); + $index ++; + } + + return ! in_array( false, $results, true ); + } + + return true; + } + + /** + * Validate a string. + * + * @param string $string The value to validate as a string. + * @param string $prop The name of the property, used in error reporting. + * @param array $schema The schema for the property, used for validation. + * + * @return bool + */ + protected function validate_string( $string, $prop, $schema ) { + if ( ! is_string( $string ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'The %s property must contain a string value.', 'wporg-plugins' ), + '' . $prop . '' + ) + ); + $this->append_error_data( $prop, 'error' ); + + return false; + } + + if ( isset( $schema['enum'] ) ) { + if ( ! in_array( $string, $schema['enum'], true ) ) { + $this->messages->add( + 'warning', + sprintf( + __( '"%1$s" is not a valid value for the %2$s property.', 'wporg-plugins' ), + esc_html( $string ), + '' . $prop . '' + ) + ); + $this->append_error_data( $prop, 'warning' ); + } + } + + if ( isset( $schema['pattern'] ) ) { + if ( ! preg_match( '#' . $schema['pattern'] . '#', $string ) ) { + $pattern_description = $this->get_human_readable_pattern_description( $schema['pattern'] ); + if ( $pattern_description ) { + $message = sprintf( + $pattern_description, + '' . $prop . '' + ); + } else { + $message = sprintf( + __( 'The value of %s does not match the required pattern.', 'wporg-plugins' ), + '' . $prop . '' + ); + } + + $this->messages->add( 'warning', $message ); + $this->append_error_data( $prop, 'warning' ); + } + } + + return true; + } + + /** + * Validate a boolean. + * + * @param bool $boolean The value to validate as a boolean. + * @param string $prop The name of the property, used in error reporting. + * + * @return bool + */ + protected function validate_boolean( $boolean, $prop ) { + if ( ! is_bool( $boolean ) ) { + $this->messages->add( + 'error', + sprintf( + __( 'The %s property must contain a boolean value.', 'wporg-plugins' ), + '' . $prop . '' + ) + ); + $this->append_error_data( $prop, 'error' ); + + return false; + } + + return true; + } + + /** + * Send a property value to the correct validator depending on which type(s) it can be. + * + * @param string|array $valid_types + * @param mixed $value + * @param string $prop + * @param array $schema + * + * @return bool + */ + protected function route_validation_for_type( $valid_types, $value, $prop, $schema ) { + // There is a single valid type. + if ( is_string( $valid_types ) ) { + $method = "validate_$valid_types"; + return $this->$method( $value, $prop, $schema ); + } + + // There are multiple valid types in an array. + foreach ( $valid_types as $type ) { + switch ( $type ) { + case 'boolean': + $check = 'is_bool'; + break; + default: + $check = "is_$type"; + break; + } + + if ( $check( $value ) ) { + $method = "validate_$type"; + return $this->$method( $value, $prop, $schema ); + } + } + + // Made it this far, it's none of the valid types. + $this->messages->add( + 'error', + sprintf( + __( 'The %1$s property must contain a value that is one of these types: %2$s', 'wporg-plugins' ), + '' . $prop . '', + // translators: used between list items, there is a space after the comma. + '' . implode( '' . __( ', ', 'wporg-plugins' ) . '', $valid_types ) . '' + ) + ); + $this->append_error_data( $prop, 'error' ); + + return false; + } + + /** + * Add more data to an error code. + * + * The `add_data` method in WP_Error replaces data with each subsequent call with the same error code. + * + * @param mixed $new_data The data to append. + * @param string $error_code The error code to assign the data to. + * + * @return void + */ + protected function append_error_data( $new_data, $error_code ) { + $data = $this->messages->get_error_data( $error_code ) ?: array(); + $data[] = $new_data; + $this->messages->add_data( $data, $error_code ); + } + + /** + * Get a description of a regex pattern that can be understood by humans. + * + * @param string $pattern A regex pattern. + * + * @return string + */ + protected function get_human_readable_pattern_description( $pattern ) { + $description = ''; + + switch ( $pattern ) { + case '\.css$': + $description = __( 'The value of %s must end in ".css".', 'wporg-plugins' ); + break; + case '\.js$': + $description = __( 'The value of %s must end in ".js".', 'wporg-plugins' ); + break; + } + + return $description; + } +} diff --git a/cli/class-import.php b/cli/class-import.php index 810eae9..b7b34fa 100644 --- a/cli/class-import.php +++ b/cli/class-import.php @@ -4,6 +4,7 @@ use Exception; use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater; use WordPressdotorg\Plugin_Directory\Jobs\Tide_Sync; +use WordPressdotorg\Plugin_Directory\Block_JSON; use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Readme\Parser; use WordPressdotorg\Plugin_Directory\Template; @@ -658,10 +659,26 @@ static function find_blocks_in_file( $filename ) { } } if ( 'block.json' === basename( $filename ) ) { - // A block.json file has everything we want. - $blockinfo = json_decode( file_get_contents( $filename ) ); - if ( isset( $blockinfo->name ) ) { - $blocks[] = $blockinfo; + // A block.json file should have everything we want. + $validator = new Block_JSON\Validator(); + $block = Block_JSON\Parser::parse( array( 'file' => $filename ) ); + $result = $validator->validate( $block ); + if ( ! is_wp_error( $block ) && is_wp_error( $result ) ) { + // Only certain properties must be valid for our purposes here. + $required_valid_props = array( + 'block.json', + 'block.json:editorScript', + 'block.json:editorStyle', + 'block.json:name', + 'block.json:script', + 'block.json:style', + ); + $invalid_props = array_intersect( $required_valid_props, $result->get_error_data( 'error' ) ); + if ( empty( $invalid_props ) ) { + $blocks[] = $block; + } + } elseif ( true === $result ) { + $blocks[] = $block; } }