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;
}
}