Commit 889a635f77 for wordpress.org
commit 889a635f77303dd3b03b32207485ebab8080c4d4
Author: oandregal <oandregal@git.wordpress.org>
Date: Mon Oct 19 17:48:00 2026 +0000
Editor: Add the entity fields API.
Adds a registry for the fields DataViews and DataForm show for an entity,
so they can be registered on the server with `wp_register_entity_fields()` and `wp_unregister_entity_fields()` on the `wp_entity_fields_api_init` action.
Props: oandregal, jorgefilipecosta.
Part of #66229.
Built from https://develop.svn.wordpress.org/trunk@64528
git-svn-id: http://core.svn.wordpress.org/trunk@63678 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-includes/class-wp-entity-fields-registry.php b/wp-includes/class-wp-entity-fields-registry.php
new file mode 100644
index 0000000000..e25f2e026a
--- /dev/null
+++ b/wp-includes/class-wp-entity-fields-registry.php
@@ -0,0 +1,1029 @@
+<?php
+/**
+ * Entity Fields API: WP_Entity_Fields_Registry class
+ *
+ * @package WordPress
+ * @subpackage Entity Fields API
+ * @since 7.2.0
+ */
+
+/**
+ * Holds the fields registered for entities.
+ *
+ * The registry maps an entity, identified by its kind and name, to its
+ * registered field definitions keyed by id, in registration order, each
+ * with the origins that registered and updated it, and to the script modules
+ * registered for it, each with the ids of the fields it applies to.
+ * register() adds fields, update() changes registered ones, and both
+ * validate the definitions before storing them: a field that cannot be
+ * stored is reported and skipped, and the rest of the call is stored.
+ * register_collection() adds the fields a folder declares, see it for the
+ * format.
+ *
+ * Fields are registered on the `wp_entity_fields_api_init` action, and only
+ * there, with wp_register_entity_fields(), wp_update_entity_fields(), and
+ * wp_unregister_entity_fields(): register(), update(), and unregister() refuse
+ * to run while the action is not firing.
+ *
+ * The registry is filled lazily: the first time its fields are read, it
+ * fires the `wp_entity_fields_api_init` action, on which the default fields of every
+ * post type and the fields of plugins are registered. A read before `init`
+ * has completed is refused, see initialize().
+ *
+ * Once the action has fired the registry does not change: every reader of
+ * a request, the REST controller as well as the import map of the editor
+ * script, sees the same fields.
+ *
+ * @since 7.2.0
+ *
+ * @access private
+ */
+class WP_Entity_Fields_Registry {
+
+ /**
+ * Registered fields, as kind => name => array of field id => definition.
+ *
+ * Kept nested rather than under a joined `{$kind}/{$name}` key: both parts
+ * may contain any character, so no separator keeps two entities apart.
+ *
+ * @since 7.2.0
+ * @var array<string, array<string, array<string, array>>>
+ */
+ protected $fields = array();
+
+ /**
+ * Registered script modules, as kind => name => array of module id =>
+ * list of the ids of the fields the module applies to, in registration
+ * order.
+ *
+ * @since 7.2.0
+ * @var array<string, array<string, array<string, string[]>>>
+ */
+ protected $field_modules = array();
+
+ /**
+ * Whether the `wp_entity_fields_api_init` action has fired since the registry
+ * was created.
+ *
+ * @since 7.2.0
+ * @var bool
+ */
+ protected $initialized = false;
+
+ /**
+ * The singleton instance.
+ *
+ * @since 7.2.0
+ * @var WP_Entity_Fields_Registry|null
+ */
+ protected static $instance = null;
+
+ /**
+ * Returns the singleton instance.
+ *
+ * @since 7.2.0
+ *
+ * @return WP_Entity_Fields_Registry The registry.
+ */
+ public static function get_instance() {
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Registers fields for an entity.
+ *
+ * The fields must be new: a field with the id of a registered field is
+ * reported and skipped, and so is every definition after the first of an
+ * id that appears more than once in the call. The rest of the fields of
+ * the call are registered: a field another plugin got to first does not
+ * cost a plugin the others. A definition that is not an array with a
+ * non-empty string `id` is reported and skipped the same way. To change
+ * a registered field, see update(); to replace it, unregister it first.
+ * The script module, if any, applies to every field the call registers.
+ * A field whose `type` DataViews does not provide is reported and
+ * registered anyway.
+ *
+ * The origin is stored as the `origin` property of each field, as
+ * `registeredBy`, replacing any `origin` the definition sets.
+ *
+ * @since 7.2.0
+ *
+ * @param string $origin Who registers the fields: `core`, or
+ * the slug of the plugin or theme.
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @param array[] $fields The list of field definitions, each with an `id`.
+ * @param string|null $script_module The id of the script module providing
+ * the JavaScript parts of the fields, if any.
+ * @return string[] The ids of the fields registered, in the order of the
+ * call. Empty when none is, when called outside the
+ * `wp_entity_fields_api_init` action, or when an argument is
+ * invalid.
+ */
+ public function register( $origin, $kind, $name, $fields, $script_module = null ) {
+ if ( ! $this->validate_arguments( __METHOD__, $origin, $kind, $name, $fields, $script_module ) ) {
+ return array();
+ }
+ $fields = $this->skip_invalid_definitions( __METHOD__, $kind, $name, $fields );
+ if ( ! $fields ) {
+ return array();
+ }
+
+ $new_fields = array();
+ $duplicated = array();
+ $registered_ids = array();
+ foreach ( $fields as $field ) {
+ $id = $field['id'];
+ if ( isset( $this->fields[ $kind ][ $name ][ $id ] ) ) {
+ $registered_ids[ $id ] = $id;
+ } elseif ( isset( $new_fields[ $id ] ) ) {
+ $duplicated[ $id ] = $id;
+ } else {
+ $new_fields[ $id ] = $field;
+ }
+ }
+
+ if ( $duplicated ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. */
+ __( 'A field can only be registered once. These fields of %1$s "%2$s" appear more than once in the same call: %3$s. Only their first definition is registered.' ),
+ $kind,
+ $name,
+ implode( ', ', $duplicated )
+ ),
+ '7.2.0'
+ );
+ }
+
+ if ( $registered_ids ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. 4: update() */
+ __( 'A field can only be registered once. These fields of %1$s "%2$s" are skipped because they are already registered: %3$s. Use %4$s to change a registered field, or unregister it first to replace it.' ),
+ $kind,
+ $name,
+ implode( ', ', $registered_ids ),
+ '<code>update()</code>'
+ ),
+ '7.2.0'
+ );
+ }
+
+ if ( ! $new_fields ) {
+ return array();
+ }
+ $this->report_unknown_types( __METHOD__, $kind, $name, $new_fields );
+
+ foreach ( $new_fields as $field ) {
+ $this->fields[ $kind ][ $name ][ $field['id'] ] = array_merge(
+ $field,
+ array(
+ 'origin' => array(
+ 'registeredBy' => $origin,
+ 'updatedBy' => array(),
+ ),
+ )
+ );
+ }
+ $ids = array_column( $new_fields, 'id' );
+ $this->add_field_module( $kind, $name, $ids, $script_module );
+
+ return $ids;
+ }
+
+ /**
+ * Updates registered fields of an entity.
+ *
+ * Each definition is merged into the registered field with its id,
+ * property by property; the field keeps its position. The fields must be
+ * registered: a field that is not is reported and skipped, and so is a
+ * definition that is not an array with a non-empty string `id`; the rest
+ * of the fields of the call are updated. The script module, if any,
+ * applies to every field the call updates, on top of the modules the
+ * fields have. A field whose `type` DataViews does not provide is
+ * reported and updated anyway.
+ *
+ * The origin is appended to the `updatedBy` list of the `origin` property
+ * of each field, once; the rest of the `origin` property cannot be
+ * updated, and any `origin` the definition sets is ignored.
+ *
+ * Like register(), it only runs on the `wp_entity_fields_api_init` action.
+ *
+ * @since 7.2.0
+ *
+ * @param string $origin Who updates the fields: `core`, or
+ * the slug of the plugin or theme.
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @param array[] $fields The list of partial field definitions,
+ * each with the `id` of a registered field.
+ * @param string|null $script_module The id of the script module providing
+ * the JavaScript parts of the fields, if any.
+ * @return string[] The ids of the fields updated, in the order of the
+ * call. Empty when none is, when called outside the
+ * `wp_entity_fields_api_init` action, or when an argument is
+ * invalid.
+ */
+ public function update( $origin, $kind, $name, $fields, $script_module = null ) {
+ if ( ! $this->validate_arguments( __METHOD__, $origin, $kind, $name, $fields, $script_module ) ) {
+ return array();
+ }
+ $fields = $this->skip_invalid_definitions( __METHOD__, $kind, $name, $fields );
+ if ( ! $fields ) {
+ return array();
+ }
+ $this->report_unknown_types( __METHOD__, $kind, $name, $fields );
+
+ $ids = array();
+ $unregistered_ids = array();
+ foreach ( $fields as $field ) {
+ $id = $field['id'];
+ if ( ! isset( $this->fields[ $kind ][ $name ][ $id ] ) ) {
+ $unregistered_ids[ $id ] = $id;
+ continue;
+ }
+
+ $registered = $this->fields[ $kind ][ $name ][ $id ];
+ $field_origin = $registered['origin'];
+ if ( ! in_array( $origin, $field_origin['updatedBy'], true ) ) {
+ $field_origin['updatedBy'][] = $origin;
+ }
+
+ $this->fields[ $kind ][ $name ][ $id ] = array_merge( $registered, $field, array( 'origin' => $field_origin ) );
+ if ( ! in_array( $id, $ids, true ) ) {
+ $ids[] = $id;
+ }
+ }
+
+ if ( $unregistered_ids ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids. 4: register() */
+ __( 'Only registered fields can be updated. These fields of %1$s "%2$s" are skipped because they are not registered: %3$s. Use %4$s to add a field.' ),
+ $kind,
+ $name,
+ implode( ', ', $unregistered_ids ),
+ '<code>register()</code>'
+ ),
+ '7.2.0'
+ );
+ }
+
+ if ( $ids ) {
+ $this->add_field_module( $kind, $name, $ids, $script_module );
+ }
+
+ return $ids;
+ }
+
+ /**
+ * Unregisters fields of an entity.
+ *
+ * Unregistering a field removes its registered definition and
+ * drops it from the script modules that applied to it (a module left with
+ * no field is forgotten). Unregistering every field forgets the entity:
+ * its registered fields and script modules.
+ *
+ * Like register(), it only runs on the `wp_entity_fields_api_init` action, and an
+ * invalid entity is reported and unregisters nothing. An id that is not a
+ * non-empty string is reported and skipped, like a definition register()
+ * cannot store.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @param string[]|null $ids The ids of the fields to unregister. Default
+ * null, every field of the entity.
+ * @return array[] The list of the definitions unregistered, in
+ * registration order, as get_registered() returns them.
+ * Empty when none of the fields is registered, when
+ * called outside the `wp_entity_fields_api_init` action, or when
+ * the entity is invalid.
+ */
+ public function unregister( $kind, $name, $ids = null ) {
+ if ( ! $this->doing_entity_fields_api_init( __METHOD__ ) || ! $this->validate_entity( __METHOD__, $kind, $name ) ) {
+ return array();
+ }
+
+ if ( null === $ids ) {
+ $unregistered = array_values( $this->fields[ $kind ][ $name ] ?? array() );
+ unset( $this->fields[ $kind ][ $name ], $this->field_modules[ $kind ][ $name ] );
+ $this->forget_empty_entity( $kind, $name );
+ return $unregistered;
+ }
+
+ $ids = $this->skip_invalid_ids( __METHOD__, $kind, $name, (array) $ids );
+ if ( ! $ids ) {
+ return array();
+ }
+
+ $unregistered = array_intersect_key( $this->fields[ $kind ][ $name ] ?? array(), array_flip( $ids ) );
+ if ( empty( $unregistered ) ) {
+ return array();
+ }
+
+ // A script module only ever applies to registered fields: register()
+ // and update() add it for fields they store, and fields leave both
+ // lists together here. So the fields removed are all the registry
+ // loses, and the modules are not returned: the ones that applied to
+ // them are listed by get_registered_field_modules(), read beforehand.
+ $ids = array_keys( $unregistered );
+ foreach ( $ids as $id ) {
+ unset( $this->fields[ $kind ][ $name ][ $id ] );
+ }
+ foreach ( $this->field_modules[ $kind ][ $name ] ?? array() as $module => $module_ids ) {
+ $remaining = array_values( array_diff( $module_ids, $ids ) );
+ if ( empty( $remaining ) ) {
+ unset( $this->field_modules[ $kind ][ $name ][ $module ] );
+ } else {
+ $this->field_modules[ $kind ][ $name ][ $module ] = $remaining;
+ }
+ }
+ $this->forget_empty_entity( $kind, $name );
+
+ return array_values( $unregistered );
+ }
+
+ /**
+ * Registers the fields of a field collection.
+ *
+ * A collection is a folder whose `index.php` returns its configuration, with
+ * a folder per field whose `field.php` returns the serializable part of the
+ * field (see get_collection_fields()). Each file is required without
+ * sharing the variables of the caller, see require_file(). The JavaScript
+ * parts of the fields, if any, ship in the script module of the
+ * collection. The configuration is an array with these keys:
+ *
+ * - `origin` (required): who registers the fields: `core`, or the slug of
+ * the plugin or theme. See register().
+ * - `kind` (required): the entity kind, e.g. `postType`.
+ * - `name` (required): the entity name, e.g. `wp_template`.
+ * - `module` (optional): the id of the script module providing the
+ * JavaScript parts of the fields. Every field of the collection is
+ * registered with it.
+ *
+ * The collection registers its fields on the entity, after the fields
+ * registered on it before, in the alphabetical order of their folders.
+ * register() skips a field already registered for the entity, and
+ * registers the rest of the collection. Like register(), it does not check
+ * that the entity exists: the `/wp/v2/entity-fields` route only serves the fields
+ * of the entities the REST API exposes.
+ *
+ * Fields that apply to several entities, such as the defaults every post
+ * type derives from its supports, are registered in code: a
+ * `wp_entity_fields_api_init` callback can read their definitions from files with
+ * get_collection_fields() and register them on each entity with
+ * register().
+ *
+ * Like register(), it only runs on the `wp_entity_fields_api_init` action. An
+ * invalid configuration is reported with _doing_it_wrong() and skipped. So
+ * is an invalid `field.php`, see get_collection_fields(): the rest of the
+ * fields of the collection are registered.
+ *
+ * @since 7.2.0
+ *
+ * @param string $directory The directory of the collection.
+ * @return bool Whether the collection is valid and all of its fields are
+ * registered. True as well when it has no fields; false when
+ * a `field.php` is skipped, or when called outside the
+ * `wp_entity_fields_api_init` action.
+ */
+ public function register_collection( $directory ) {
+ if ( ! $this->doing_entity_fields_api_init( __METHOD__ ) ) {
+ return false;
+ }
+
+ $collection = $this->get_collection( __METHOD__, $directory );
+ if ( null === $collection ) {
+ return false;
+ }
+
+ if ( empty( $collection['fields'] ) ) {
+ return ! $collection['skipped'];
+ }
+ $registered = $this->register( $collection['origin'], $collection['kind'], $collection['name'], $collection['fields'], $collection['module'] );
+
+ return ! $collection['skipped'] && count( $registered ) === count( $collection['fields'] );
+ }
+
+ /**
+ * Returns the fields of a field collection.
+ *
+ * A collection holds a folder per field, whose `field.php` returns the
+ * serializable part of the field, see register_collection().
+ *
+ * @since 7.2.0
+ *
+ * @param string $directory The directory of the collection.
+ * @return array<string, array> The fields of the collection, keyed by id, in
+ * the alphabetical order of their folders. The
+ * id of a field is the name of its folder,
+ * unless its `field.php` sets an `id`, and it
+ * comes first in the field. A `field.php`
+ * that does not return an array with a
+ * non-empty string id, or whose id an earlier
+ * folder took, is reported and skipped.
+ */
+ public function get_collection_fields( $directory ) {
+ return $this->read_collection_fields( __METHOD__, $directory )['fields'];
+ }
+
+ /**
+ * Returns the fields registered for an entity.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @return array[] The list of field definitions, in registration order.
+ */
+ public function get_registered( $kind, $name ) {
+ $this->initialize();
+ return array_values( $this->fields[ $kind ][ $name ] ?? array() );
+ }
+
+ /**
+ * Returns the fields registered for every entity.
+ *
+ * @since 7.2.0
+ *
+ * @return array<string, array<string, array[]>> The lists of field
+ * definitions, keyed by
+ * kind, then by name.
+ */
+ public function get_all_registered() {
+ $this->initialize();
+ return array_map(
+ static function ( $entities ) {
+ return array_map( 'array_values', $entities );
+ },
+ $this->fields
+ );
+ }
+
+ /**
+ * Returns the script modules registered for an entity.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @return array<string, string[]> The ids of the fields each module
+ * applies to, keyed by module id, in
+ * registration order.
+ */
+ public function get_registered_field_modules( $kind, $name ) {
+ $this->initialize();
+ return $this->field_modules[ $kind ][ $name ] ?? array();
+ }
+
+ /**
+ * Returns the ids of the script modules registered for every entity.
+ *
+ * @since 7.2.0
+ *
+ * @return array<string, array<string, string[]>> The lists of module ids,
+ * keyed by kind, then by
+ * name.
+ */
+ public function get_all_registered_field_modules() {
+ $this->initialize();
+ return array_map(
+ static function ( $entities ) {
+ return array_map( 'array_keys', $entities );
+ },
+ $this->field_modules
+ );
+ }
+
+ /**
+ * Fires the `wp_entity_fields_api_init` action the first time the registry
+ * is read.
+ *
+ * The read is refused until `init` has completed. The registry holds the
+ * fields of post types, and post types are not known until then: core
+ * registers its own on `init` at priority 0, plugins theirs at the default
+ * priority, and supports are added and removed on `init` too. The default
+ * fields of a post type derive from its supports, and the action fires
+ * once, so a read before or during `init` would fix the defaults from the
+ * post types registered so far for the rest of the request.
+ *
+ * did_action() is true from the first `init` callback on, hence the
+ * doing_action() check for the reads made while it runs.
+ *
+ * @since 7.2.0
+ */
+ protected function initialize() {
+ if ( $this->initialized ) {
+ return;
+ }
+
+ if ( ! did_action( 'init' ) || doing_action( 'init' ) ) {
+ _doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: init, 2: wp_entity_fields_api_init */
+ __( 'The registered fields cannot be read until the %1$s action has completed: the post types and supports they derive from are registered on %1$s. Read them once %1$s has completed, or hook %2$s.' ),
+ '<code>init</code>',
+ '<code>wp_entity_fields_api_init</code>'
+ ),
+ '7.2.0'
+ );
+ return;
+ }
+
+ // Set before firing, so a callback reading the registry to inspect
+ // the fields it updates does not fire the action again.
+ $this->initialized = true;
+
+ /**
+ * Fires the first time the registered fields are read, after `init`.
+ *
+ * Register or adjust fields here, with wp_register_entity_fields(),
+ * wp_update_entity_fields(), and wp_unregister_entity_fields(): it is the only
+ * time they run.
+ *
+ * The first read happens wherever the fields are needed: while
+ * handling a REST request as well as on `admin_footer` of the pages
+ * that load the editor script. A callback should register fields and
+ * nothing else; script modules, styles, and their enqueue hooks
+ * belong on `init`.
+ *
+ * @since 7.2.0
+ */
+ do_action( 'wp_entity_fields_api_init' );
+ }
+
+ /**
+ * Checks that the `wp_entity_fields_api_init` action is firing, as register(),
+ * update(), and unregister() require.
+ *
+ * Outside the action a registration would either come before the
+ * defaults, which could then not be registered, or after the fields have been read
+ * and the import map of the editor script built from them. Refusing
+ * keeps the registry immutable once read.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @return bool Whether the action is firing.
+ */
+ protected function doing_entity_fields_api_init( $method ) {
+ if ( doing_action( 'wp_entity_fields_api_init' ) ) {
+ return true;
+ }
+
+ _doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: %s: wp_entity_fields_api_init */
+ __( 'Fields can only be registered, updated, and unregistered on the %s action.' ),
+ '<code>wp_entity_fields_api_init</code>'
+ ),
+ '7.2.0'
+ );
+ return false;
+ }
+
+ /**
+ * Reports the fields whose `type` is not one the REST schema of the
+ * fields lists, with _doing_it_wrong(). Those are the types DataViews
+ * provides a control for: a misspelt type is otherwise silent, the field
+ * renders without a control. A missing `type` is fine.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param string $kind The entity kind.
+ * @param string $name The entity name.
+ * @param array[] $fields The field definitions.
+ */
+ protected function report_unknown_types( $method, $kind, $name, $fields ) {
+ $type_schema = ( new WP_REST_Entity_Fields_Controller() )->get_item_schema()['properties']['fields']['items']['properties']['type'];
+ $unknown = array();
+ foreach ( $fields as $field ) {
+ if ( ! isset( $field['type'] ) ) {
+ continue;
+ }
+ $valid = rest_validate_value_from_schema( $field['type'], $type_schema, 'type' );
+ if ( is_wp_error( $valid ) ) {
+ $unknown[] = sprintf( '%s (%s)', $field['id'], $valid->get_error_message() );
+ }
+ }
+ if ( ! $unknown ) {
+ return;
+ }
+ _doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of field ids, each with the error of its type. */
+ __( 'These fields of %1$s "%2$s" have a type DataViews does not provide, so they render without a control: %3$s' ),
+ $kind,
+ $name,
+ implode( ', ', $unknown )
+ ),
+ '7.2.0'
+ );
+ }
+
+ /**
+ * Validates the arguments of register() and update().
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param mixed $origin The origin.
+ * @param mixed $kind The entity kind.
+ * @param mixed $name The entity name.
+ * @param mixed $fields The field definitions.
+ * @param mixed $script_module The script module id, if any.
+ * @return bool Whether the call may proceed: the `wp_entity_fields_api_init` action
+ * is firing and the arguments are valid.
+ */
+ protected function validate_arguments( $method, $origin, $kind, $name, $fields, $script_module ) {
+ if ( ! $this->doing_entity_fields_api_init( $method ) ) {
+ return false;
+ }
+
+ if ( ! is_string( $origin ) || '' === $origin ) {
+ _doing_it_wrong(
+ $method,
+ __( 'The origin must be a non-empty string.' ),
+ '7.2.0'
+ );
+ return false;
+ }
+
+ if ( ! $this->validate_entity( $method, $kind, $name ) ) {
+ return false;
+ }
+
+ if ( ! is_array( $fields ) || empty( $fields ) || ! array_is_list( $fields ) ) {
+ _doing_it_wrong(
+ $method,
+ __( 'The fields must be a list of non-empty field definitions.' ),
+ '7.2.0'
+ );
+ return false;
+ }
+
+ if ( null !== $script_module && ( ! is_string( $script_module ) || '' === $script_module ) ) {
+ _doing_it_wrong(
+ $method,
+ __( 'The script module must be the id of a script module.' ),
+ '7.2.0'
+ );
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks that the entity kind and name are non-empty strings, reporting
+ * them with _doing_it_wrong() otherwise.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param mixed $kind The entity kind.
+ * @param mixed $name The entity name.
+ * @return bool Whether both are valid.
+ */
+ protected function validate_entity( $method, $kind, $name ) {
+ foreach ( array( $kind, $name ) as $argument ) {
+ if ( ! is_string( $argument ) || '' === $argument ) {
+ _doing_it_wrong(
+ $method,
+ __( 'The entity kind and the entity name must be non-empty strings.' ),
+ '7.2.0'
+ );
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Drops the definitions that are not an array with a non-empty string
+ * `id`, reporting their positions in the call with _doing_it_wrong():
+ * like a duplicated field, an invalid definition does not cost the call
+ * its other fields.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param string $kind The entity kind.
+ * @param string $name The entity name.
+ * @param array[] $fields The list of field definitions.
+ * @return array[] The valid definitions, in the order of the call.
+ */
+ protected function skip_invalid_definitions( $method, $kind, $name, $fields ) {
+ $valid = array();
+ $invalid = array();
+ foreach ( $fields as $position => $field ) {
+ if ( is_array( $field ) && ! empty( $field['id'] ) && is_string( $field['id'] ) ) {
+ $valid[] = $field;
+ } else {
+ $invalid[] = '#' . ( $position + 1 );
+ }
+ }
+
+ if ( $invalid ) {
+ _doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of positions, e.g. #2, #4. 4: id */
+ __( 'Every field definition must be an array with a non-empty string %4$s. These definitions of %1$s "%2$s" are skipped: %3$s. The rest of the fields of the call are stored.' ),
+ $kind,
+ $name,
+ implode( ', ', $invalid ),
+ '<code>id</code>'
+ ),
+ '7.2.0'
+ );
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Drops the ids that are not a non-empty string, reporting their
+ * positions in the call with _doing_it_wrong(): like an invalid
+ * definition, an invalid id does not cost the call its other ids.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param string $kind The entity kind.
+ * @param string $name The entity name.
+ * @param array $ids The ids of the fields.
+ * @return string[] The valid ids, in the order of the call.
+ */
+ protected function skip_invalid_ids( $method, $kind, $name, $ids ) {
+ $valid = array();
+ $invalid = array();
+ foreach ( array_values( $ids ) as $position => $id ) {
+ if ( is_string( $id ) && '' !== $id ) {
+ $valid[] = $id;
+ } else {
+ $invalid[] = '#' . ( $position + 1 );
+ }
+ }
+
+ if ( $invalid ) {
+ _doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: 1: Entity kind, e.g. postType. 2: Entity name, e.g. page. 3: Comma-separated list of positions, e.g. #2, #4. */
+ __( 'Every field id must be a non-empty string. These ids of %1$s "%2$s" are skipped: %3$s. The rest of the fields of the call are unregistered.' ),
+ $kind,
+ $name,
+ implode( ', ', $invalid )
+ ),
+ '7.2.0'
+ );
+ }
+
+ return $valid;
+ }
+
+ /**
+ * Reads and validates a field collection, see register_collection() for
+ * its format.
+ *
+ * An invalid configuration is reported with _doing_it_wrong(): the
+ * collection is skipped.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param string $directory The directory of the collection.
+ * @return array|null The configuration of the collection, with the `module`
+ * key set, its valid `fields` as a list, and `skipped`
+ * telling whether a `field.php` was skipped, see
+ * read_collection_fields(); or null when the
+ * configuration is invalid.
+ */
+ protected function get_collection( $method, $directory ) {
+ $slug = basename( $directory );
+ $file = $directory . '/index.php';
+ $config = is_file( $file ) ? self::require_file( $file ) : null;
+ $error = null;
+
+ if ( ! is_array( $config ) ) {
+ $error = sprintf(
+ /* translators: %s: index.php */
+ __( 'The %s of a field collection must return an array.' ),
+ '<code>index.php</code>'
+ );
+ } elseif ( ! isset( $config['origin'] ) || ! is_string( $config['origin'] ) || '' === $config['origin'] ) {
+ $error = sprintf(
+ /* translators: %s: origin */
+ __( 'The %s of a field collection must be a non-empty string.' ),
+ '<code>origin</code>'
+ );
+ } elseif ( ! isset( $config['kind'] ) || ! is_string( $config['kind'] ) || '' === $config['kind'] ) {
+ $error = sprintf(
+ /* translators: %s: kind */
+ __( 'The %s of a field collection must be a non-empty string.' ),
+ '<code>kind</code>'
+ );
+ } elseif ( ! isset( $config['name'] ) || ! is_string( $config['name'] ) || '' === $config['name'] ) {
+ $error = sprintf(
+ /* translators: %s: name */
+ __( 'The %s of a field collection must be a non-empty string.' ),
+ '<code>name</code>'
+ );
+ } elseif ( isset( $config['module'] ) && ( ! is_string( $config['module'] ) || '' === $config['module'] ) ) {
+ $error = sprintf(
+ /* translators: %s: module */
+ __( 'The %s of a field collection must be the id of a script module.' ),
+ '<code>module</code>'
+ );
+ }
+
+ if ( null !== $error ) {
+ _doing_it_wrong(
+ $method,
+ /* translators: 1: The name of a field collection. 2: The error. */
+ sprintf( __( 'The field collection "%1$s" is skipped. %2$s' ), $slug, $error ),
+ '7.2.0'
+ );
+ return null;
+ }
+
+ $read = $this->read_collection_fields( $method, $directory );
+
+ return array_merge(
+ $config,
+ array(
+ 'module' => $config['module'] ?? null,
+ 'fields' => array_values( $read['fields'] ),
+ 'skipped' => $read['skipped'],
+ )
+ );
+ }
+
+ /**
+ * Reads the fields of a field collection, see get_collection_fields().
+ *
+ * The id of a field is used as a key, so it is checked before: a
+ * `field.php` that does not return an array with a non-empty string id
+ * is reported with _doing_it_wrong() and skipped, and so is one whose
+ * id an earlier folder of the collection took. The rest of the fields
+ * are read.
+ *
+ * @since 7.2.0
+ *
+ * @param string $method The calling method, for the notice.
+ * @param string $directory The directory of the collection.
+ * @return array {
+ * The fields of the collection.
+ *
+ * @type array<string, array> $fields The valid fields, keyed by id,
+ * in the alphabetical order of
+ * their folders.
+ * @type bool $skipped Whether a `field.php` was skipped.
+ * }
+ */
+ protected function read_collection_fields( $method, $directory ) {
+ $files = glob( $directory . '/*/field.php' );
+ if ( empty( $files ) ) {
+ return array(
+ 'fields' => array(),
+ 'skipped' => false,
+ );
+ }
+ sort( $files, SORT_STRING );
+
+ $fields = array();
+ $invalid = array();
+ $duplicated = array();
+ foreach ( $files as $file ) {
+ $folder = basename( dirname( $file ) );
+ $field = self::require_file( $file );
+ if ( ! is_array( $field ) ) {
+ $invalid[] = $folder;
+ continue;
+ }
+
+ // The id comes first, like in the fields registered directly.
+ $field = array_merge( array( 'id' => $folder ), $field );
+ $id = $field['id'];
+ if ( ! is_string( $id ) || '' === $id ) {
+ $invalid[] = $folder;
+ } elseif ( isset( $fields[ $id ] ) ) {
+ $duplicated[] = $folder;
+ } else {
+ $fields[ $id ] = $field;
+ }
+ }
+
+ $collection = basename( $directory );
+ if ( $invalid ) {
+ _doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: 1: The name of a field collection. 2: Comma-separated list of folder names. 3: field.php 4: id */
+ __( 'The %3$s of a field must return an array, with a non-empty string %4$s if it sets one. These fields of the field collection "%1$s" are skipped: %2$s.' ),
+ $collection,
+ implode( ', ', $invalid ),
+ '<code>field.php</code>',
+ '<code>id</code>'
+ ),
+ '7.2.0'
+ );
+ }
+
+ if ( $duplicated ) {
+ _doing_it_wrong(
+ $method,
+ sprintf(
+ /* translators: 1: The name of a field collection. 2: Comma-separated list of folder names. */
+ __( 'A field can only be defined once in a field collection. These fields of the field collection "%1$s" are skipped because an earlier folder defines their id: %2$s.' ),
+ $collection,
+ implode( ', ', $duplicated )
+ ),
+ '7.2.0'
+ );
+ }
+
+ return array(
+ 'fields' => $fields,
+ 'skipped' => $invalid || $duplicated,
+ );
+ }
+
+ /**
+ * Requires a file without sharing the variables of the caller: this is
+ * about variable scope.
+ *
+ * @since 7.2.0
+ *
+ * @param string $file The path of the file.
+ * @return mixed The return value of the file.
+ */
+ protected static function require_file( $file ) {
+ return require $file;
+ }
+
+ /**
+ * Applies a script module to fields of an entity, after the fields it
+ * already applies to.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind.
+ * @param string $name The entity name.
+ * @param string[] $ids The ids of the fields.
+ * @param string|null $script_module The id of the script module, if any.
+ */
+ protected function add_field_module( $kind, $name, $ids, $script_module ) {
+ if ( null === $script_module ) {
+ return;
+ }
+
+ $module_ids = $this->field_modules[ $kind ][ $name ][ $script_module ] ?? array();
+ foreach ( $ids as $id ) {
+ if ( ! in_array( $id, $module_ids, true ) ) {
+ $module_ids[] = $id;
+ }
+ }
+ $this->field_modules[ $kind ][ $name ][ $script_module ] = $module_ids;
+ }
+
+ /**
+ * Forgets an entity left without fields or script modules, and its kind
+ * once no entity of the kind is left, so the registry only lists
+ * entities that have some.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind.
+ * @param string $name The entity name.
+ */
+ protected function forget_empty_entity( $kind, $name ) {
+ if ( empty( $this->fields[ $kind ][ $name ] ) ) {
+ unset( $this->fields[ $kind ][ $name ] );
+ }
+ if ( empty( $this->fields[ $kind ] ) ) {
+ unset( $this->fields[ $kind ] );
+ }
+ if ( empty( $this->field_modules[ $kind ][ $name ] ) ) {
+ unset( $this->field_modules[ $kind ][ $name ] );
+ }
+ if ( empty( $this->field_modules[ $kind ] ) ) {
+ unset( $this->field_modules[ $kind ] );
+ }
+ }
+}
diff --git a/wp-includes/default-filters.php b/wp-includes/default-filters.php
index 6a635df738..16afab80fc 100644
--- a/wp-includes/default-filters.php
+++ b/wp-includes/default-filters.php
@@ -856,4 +856,8 @@ add_filter( 'get_entity_view_config_root_site', '_wp_get_entity_view_config_root
// Entity Actions API.
add_action( 'admin_footer', '_wp_add_entity_action_modules_to_editor_script', 10, 0 );
+// Entity Fields API.
+add_action( 'admin_footer', '_wp_add_entity_field_modules_to_editor_script', 10, 0 );
+add_filter( 'block_editor_rest_api_preload_paths', '_wp_preload_entity_fields', 10, 2 );
+
unset( $filter, $action, $post_type );
diff --git a/wp-includes/entity-fields.php b/wp-includes/entity-fields.php
new file mode 100644
index 0000000000..fff833b678
--- /dev/null
+++ b/wp-includes/entity-fields.php
@@ -0,0 +1,357 @@
+<?php
+/**
+ * Entity Fields API.
+ *
+ * A field definition is the serializable subset of the DataViews Field API:
+ * every property that is plain data (`id`, `type`, `label`, `elements`,
+ * `filterBy`, `isValid`, …). Properties that are JavaScript callbacks or
+ * components (`render`, `Edit` as a component, `getValue`, `setValue`, `sort`,
+ * `isVisible`, `getElements`, …) cannot be declared here. They are provided by
+ * a script module registered for the entity along with its fields: its default
+ * export maps field ids to the properties of each field that are JavaScript. An
+ * entity can have several modules. Each module applies to the fields it was
+ * registered with, see {@see wp_get_registered_entity_field_modules()}.
+ *
+ * Fields are registered on the `wp_entity_fields_api_init` action, and only there,
+ * with {@see wp_register_entity_fields()}, {@see wp_update_entity_fields()}, and
+ * {@see wp_unregister_entity_fields()}. The other functions read the registry.
+ *
+ * Fields can also be declared as files, grouped in a field collection, which
+ * a `wp_entity_fields_api_init` callback registers with
+ * {@see wp_register_entity_field_collection()}. A collection is for a single
+ * entity.
+ *
+ * The registered fields are exposed by the `/wp/v2/entity-fields` REST API
+ * route, see {@see WP_REST_Entity_Fields_Controller}.
+ *
+ * @package WordPress
+ * @subpackage Entity Fields API
+ * @since 7.2.0
+ */
+
+/**
+ * Registers fields for an entity, see
+ * {@see WP_Entity_Fields_Registry::register()}.
+ *
+ * It only runs on the `wp_entity_fields_api_init` action.
+ *
+ * @since 7.2.0
+ *
+ * @param string $origin Who registers the fields: `core`, or
+ * the slug of the plugin or theme.
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @param array[] $fields The list of field definitions, each with an `id`.
+ * @param string|null $script_module The id of the script module providing
+ * the JavaScript parts of the fields, if any.
+ * @return string[] The ids of the fields registered, in the order of the
+ * call. Empty when none is.
+ */
+function wp_register_entity_fields( $origin, $kind, $name, $fields, $script_module = null ) {
+ return WP_Entity_Fields_Registry::get_instance()->register( $origin, $kind, $name, $fields, $script_module );
+}
+
+/**
+ * Updates registered fields of an entity, see
+ * {@see WP_Entity_Fields_Registry::update()}.
+ *
+ * It only runs on the `wp_entity_fields_api_init` action.
+ *
+ * @since 7.2.0
+ *
+ * @param string $origin Who updates the fields: `core`, or
+ * the slug of the plugin or theme.
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @param array[] $fields The list of partial field definitions,
+ * each with the `id` of a registered field.
+ * @param string|null $script_module The id of the script module providing
+ * the JavaScript parts of the fields, if any.
+ * @return string[] The ids of the fields updated, in the order of the
+ * call. Empty when none is.
+ */
+function wp_update_entity_fields( $origin, $kind, $name, $fields, $script_module = null ) {
+ return WP_Entity_Fields_Registry::get_instance()->update( $origin, $kind, $name, $fields, $script_module );
+}
+
+/**
+ * Unregisters fields of an entity, see
+ * {@see WP_Entity_Fields_Registry::unregister()}.
+ *
+ * It only runs on the `wp_entity_fields_api_init` action.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @param string[]|null $ids The ids of the fields to unregister. Default
+ * null, every field of the entity.
+ * @return array[] The list of the definitions unregistered, in
+ * registration order. Empty when none is.
+ */
+function wp_unregister_entity_fields( $kind, $name, $ids = null ) {
+ return WP_Entity_Fields_Registry::get_instance()->unregister( $kind, $name, $ids );
+}
+
+/**
+ * Returns the fields registered for the given entity.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @return array[] The list of registered field definitions, in registration
+ * order. Empty until the `init` action has completed: a
+ * read before then is refused.
+ */
+function wp_get_registered_entity_fields( $kind, $name ) {
+ return WP_Entity_Fields_Registry::get_instance()->get_registered( $kind, $name );
+}
+
+/**
+ * Returns the ids of the script modules registered for the given entity.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @return array<string, string[]> The ids of the fields each module applies
+ * to, keyed by module id, in registration
+ * order. Empty until the `init` action has
+ * completed: a read before then is refused.
+ */
+function wp_get_registered_entity_field_modules( $kind, $name ) {
+ return WP_Entity_Fields_Registry::get_instance()->get_registered_field_modules( $kind, $name );
+}
+
+/**
+ * Returns the ids of all script modules registered for any entity.
+ *
+ * @since 7.2.0
+ *
+ * @return array<string, array<string, string[]>> The lists of module ids,
+ * keyed by kind, then by
+ * name. Empty until the
+ * `init` action has
+ * completed: a read before
+ * then is refused.
+ */
+function wp_get_all_registered_entity_field_modules() {
+ return WP_Entity_Fields_Registry::get_instance()->get_all_registered_field_modules();
+}
+
+/**
+ * Declares the script modules of the entity fields as dependencies of the
+ * editor script.
+ *
+ * The editor imports the script modules of an entity on demand, with a dynamic
+ * `import()` the browser resolves through the import map of the page. Listing
+ * the modules as dynamic `module_dependencies` of the `wp-editor` script adds
+ * them to the import map of every page that loads the editor script: the post
+ * editor, the site editor, and the pages booted by `@wordpress/boot`, whose
+ * prerequisites depend on it. A dynamic dependency is only listed in the
+ * import map; the module is fetched when it is imported.
+ *
+ * Reading the registry fires `wp_entity_fields_api_init`, so the fields and their
+ * script modules are registered on demand. It only reads the registry on the
+ * pages that load the editor script, not on every admin request (heartbeat,
+ * Ajax, other screens). It runs on `admin_footer`, the last action before the
+ * import map is printed that fires both in the admin template and in the pages
+ * rendered outside it, which print the import map themselves. By then `init`
+ * has completed, so the supports the default fields derive from are final.
+ * The registry only accepts registrations while the action fires, so the
+ * modules declared here are the modules of every field the page can show.
+ *
+ * It is hooked to `admin_footer` without arguments, so the global scripts
+ * registry is used. In other scenarios (tests), the scripts are given directly.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @param WP_Scripts|null $scripts Optional. The scripts registry. Default null,
+ * the global one.
+ */
+function _wp_add_entity_field_modules_to_editor_script( $scripts = null ) {
+ if ( ! $scripts instanceof WP_Scripts ) {
+ $scripts = wp_scripts();
+ }
+
+ /*
+ * The fields are loaded by the editor package, see
+ * packages/editor/src/dataviews/store/private-resolvers.ts in Gutenberg,
+ * which means any screen that wants to use this mechanism needs to load
+ * the editor script.
+ */
+ if ( ! $scripts->query( 'wp-editor', 'enqueued' ) && ! $scripts->query( 'wp-editor', 'done' ) ) {
+ return;
+ }
+
+ $kinds = wp_get_all_registered_entity_field_modules();
+ if ( empty( $kinds ) ) {
+ return;
+ }
+
+ $dependencies = $scripts->get_data( 'wp-editor', 'module_dependencies' );
+ $dependencies = is_array( $dependencies ) ? $dependencies : array();
+
+ $declared = array();
+ foreach ( $dependencies as $dependency ) {
+ $declared[] = is_array( $dependency ) ? $dependency['id'] : $dependency;
+ }
+
+ $added = false;
+ foreach ( $kinds as $entities ) {
+ foreach ( $entities as $modules ) {
+ foreach ( $modules as $module ) {
+ if ( ! in_array( $module, $declared, true ) ) {
+ $dependencies[] = array(
+ 'id' => $module,
+ 'import' => 'dynamic',
+ );
+ $declared[] = $module;
+ $added = true;
+ }
+ }
+ }
+ }
+
+ if ( $added ) {
+ // `add_data()` replaces the value, hence the merge above.
+ $scripts->add_data( 'wp-editor', 'module_dependencies', $dependencies );
+ }
+}
+
+/**
+ * Returns the fields of a field collection, see
+ * {@see WP_Entity_Fields_Registry::get_collection_fields()}.
+ *
+ * @since 7.2.0
+ *
+ * @param string $directory The directory of the collection.
+ * @return array<string, array> The fields of the collection, keyed by id, in
+ * the alphabetical order of their folders.
+ */
+function wp_get_entity_field_collection_fields( $directory ) {
+ return WP_Entity_Fields_Registry::get_instance()->get_collection_fields( $directory );
+}
+
+/**
+ * Registers the fields of a field collection, see
+ * {@see WP_Entity_Fields_Registry::register_collection()} for the format of a
+ * collection.
+ *
+ * Like wp_register_entity_fields(), it only runs on the `wp_entity_fields_api_init`
+ * action.
+ *
+ * @since 7.2.0
+ *
+ * @param string $directory The directory of the collection.
+ * @return bool Whether the collection is valid and all of its fields are
+ * registered. True as well when it has no fields.
+ */
+function wp_register_entity_field_collection( $directory ) {
+ return WP_Entity_Fields_Registry::get_instance()->register_collection( $directory );
+}
+
+/**
+ * Returns the paths that serve the fields of the given post types, skipping
+ * the post types that are not registered.
+ *
+ * A path must match the request of the `getFieldsConfig` core data resolver,
+ * see packages/core-data/src/resolvers.js in Gutenberg.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @param string[] $post_types The post types.
+ * @return string[] One path of the `/wp/v2/entity-fields` route per post type,
+ * in the order the post types are given, without duplicates.
+ */
+function _wp_get_post_type_fields_preload_paths( $post_types ) {
+ $paths = array();
+ foreach ( array_unique( $post_types ) as $post_type ) {
+ if ( ! post_type_exists( $post_type ) ) {
+ continue;
+ }
+ $paths[] = add_query_arg(
+ array(
+ 'kind' => 'postType',
+ 'name' => $post_type,
+ ),
+ '/wp/v2/entity-fields'
+ );
+ }
+
+ return $paths;
+}
+
+/**
+ * Returns the post types whose fields the screen of the site editor being
+ * loaded lists, from its `p` query arg.
+ *
+ * The screens are the routes in
+ * packages/edit-site/src/components/site-editor-routes in Gutenberg: the
+ * lists and the editors of pages, templates, patterns, and template parts.
+ * The patterns screen lists both patterns and template parts.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @return string[] The post types, empty for any other screen.
+ */
+function _wp_get_site_editor_screen_post_types() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only picks what to preload.
+ $path = isset( $_GET['p'] ) && is_string( $_GET['p'] ) ? sanitize_text_field( wp_unslash( $_GET['p'] ) ) : '';
+ $segments = explode( '/', trim( $path, '/' ) );
+
+ switch ( $segments[0] ) {
+ case 'page':
+ return array( 'page' );
+ case 'template':
+ case 'wp_template':
+ return array( 'wp_template' );
+ case 'pattern':
+ return array( 'wp_block', 'wp_template_part' );
+ case 'wp_block':
+ return array( 'wp_block' );
+ case 'wp_template_part':
+ return array( 'wp_template_part' );
+ }
+ return array();
+}
+
+/**
+ * Preloads the fields of the post types the site editor lists.
+ *
+ * The site editor reads them from the `/wp/v2/entity-fields` route before it
+ * can list the post fields, see `useFields` in
+ * packages/editor/src/dataviews/fields-loader/index.ts in Gutenberg: those of
+ * the edited post, if any, and of the post types of the screen it opens on.
+ *
+ * The post editor is left untouched: it only reads the fields in the
+ * DataForm-based inspector, an experiment of the Gutenberg plugin which
+ * preloads them on its own.
+ *
+ * @since 7.2.0
+ * @access private
+ *
+ * @param array $paths REST API paths to preload.
+ * @param WP_Block_Editor_Context $context Block editor context.
+ * @return array Filtered preload paths.
+ */
+function _wp_preload_entity_fields( $paths, $context ) {
+ if ( 'core/edit-site' !== $context->name ) {
+ return $paths;
+ }
+
+ $post_types = array();
+ if ( isset( $context->post ) ) {
+ $post_types[] = $context->post->post_type;
+ }
+ $post_types = array_merge( $post_types, _wp_get_site_editor_screen_post_types() );
+
+ return array_merge(
+ $paths,
+ _wp_get_post_type_fields_preload_paths( $post_types )
+ );
+}
diff --git a/wp-includes/rest-api.php b/wp-includes/rest-api.php
index 2861e08b58..4ec8f119f3 100644
--- a/wp-includes/rest-api.php
+++ b/wp-includes/rest-api.php
@@ -440,6 +440,10 @@ function create_initial_rest_routes() {
// Entity Actions.
$entity_actions_controller = new WP_REST_Entity_Actions_Controller();
$entity_actions_controller->register_routes();
+
+ // Entity Fields.
+ $entity_fields_controller = new WP_REST_Entity_Fields_Controller();
+ $entity_fields_controller->register_routes();
}
/**
diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php
new file mode 100644
index 0000000000..dae68fff62
--- /dev/null
+++ b/wp-includes/rest-api/endpoints/class-wp-rest-entity-fields-controller.php
@@ -0,0 +1,402 @@
+<?php
+/**
+ * REST API: WP_REST_Entity_Fields_Controller class
+ *
+ * @package WordPress
+ * @subpackage REST_API
+ * @since 7.2.0
+ */
+
+/**
+ * Controller which provides a REST endpoint for retrieving the fields
+ * registered for a given entity type.
+ *
+ * The fields are the ones registered on the server on the
+ * `wp_entity_fields_api_init` action: the serializable part of each field, plus
+ * the script modules that provide the JavaScript parts (render callbacks,
+ * components, value getters and setters), each with the ids of the fields
+ * it applies to. The client merges both into the fields it derives itself.
+ *
+ * @since 7.2.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Entity_Fields_Controller extends WP_REST_Controller {
+
+ /**
+ * Constructor.
+ *
+ * @since 7.2.0
+ */
+ public function __construct() {
+ $this->namespace = 'wp/v2';
+ $this->rest_base = 'entity-fields';
+ }
+
+ /**
+ * Registers the routes for the controller.
+ *
+ * @since 7.2.0
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ '/' . $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_items' ),
+ 'permission_callback' => array( $this, 'get_items_permissions_check' ),
+ 'args' => array(
+ 'kind' => array(
+ 'description' => __( 'Entity kind.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Entity name.' ),
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ ),
+ ),
+ 'schema' => array( $this, 'get_public_item_schema' ),
+ )
+ );
+ }
+
+ /**
+ * Checks if a given request has access to read the fields of an entity.
+ *
+ * @since 7.2.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return true|WP_Error True if the request has read access, WP_Error object otherwise.
+ */
+ public function get_items_permissions_check( $request ) {
+ $kind = $request->get_param( 'kind' );
+ $name = $request->get_param( 'name' );
+
+ $capability = $this->get_required_capability( $kind, $name );
+
+ if ( null === $capability ) {
+ return new WP_Error(
+ 'rest_entity_fields_invalid_entity',
+ __( 'Invalid entity kind or name.' ),
+ array( 'status' => 404 )
+ );
+ }
+
+ if ( ! current_user_can( $capability ) ) {
+ return new WP_Error(
+ 'rest_cannot_read',
+ __( 'Sorry, you are not allowed to read the fields of this entity.' ),
+ array( 'status' => rest_authorization_required_code() )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Resolves the capability required to read the fields of an entity.
+ *
+ * Mirrors the view config endpoint, which gates the same entities: post
+ * types use their own `edit_posts` capability (which honors custom
+ * `capability_type` registrations), taxonomies use `manage_terms`, and
+ * root-level entities use `manage_options`. A post type or taxonomy that is
+ * not registered, or not exposed to the REST API, resolves to `null` so the
+ * request is treated as referencing an unknown entity.
+ *
+ * Any other kind falls back to `edit_posts`, so fields registered for a
+ * custom entity kind stay readable behind a baseline capability.
+ *
+ * @since 7.2.0
+ *
+ * @param string $kind The entity kind (e.g. `postType`).
+ * @param string $name The entity name (e.g. `page`).
+ * @return string|null Capability required to read the fields, or null if
+ * the entity is not registered.
+ */
+ protected function get_required_capability( $kind, $name ) {
+ switch ( $kind ) {
+ case 'postType':
+ $post_type = get_post_type_object( $name );
+ if ( $post_type && $post_type->show_in_rest ) {
+ return $post_type->cap->edit_posts;
+ }
+ return null;
+
+ case 'taxonomy':
+ $taxonomy = get_taxonomy( $name );
+ if ( $taxonomy && $taxonomy->show_in_rest ) {
+ return $taxonomy->cap->manage_terms;
+ }
+ return null;
+
+ case 'root':
+ return 'manage_options';
+ }
+
+ return 'edit_posts';
+ }
+
+ /**
+ * Returns the fields registered for the given entity type.
+ *
+ * @since 7.2.0
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+ */
+ public function get_items( $request ) {
+ $kind = $request->get_param( 'kind' );
+ $name = $request->get_param( 'name' );
+
+ $field_schema = $this->get_field_schema();
+ $fields = array();
+ foreach ( wp_get_registered_entity_fields( $kind, $name ) as $field ) {
+ $fields[] = _rest_cast_empty_objects_from_schema( $field, $field_schema );
+ }
+
+ $script_modules = array();
+ foreach ( wp_get_registered_entity_field_modules( $kind, $name ) as $module => $field_ids ) {
+ $script_modules[] = array(
+ 'id' => $module,
+ 'fields' => array_values( $field_ids ),
+ );
+ }
+
+ $response = array(
+ 'kind' => $kind,
+ 'name' => $name,
+ 'fields' => $fields,
+ 'script_modules' => $script_modules,
+ );
+
+ return rest_ensure_response( $response );
+ }
+
+ /**
+ * Retrieves the item's schema, conforming to JSON Schema.
+ *
+ * @since 7.2.0
+ *
+ * @return array Item schema data.
+ */
+ public function get_item_schema() {
+ if ( $this->schema ) {
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ $this->schema = array(
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'title' => 'entity-fields',
+ 'type' => 'object',
+ 'properties' => array(
+ 'kind' => array(
+ 'description' => __( 'Entity kind.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'name' => array(
+ 'description' => __( 'Entity name.' ),
+ 'type' => 'string',
+ 'readonly' => true,
+ ),
+ 'fields' => array(
+ 'description' => __( 'The fields registered for the entity, in registration order.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'items' => $this->get_field_schema(),
+ ),
+ 'script_modules' => array(
+ 'description' => __( 'The script modules providing the JavaScript parts of the fields, each with the ids of the fields it applies to.' ),
+ 'type' => 'array',
+ 'readonly' => true,
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'The id of the script module.' ),
+ 'type' => 'string',
+ ),
+ 'fields' => array(
+ 'description' => __( 'The ids of the fields the script module applies to.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ return $this->add_additional_fields_schema( $this->schema );
+ }
+
+ /**
+ * Returns the schema for a field definition.
+ *
+ * Describes the serializable subset of the DataViews Field API, see
+ * packages/dataviews/src/types/field-api.ts in Gutenberg. The properties
+ * that are JavaScript callbacks or components (`render`, `sort`,
+ * `getValue`, …) come from the script modules and are not part of the
+ * definition.
+ * Additional properties are allowed, so a plugin can register a field
+ * with properties of its own that its script module understands.
+ *
+ * @since 7.2.0
+ *
+ * @return array Schema for a field definition.
+ */
+ protected function get_field_schema() {
+ $option_schema = array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'value' => array(
+ 'description' => __( 'The value of the option.' ),
+ 'type' => array( 'string', 'integer', 'number', 'boolean', 'null' ),
+ ),
+ 'label' => array(
+ 'description' => __( 'The label of the option.' ),
+ 'type' => 'string',
+ ),
+ 'description' => array(
+ 'description' => __( 'The description of the option.' ),
+ 'type' => 'string',
+ ),
+ ),
+ );
+
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array(
+ 'description' => __( 'The unique identifier of the field.' ),
+ 'type' => 'string',
+ ),
+ 'origin' => array(
+ 'description' => sprintf(
+ /* translators: %s: core */
+ __( 'Who registered and updated the field: %s, or the slug of a plugin or theme.' ),
+ 'core'
+ ),
+ 'type' => 'object',
+ 'properties' => array(
+ 'registeredBy' => array(
+ 'description' => __( 'Who registered the field.' ),
+ 'type' => 'string',
+ ),
+ 'updatedBy' => array(
+ 'description' => __( 'Who updated the field, in update order.' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ ),
+ ),
+ ),
+ ),
+ 'type' => array(
+ 'description' => __( 'The type of the field, one of the types DataViews provides a control for.' ),
+ 'type' => 'string',
+ 'enum' => array( 'array', 'boolean', 'color', 'date', 'datetime', 'email', 'integer', 'media', 'number', 'password', 'telephone', 'text', 'time', 'url' ),
+ ),
+ 'label' => array(
+ 'description' => __( 'The label of the field.' ),
+ 'type' => 'string',
+ ),
+ 'header' => array(
+ 'description' => __( 'The header of the field. Defaults to the label.' ),
+ 'type' => 'string',
+ ),
+ 'description' => array(
+ 'description' => __( 'The description of the field.' ),
+ 'type' => 'string',
+ ),
+ 'placeholder' => array(
+ 'description' => __( 'The placeholder of the field.' ),
+ 'type' => 'string',
+ ),
+ 'Edit' => array(
+ 'description' => __( 'The control used to edit the field: the name of a control, or a control configuration.' ),
+ 'type' => array( 'string', 'object' ),
+ ),
+ 'isValid' => array(
+ 'description' => __( 'The validation rules of the field.' ),
+ 'type' => 'object',
+ ),
+ 'isDisabled' => array(
+ 'description' => __( 'Whether the field is disabled.' ),
+ 'type' => 'boolean',
+ ),
+ 'enableSorting' => array(
+ 'description' => __( 'Whether the field is sortable.' ),
+ 'type' => 'boolean',
+ ),
+ 'enableGlobalSearch' => array(
+ 'description' => __( 'Whether the field is searchable.' ),
+ 'type' => 'boolean',
+ ),
+ 'enableHiding' => array(
+ 'description' => __( 'Whether the field can be hidden.' ),
+ 'type' => 'boolean',
+ ),
+ 'elements' => array(
+ 'description' => __( 'The options to pick from when using the field as a filter.' ),
+ 'type' => 'array',
+ 'items' => $option_schema,
+ ),
+ 'filterBy' => array(
+ 'description' => __( 'The filter configuration of the field, or false when the field cannot be filtered.' ),
+ 'type' => array( 'object', 'boolean' ),
+ 'properties' => array(
+ 'operators' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ 'enum' => array(
+ 'is',
+ 'isNot',
+ 'isAny',
+ 'isNone',
+ 'isAll',
+ 'lessThan',
+ 'greaterThan',
+ 'lessThanOrEqual',
+ 'greaterThanOrEqual',
+ 'before',
+ 'after',
+ 'beforeInc',
+ 'afterInc',
+ 'contains',
+ 'notContains',
+ 'startsWith',
+ 'between',
+ 'on',
+ 'notOn',
+ 'inThePast',
+ 'over',
+ ),
+ ),
+ ),
+ 'isPrimary' => array(
+ 'type' => 'boolean',
+ ),
+ ),
+ ),
+ 'readOnly' => array(
+ 'description' => __( 'Whether the field is read only.' ),
+ 'type' => 'boolean',
+ ),
+ 'format' => array(
+ 'description' => __( 'The display format of the field.' ),
+ 'type' => 'object',
+ ),
+ ),
+ 'additionalProperties' => true,
+ );
+ }
+}
diff --git a/wp-includes/version.php b/wp-includes/version.php
index 6ac6f367cc..43cf1926f1 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-alpha-64527';
+$wp_version = '7.2-alpha-64528';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
diff --git a/wp-settings.php b/wp-settings.php
index 107e984a74..98ecc57819 100644
--- a/wp-settings.php
+++ b/wp-settings.php
@@ -208,6 +208,8 @@ require ABSPATH . WPINC . '/class-wp-view-config-data.php';
require ABSPATH . WPINC . '/view-config.php';
require ABSPATH . WPINC . '/class-wp-entity-actions-registry.php';
require ABSPATH . WPINC . '/entity-actions.php';
+require ABSPATH . WPINC . '/class-wp-entity-fields-registry.php';
+require ABSPATH . WPINC . '/entity-fields.php';
require ABSPATH . WPINC . '/https-detection.php';
require ABSPATH . WPINC . '/https-migration.php';
require ABSPATH . WPINC . '/class-wp-user-request.php';
@@ -382,6 +384,7 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icons-controller.ph
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-icon-collections-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-view-config-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-entity-actions-controller.php';
+require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-entity-fields-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php';
require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php';