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