diff --git a/Gruntfile.js b/Gruntfile.js index 61f18481e23a8..7d97636361d6f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1727,6 +1727,17 @@ module.exports = function(grunt) { } ); } ); + grunt.registerTask( 'verify:view-config-schema', 'Verifies the generated view-config REST schema PHP file is in sync with the canonical JSON Schema.', function() { + const done = this.async(); + grunt.util.spawn( { + cmd: 'node', + args: [ 'tools/rest-api/gen-view-config-schema-php.mjs', '--check' ], + opts: { stdio: 'inherit' } + }, function( error ) { + done( ! error ); + } ); + } ); + grunt.renameTask( 'watch', '_watch' ); grunt.registerTask( 'watch', function() { @@ -1765,6 +1776,7 @@ module.exports = function(grunt) { ] ); grunt.registerTask( 'precommit:php', [ + 'verify:view-config-schema', 'phpstan', 'phpunit' ] ); diff --git a/package.json b/package.json index 5264d752b8e4e..ec1eb1c6bad0c 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,8 @@ "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", - "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg" + "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg", + "view-config-schema:generate": "node tools/rest-api/gen-view-config-schema-php.mjs", + "view-config-schema:check": "node tools/rest-api/gen-view-config-schema-php.mjs --check" } } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index 64c1ebe1ba921..18f855a0896fc 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -242,6 +242,13 @@ protected function cast_empty_objects( $value, $schema ) { /** * Retrieves the item's schema, conforming to JSON Schema. * + * The schema is loaded from `wp-includes/rest-api/view-config-schema.php`, + * which is generated from the canonical JSON Schema at + * `tools/rest-api/view-config.json` (see + * `tools/rest-api/gen-view-config-schema-php.mjs`). The generated file + * wraps the JSON Schema descriptions in `__()` calls so they go through + * the translation pipeline. + * * @since 7.1.0 * * @return array Item schema data. @@ -251,596 +258,8 @@ public function get_item_schema() { return $this->add_additional_fields_schema( $this->schema ); } - $view_base_properties = $this->get_view_base_schema(); - - $this->schema = array( - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => 'view-config', - 'type' => 'object', - 'properties' => array( - 'kind' => array( - 'description' => __( 'Entity kind.' ), - 'type' => 'string', - 'readonly' => true, - ), - 'name' => array( - 'description' => __( 'Entity name.' ), - 'type' => 'string', - 'readonly' => true, - ), - 'version' => array( - 'description' => __( 'The schema version of the configuration.' ), - 'type' => 'integer', - 'readonly' => true, - ), - 'default_view' => array( - 'description' => __( 'Default view configuration.' ), - 'type' => 'object', - 'readonly' => true, - 'properties' => array_merge( - array( - 'type' => array( - 'type' => 'string', - ), - 'layout' => $this->get_combined_layout_schema(), - ), - $view_base_properties - ), - ), - 'default_layouts' => array( - 'description' => __( 'Default layout configurations.' ), - 'type' => 'object', - 'readonly' => true, - 'properties' => array( - 'table' => array( - 'type' => 'object', - 'properties' => array_merge( - $view_base_properties, - array( - 'layout' => $this->get_table_layout_schema(), - ) - ), - ), - 'list' => array( - 'type' => 'object', - 'properties' => array_merge( - $view_base_properties, - array( - 'layout' => $this->get_list_layout_schema(), - ) - ), - ), - 'grid' => array( - 'type' => 'object', - 'properties' => array_merge( - $view_base_properties, - array( - 'layout' => $this->get_grid_layout_schema(), - ) - ), - ), - 'activity' => array( - 'type' => 'object', - 'properties' => array_merge( - $view_base_properties, - array( - 'layout' => $this->get_list_layout_schema(), - ) - ), - ), - 'pickerGrid' => array( - 'type' => 'object', - 'properties' => array_merge( - $view_base_properties, - array( - 'layout' => $this->get_grid_layout_schema(), - ) - ), - ), - 'pickerTable' => array( - 'type' => 'object', - 'properties' => array_merge( - $view_base_properties, - array( - 'layout' => $this->get_table_layout_schema(), - ) - ), - ), - ), - ), - 'view_list' => array( - 'description' => __( 'List of default views.' ), - 'type' => 'array', - 'readonly' => true, - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'title' => array( - 'type' => 'string', - ), - 'slug' => array( - 'type' => 'string', - ), - 'view' => array( - 'type' => 'object', - 'properties' => array_merge( - array( - 'type' => array( - 'type' => 'string', - ), - 'layout' => $this->get_combined_layout_schema(), - ), - $view_base_properties - ), - ), - ), - ), - ), - 'form' => array( - 'description' => __( 'Default form configuration.' ), - 'type' => 'object', - 'readonly' => true, - 'properties' => $this->get_form_schema(), - ), - ), - ); + $this->schema = require ABSPATH . WPINC . '/rest-api/view-config-schema.php'; return $this->add_additional_fields_schema( $this->schema ); } - - /** - * Returns the schema properties shared by all view types (ViewBase), excluding 'type'. - * - * Note that `search` and `page` are not part of the schema: they are managed - * via the URL, which is their only source of truth. - * - * @since 7.1.0 - * - * @return array Schema properties for the base view configuration. - */ - protected function get_view_base_schema() { - return array( - 'filters' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'object', - 'properties' => array( - 'field' => array( - 'type' => 'string', - ), - 'operator' => array( - 'type' => 'string', - 'enum' => array( - 'is', - 'isNot', - 'isAny', - 'isNone', - 'isAll', - 'isNotAll', - 'lessThan', - 'greaterThan', - 'lessThanOrEqual', - 'greaterThanOrEqual', - 'before', - 'after', - ), - ), - 'value' => array(), - 'isLocked' => array( - 'type' => 'boolean', - ), - ), - ), - ), - 'sort' => array( - 'type' => 'object', - 'properties' => array( - 'field' => array( - 'type' => 'string', - ), - 'direction' => array( - 'type' => 'string', - 'enum' => array( 'asc', 'desc' ), - ), - ), - ), - 'perPage' => array( - 'type' => 'integer', - ), - 'fields' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - ), - ), - 'titleField' => array( - 'type' => 'string', - ), - 'mediaField' => array( - 'type' => 'string', - ), - 'descriptionField' => array( - 'type' => 'string', - ), - 'showTitle' => array( - 'type' => 'boolean', - ), - 'showMedia' => array( - 'type' => 'boolean', - ), - 'showDescription' => array( - 'type' => 'boolean', - ), - 'showLevels' => array( - 'type' => 'boolean', - ), - 'groupBy' => array( - 'type' => 'object', - 'properties' => array( - 'field' => array( - 'type' => 'string', - ), - 'direction' => array( - 'type' => 'string', - 'enum' => array( 'asc', 'desc' ), - ), - 'showLabel' => array( - 'type' => 'boolean', - 'default' => true, - ), - ), - ), - 'infiniteScrollEnabled' => array( - 'type' => 'boolean', - ), - ); - } - - /** - * Returns the schema for the ColumnStyle type. - * - * @since 7.1.0 - * - * @return array Schema for a column style object. - */ - protected function get_column_style_schema() { - return array( - 'type' => 'object', - 'properties' => array( - 'width' => array( - 'type' => array( 'string', 'number' ), - ), - 'maxWidth' => array( - 'type' => array( 'string', 'number' ), - ), - 'minWidth' => array( - 'type' => array( 'string', 'number' ), - ), - 'align' => array( - 'type' => 'string', - 'enum' => array( 'start', 'center', 'end' ), - ), - ), - ); - } - - /** - * Returns the layout schema for table-type views (ViewTable, ViewPickerTable). - * - * @since 7.1.0 - * - * @return array Schema for a table layout object. - */ - protected function get_table_layout_schema() { - return array( - 'type' => 'object', - 'properties' => array( - 'styles' => array( - 'type' => 'object', - 'additionalProperties' => $this->get_column_style_schema(), - ), - 'density' => array( - 'type' => 'string', - 'enum' => array( 'compact', 'balanced', 'comfortable' ), - ), - 'enableMoving' => array( - 'type' => 'boolean', - ), - ), - ); - } - - /** - * Returns the layout schema for list-type views (ViewList, ViewActivity). - * - * @since 7.1.0 - * - * @return array Schema for a list layout object. - */ - protected function get_list_layout_schema() { - return array( - 'type' => 'object', - 'properties' => array( - 'density' => array( - 'type' => 'string', - 'enum' => array( 'compact', 'balanced', 'comfortable' ), - ), - ), - ); - } - - /** - * Returns a combined layout schema that accepts properties from all view types. - * - * This is useful for contexts where the view type is not known ahead of time - * (e.g. the `view` override in a view list item), so all possible layout - * properties must be accepted. - * - * @since 7.1.0 - * - * @return array Schema for a combined layout object. - */ - protected function get_combined_layout_schema() { - return array( - 'type' => 'object', - 'properties' => array_merge( - $this->get_table_layout_schema()['properties'], - $this->get_grid_layout_schema()['properties'], - $this->get_list_layout_schema()['properties'] - ), - ); - } - - /** - * Returns the layout schema for grid-type views (ViewGrid, ViewPickerGrid). - * - * @since 7.1.0 - * - * @return array Schema for a grid layout object. - */ - protected function get_grid_layout_schema() { - return array( - 'type' => 'object', - 'properties' => array( - 'badgeFields' => array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - ), - ), - 'previewSize' => array( - 'type' => 'number', - ), - 'density' => array( - 'type' => 'string', - 'enum' => array( 'compact', 'balanced', 'comfortable' ), - ), - ), - ); - } - - /** - * Returns the schema for a form layout object as a discriminated union. - * - * Each variant is discriminated by a single-value enum on its `type` property, - * matching the TypeScript Layout union in dataviews/src/types/dataform.ts. - * - * @since 7.1.0 - * - * @return array Schema for a form layout object. - */ - protected function get_form_layout_schema() { - return array( - 'oneOf' => array( - // RegularLayout. - array( - 'type' => 'object', - 'properties' => array( - 'type' => array( - 'type' => 'string', - 'enum' => array( 'regular' ), - ), - 'labelPosition' => array( - 'type' => 'string', - 'enum' => array( 'top', 'side', 'none' ), - ), - ), - ), - // PanelLayout. - array( - 'type' => 'object', - 'properties' => array( - 'type' => array( - 'type' => 'string', - 'enum' => array( 'panel' ), - ), - 'labelPosition' => array( - 'type' => 'string', - 'enum' => array( 'top', 'side', 'none' ), - ), - 'openAs' => array( - 'oneOf' => array( - array( - 'type' => 'string', - 'enum' => array( 'dropdown', 'modal' ), - ), - array( - 'type' => 'object', - 'properties' => array( - 'type' => array( - 'type' => 'string', - 'enum' => array( 'dropdown', 'modal' ), - ), - 'applyLabel' => array( - 'type' => 'string', - ), - 'cancelLabel' => array( - 'type' => 'string', - ), - ), - ), - ), - ), - 'summary' => array( - 'oneOf' => array( - array( 'type' => 'string' ), - array( - 'type' => 'array', - 'items' => array( - 'type' => 'string', - ), - ), - ), - ), - 'editVisibility' => array( - 'type' => 'string', - 'enum' => array( 'always', 'on-hover' ), - ), - ), - ), - // CardLayout. - array( - 'type' => 'object', - 'properties' => array( - 'type' => array( - 'type' => 'string', - 'enum' => array( 'card' ), - ), - 'withHeader' => array( - 'type' => 'boolean', - ), - 'isOpened' => array( - 'type' => 'boolean', - ), - 'isCollapsible' => array( - 'type' => 'boolean', - ), - 'summary' => array( - 'oneOf' => array( - array( 'type' => 'string' ), - array( - 'type' => 'array', - 'items' => array( - 'oneOf' => array( - array( 'type' => 'string' ), - array( - 'type' => 'object', - 'properties' => array( - 'id' => array( - 'type' => 'string', - ), - 'visibility' => array( - 'type' => 'string', - 'enum' => array( 'always', 'when-collapsed' ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - // RowLayout. - array( - 'type' => 'object', - 'properties' => array( - 'type' => array( - 'type' => 'string', - 'enum' => array( 'row' ), - ), - 'alignment' => array( - 'type' => 'string', - 'enum' => array( 'start', 'center', 'end' ), - ), - 'styles' => array( - 'type' => 'object', - 'additionalProperties' => array( - 'type' => 'object', - 'properties' => array( - 'flex' => array( - 'type' => array( 'string', 'number' ), - ), - ), - ), - ), - ), - ), - // DetailsLayout. - array( - 'type' => 'object', - 'properties' => array( - 'type' => array( - 'type' => 'string', - 'enum' => array( 'details' ), - ), - 'summary' => array( - 'type' => 'string', - ), - ), - ), - ), - ); - } - - /** - * Returns the schema for a form field item (string or object). - * - * @since 7.1.0 - * - * @return array Schema for a form field. - */ - protected function get_form_field_schema() { - return array( - 'oneOf' => array( - array( 'type' => 'string' ), - array( - 'type' => 'object', - 'properties' => array( - 'id' => array( - 'type' => 'string', - ), - 'label' => array( - 'type' => 'string', - ), - 'description' => array( - 'type' => 'string', - ), - 'layout' => $this->get_form_layout_schema(), - 'children' => array( - 'type' => 'array', - 'items' => array( - 'oneOf' => array( - array( 'type' => 'string' ), - // This object can have the shape of a form field itself, - // allowing for recursive nesting of form fields. - // There's no easy way to codify this recursion via the JSON Schema draft-04 - // supported by the REST API. - array( 'type' => 'object' ), - ), - ), - ), - ), - ), - ), - ); - } - - /** - * Returns the schema for the form configuration object. - * - * @since 7.1.0 - * - * @return array Schema properties for the form configuration. - */ - protected function get_form_schema() { - return array( - 'layout' => $this->get_form_layout_schema(), - 'fields' => array( - 'type' => 'array', - 'items' => $this->get_form_field_schema(), - ), - ); - } } diff --git a/src/wp-includes/rest-api/view-config-schema.php b/src/wp-includes/rest-api/view-config-schema.php new file mode 100644 index 0000000000000..ff5915523b5ba --- /dev/null +++ b/src/wp-includes/rest-api/view-config-schema.php @@ -0,0 +1,1944 @@ + 'http://json-schema.org/draft-04/schema#', + 'title' => 'view-config', + 'type' => 'object', + 'properties' => array( + 'kind' => array( + 'description' => __( 'Entity kind (e.g. `postType`).' ), + 'type' => 'string', + 'readonly' => true, + ), + 'name' => array( + 'description' => __( 'Entity name (e.g. `page`).' ), + 'type' => 'string', + 'readonly' => true, + ), + 'version' => array( + 'description' => __( 'The schema version of the configuration.' ), + 'type' => 'integer', + 'readonly' => true, + ), + 'default_view' => array( + 'description' => __( 'The default DataViews configuration for the screen: layout type, visible fields, sorting, filtering, and pagination.' ), + 'type' => 'object', + 'readonly' => true, + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type, one of `table`, `grid`, `list`, `activity`, `pickerGrid`, or `pickerTable`.' ), + 'type' => 'string', + ), + 'layout' => array( + 'description' => __( 'Configuration specific to the selected layout type. Accepts the `layout` options of any layout type; see [`default_layouts`](#default_layouts).' ), + 'type' => 'object', + 'properties' => array( + 'styles' => array( + 'description' => __( 'The styles for the columns, keyed by field id. Each column style accepts `width`, `maxWidth`, and `minWidth` (a CSS value or a number of pixels) and `align` (`start`, `center`, or `end`).' ), + 'type' => 'object', + 'additionalProperties' => array( + 'description' => __( 'The style of a single field column.' ), + 'type' => 'object', + 'properties' => array( + 'width' => array( + 'description' => __( 'The width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'maxWidth' => array( + 'description' => __( 'The maximum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'minWidth' => array( + 'description' => __( 'The minimum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'align' => array( + 'description' => __( 'The alignment of the field column: `start`, `center`, or `end`. Defaults to `start`.' ), + 'type' => 'string', + 'enum' => array( + 'start', + 'center', + 'end', + ), + ), + ), + ), + ), + 'density' => array( + 'description' => __( 'The density of the layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + 'enableMoving' => array( + 'description' => __( 'Whether the user can reorder columns.' ), + 'type' => 'boolean', + ), + 'badgeFields' => array( + 'description' => __( 'Ids of the fields to display as badges instead of regular fields.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'previewSize' => array( + 'description' => __( 'The preview size of the grid.' ), + 'type' => 'number', + ), + ), + ), + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + ), + ), + 'default_layouts' => array( + 'description' => __( 'The layout types the user can switch between, and the view overrides each one applies.' ), + 'type' => 'object', + 'readonly' => true, + 'properties' => array( + 'table' => array( + 'description' => __( 'View overrides applied when the table layout is selected.' ), + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + 'layout' => array( + 'description' => __( 'Options specific to table-type layouts (`table`, `pickerTable`).' ), + 'type' => 'object', + 'properties' => array( + 'styles' => array( + 'description' => __( 'The styles for the columns, keyed by field id. Each column style accepts `width`, `maxWidth`, and `minWidth` (a CSS value or a number of pixels) and `align` (`start`, `center`, or `end`).' ), + 'type' => 'object', + 'additionalProperties' => array( + 'description' => __( 'The style of a single field column.' ), + 'type' => 'object', + 'properties' => array( + 'width' => array( + 'description' => __( 'The width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'maxWidth' => array( + 'description' => __( 'The maximum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'minWidth' => array( + 'description' => __( 'The minimum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'align' => array( + 'description' => __( 'The alignment of the field column: `start`, `center`, or `end`. Defaults to `start`.' ), + 'type' => 'string', + 'enum' => array( + 'start', + 'center', + 'end', + ), + ), + ), + ), + ), + 'density' => array( + 'description' => __( 'The density of the layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + 'enableMoving' => array( + 'description' => __( 'Whether the user can reorder columns.' ), + 'type' => 'boolean', + ), + ), + ), + ), + ), + 'list' => array( + 'description' => __( 'View overrides applied when the list layout is selected.' ), + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + 'layout' => array( + 'description' => __( 'Options specific to list-type layouts (`list`, `activity`).' ), + 'type' => 'object', + 'properties' => array( + 'density' => array( + 'description' => __( 'The density of the layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + ), + ), + ), + ), + 'grid' => array( + 'description' => __( 'View overrides applied when the grid layout is selected.' ), + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + 'layout' => array( + 'description' => __( 'Options specific to grid-type layouts (`grid`, `pickerGrid`).' ), + 'type' => 'object', + 'properties' => array( + 'badgeFields' => array( + 'description' => __( 'Ids of the fields to display as badges instead of regular fields.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'previewSize' => array( + 'description' => __( 'The preview size of the grid.' ), + 'type' => 'number', + ), + 'density' => array( + 'description' => __( 'The density of the grid layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + ), + ), + ), + ), + 'activity' => array( + 'description' => __( 'View overrides applied when the activity layout is selected.' ), + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + 'layout' => array( + 'description' => __( 'Options specific to list-type layouts (`list`, `activity`).' ), + 'type' => 'object', + 'properties' => array( + 'density' => array( + 'description' => __( 'The density of the layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + ), + ), + ), + ), + 'pickerGrid' => array( + 'description' => __( 'View overrides applied when the grid layout of a picker (DataViewsPicker) is selected.' ), + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + 'layout' => array( + 'description' => __( 'Options specific to grid-type layouts (`grid`, `pickerGrid`).' ), + 'type' => 'object', + 'properties' => array( + 'badgeFields' => array( + 'description' => __( 'Ids of the fields to display as badges instead of regular fields.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'previewSize' => array( + 'description' => __( 'The preview size of the grid.' ), + 'type' => 'number', + ), + 'density' => array( + 'description' => __( 'The density of the grid layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + ), + ), + ), + ), + 'pickerTable' => array( + 'description' => __( 'View overrides applied when the table layout of a picker (DataViewsPicker) is selected.' ), + 'type' => 'object', + 'properties' => array( + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + 'layout' => array( + 'description' => __( 'Options specific to table-type layouts (`table`, `pickerTable`).' ), + 'type' => 'object', + 'properties' => array( + 'styles' => array( + 'description' => __( 'The styles for the columns, keyed by field id. Each column style accepts `width`, `maxWidth`, and `minWidth` (a CSS value or a number of pixels) and `align` (`start`, `center`, or `end`).' ), + 'type' => 'object', + 'additionalProperties' => array( + 'description' => __( 'The style of a single field column.' ), + 'type' => 'object', + 'properties' => array( + 'width' => array( + 'description' => __( 'The width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'maxWidth' => array( + 'description' => __( 'The maximum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'minWidth' => array( + 'description' => __( 'The minimum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'align' => array( + 'description' => __( 'The alignment of the field column: `start`, `center`, or `end`. Defaults to `start`.' ), + 'type' => 'string', + 'enum' => array( + 'start', + 'center', + 'end', + ), + ), + ), + ), + ), + 'density' => array( + 'description' => __( 'The density of the layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + 'enableMoving' => array( + 'description' => __( 'Whether the user can reorder columns.' ), + 'type' => 'boolean', + ), + ), + ), + ), + ), + ), + ), + 'view_list' => array( + 'description' => __( 'The preconfigured views displayed in the screen\'s sidebar.' ), + 'type' => 'array', + 'readonly' => true, + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'title' => array( + 'description' => __( 'Title of the view, displayed in the sidebar.' ), + 'type' => 'string', + ), + 'slug' => array( + 'description' => __( 'Unique identifier for the view. Used as the member identity when merging patches.' ), + 'type' => 'string', + ), + 'view' => array( + 'description' => __( 'Partial view configuration applied on top of `default_view` when the view is selected — typically locked `filters`, but any view property works. Optional.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type, one of `table`, `grid`, `list`, `activity`, `pickerGrid`, or `pickerTable`.' ), + 'type' => 'string', + ), + 'layout' => array( + 'description' => __( 'Configuration specific to the selected layout type. Accepts the `layout` options of any layout type; see [`default_layouts`](#default_layouts).' ), + 'type' => 'object', + 'properties' => array( + 'styles' => array( + 'description' => __( 'The styles for the columns, keyed by field id. Each column style accepts `width`, `maxWidth`, and `minWidth` (a CSS value or a number of pixels) and `align` (`start`, `center`, or `end`).' ), + 'type' => 'object', + 'additionalProperties' => array( + 'description' => __( 'The style of a single field column.' ), + 'type' => 'object', + 'properties' => array( + 'width' => array( + 'description' => __( 'The width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'maxWidth' => array( + 'description' => __( 'The maximum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'minWidth' => array( + 'description' => __( 'The minimum width of the field column, as a CSS value or a number of pixels.' ), + 'type' => array( + 'string', + 'number', + ), + ), + 'align' => array( + 'description' => __( 'The alignment of the field column: `start`, `center`, or `end`. Defaults to `start`.' ), + 'type' => 'string', + 'enum' => array( + 'start', + 'center', + 'end', + ), + ), + ), + ), + ), + 'density' => array( + 'description' => __( 'The density of the layout: `compact`, `balanced`, or `comfortable`.' ), + 'type' => 'string', + 'enum' => array( + 'compact', + 'balanced', + 'comfortable', + ), + ), + 'enableMoving' => array( + 'description' => __( 'Whether the user can reorder columns.' ), + 'type' => 'boolean', + ), + 'badgeFields' => array( + 'description' => __( 'Ids of the fields to display as badges instead of regular fields.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'previewSize' => array( + 'description' => __( 'The preview size of the grid.' ), + 'type' => 'number', + ), + ), + ), + 'filters' => array( + 'description' => __( 'Filters applied to the dataset. A filter with `isLocked` set cannot be changed or removed by the user.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to filter by.' ), + 'type' => 'string', + ), + 'operator' => array( + 'description' => __( 'The operator to use, one of `is`, `isNot`, `isAny`, `isNone`, `isAll`, `isNotAll`, `lessThan`, `greaterThan`, `lessThanOrEqual`, `greaterThanOrEqual`, `before`, or `after`.' ), + 'type' => 'string', + 'enum' => array( + 'is', + 'isNot', + 'isAny', + 'isNone', + 'isAll', + 'isNotAll', + 'lessThan', + 'greaterThan', + 'lessThanOrEqual', + 'greaterThanOrEqual', + 'before', + 'after', + ), + ), + 'value' => array( + 'description' => __( 'The value to filter by.' ), + ), + 'isLocked' => array( + 'description' => __( 'Whether the filter is locked. A locked filter cannot be changed or removed by the user.' ), + 'type' => 'boolean', + ), + ), + ), + ), + 'sort' => array( + 'description' => __( 'The default sort: the field id and the direction (`asc` or `desc`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to sort by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + ), + ), + 'perPage' => array( + 'description' => __( 'Number of records per page. Also used as the batch size when infinite scroll is enabled.' ), + 'type' => 'integer', + ), + 'fields' => array( + 'description' => __( 'Ids of the fields that are visible, in display order.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + 'titleField' => array( + 'description' => __( 'Id of the field used as the record title.' ), + 'type' => 'string', + ), + 'mediaField' => array( + 'description' => __( 'Id of the field used as the record media (e.g. featured image or preview).' ), + 'type' => 'string', + ), + 'descriptionField' => array( + 'description' => __( 'Id of the field used as the record description.' ), + 'type' => 'string', + ), + 'showTitle' => array( + 'description' => __( 'Whether the title is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showMedia' => array( + 'description' => __( 'Whether the media is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showDescription' => array( + 'description' => __( 'Whether the description is shown. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'showLevels' => array( + 'description' => __( 'Whether to display hierarchical levels for the records (e.g. child pages indented under their parent). Defaults to `false`.' ), + 'type' => 'boolean', + ), + 'groupBy' => array( + 'description' => __( 'The grouping configuration: the field to group by, the direction (`asc` or `desc`), and whether to show the field label in each group header (`showLabel`, defaults to `true`).' ), + 'type' => 'object', + 'properties' => array( + 'field' => array( + 'description' => __( 'The field to group by.' ), + 'type' => 'string', + ), + 'direction' => array( + 'description' => __( 'The direction to sort the groups by, `asc` or `desc`.' ), + 'type' => 'string', + 'enum' => array( + 'asc', + 'desc', + ), + ), + 'showLabel' => array( + 'description' => __( 'Whether to show the field label in the group header.' ), + 'type' => 'boolean', + 'default' => true, + ), + ), + ), + 'infiniteScrollEnabled' => array( + 'description' => __( 'Whether infinite scroll is enabled instead of pagination.' ), + 'type' => 'boolean', + ), + ), + ), + ), + ), + ), + 'form' => array( + 'description' => __( 'The DataForm configuration for the Quick Edit form: which fields are displayed, in which order, and how each one is laid out.' ), + 'type' => 'object', + 'readonly' => true, + 'properties' => array( + 'layout' => array( + 'description' => __( 'The layout used to render the form fields, discriminated by its `type`. See the form layout types.' ), + 'oneOf' => array( + array( + 'description' => __( 'The default layout: the field controls are rendered directly in the form, one after another.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'regular', + ), + ), + 'labelPosition' => array( + 'description' => __( 'Position of the field label: `top`, `side`, or `none`.' ), + 'type' => 'string', + 'enum' => array( + 'top', + 'side', + 'none', + ), + ), + ), + ), + array( + 'description' => __( 'The field is rendered as a button that opens a dropdown or modal with the field controls.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'panel', + ), + ), + 'labelPosition' => array( + 'description' => __( 'Position of the field label: `top`, `side`, or `none`.' ), + 'type' => 'string', + 'enum' => array( + 'top', + 'side', + 'none', + ), + ), + 'openAs' => array( + 'description' => __( 'How the panel opens: as a `dropdown` or as a `modal`. The object form allows customizing the labels of the modal buttons.' ), + 'oneOf' => array( + array( + 'description' => __( 'The type of container to open, `dropdown` or `modal`.' ), + 'type' => 'string', + 'enum' => array( + 'dropdown', + 'modal', + ), + ), + array( + 'description' => __( 'The type of container to open, with custom labels for the modal buttons.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The type of container to open, `dropdown` or `modal`.' ), + 'type' => 'string', + 'enum' => array( + 'dropdown', + 'modal', + ), + ), + 'applyLabel' => array( + 'description' => __( 'Label of the modal button that applies the changes.' ), + 'type' => 'string', + ), + 'cancelLabel' => array( + 'description' => __( 'Label of the modal button that discards the changes.' ), + 'type' => 'string', + ), + ), + ), + ), + ), + 'summary' => array( + 'description' => __( 'Id(s) of the field(s) whose values are rendered in the panel button.' ), + 'oneOf' => array( + array( + 'description' => __( 'A single field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A list of field ids.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + 'editVisibility' => array( + 'description' => __( 'When the edit button is visible: `always` or `on-hover`.' ), + 'type' => 'string', + 'enum' => array( + 'always', + 'on-hover', + ), + ), + ), + ), + array( + 'description' => __( 'The fields are grouped in a card container.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'card', + ), + ), + 'withHeader' => array( + 'description' => __( 'Whether the card renders a header. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'isOpened' => array( + 'description' => __( 'Whether the card content is opened. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'isCollapsible' => array( + 'description' => __( 'Whether the card can be collapsed by the user.' ), + 'type' => 'boolean', + ), + 'summary' => array( + 'description' => __( 'Id(s) of the field(s) whose values are rendered in the card header. An entry declared as an object controls when it is visible: `always` or `when-collapsed`.' ), + 'oneOf' => array( + array( + 'description' => __( 'A single field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A list of field ids, optionally with their visibility.' ), + 'type' => 'array', + 'items' => array( + 'oneOf' => array( + array( + 'description' => __( 'A field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A field id with its visibility.' ), + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Id of the field.' ), + 'type' => 'string', + ), + 'visibility' => array( + 'description' => __( 'When the field value is visible in the card header: `always` or `when-collapsed`.' ), + 'type' => 'string', + 'enum' => array( + 'always', + 'when-collapsed', + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + array( + 'description' => __( 'The fields are rendered horizontally in a single row.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'row', + ), + ), + 'alignment' => array( + 'description' => __( 'Vertical alignment of the fields in the row: `start`, `center`, or `end`.' ), + 'type' => 'string', + 'enum' => array( + 'start', + 'center', + 'end', + ), + ), + 'styles' => array( + 'description' => __( 'The styles for the fields in the row, keyed by field id. Each style accepts a `flex` value controlling how the field grows or shrinks.' ), + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'flex' => array( + 'description' => __( 'The CSS `flex` value for the field.' ), + 'type' => array( + 'string', + 'number', + ), + ), + ), + ), + ), + ), + ), + array( + 'description' => __( 'The fields are rendered inside a collapsible disclosure (details) element.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'details', + ), + ), + 'summary' => array( + 'description' => __( 'Label displayed as the summary of the disclosure element.' ), + 'type' => 'string', + ), + ), + ), + ), + ), + 'fields' => array( + 'description' => __( 'The fields of the form, in display order. Each entry is a field id, or an object for further configuration.' ), + 'type' => 'array', + 'items' => array( + 'description' => __( 'A form field: a field id, or an object for further configuration.' ), + 'oneOf' => array( + array( + 'description' => __( 'A field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A form field with additional configuration.' ), + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Id of the field. Used as the member identity when merging patches.' ), + 'type' => 'string', + ), + 'label' => array( + 'description' => __( 'Label displayed for the field, overriding the field\'s own.' ), + 'type' => 'string', + ), + 'description' => array( + 'description' => __( 'Description displayed for the field.' ), + 'type' => 'string', + ), + 'layout' => array( + 'description' => __( 'The layout used to render the form fields, discriminated by its `type`. See the form layout types.' ), + 'oneOf' => array( + array( + 'description' => __( 'The default layout: the field controls are rendered directly in the form, one after another.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'regular', + ), + ), + 'labelPosition' => array( + 'description' => __( 'Position of the field label: `top`, `side`, or `none`.' ), + 'type' => 'string', + 'enum' => array( + 'top', + 'side', + 'none', + ), + ), + ), + ), + array( + 'description' => __( 'The field is rendered as a button that opens a dropdown or modal with the field controls.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'panel', + ), + ), + 'labelPosition' => array( + 'description' => __( 'Position of the field label: `top`, `side`, or `none`.' ), + 'type' => 'string', + 'enum' => array( + 'top', + 'side', + 'none', + ), + ), + 'openAs' => array( + 'description' => __( 'How the panel opens: as a `dropdown` or as a `modal`. The object form allows customizing the labels of the modal buttons.' ), + 'oneOf' => array( + array( + 'description' => __( 'The type of container to open, `dropdown` or `modal`.' ), + 'type' => 'string', + 'enum' => array( + 'dropdown', + 'modal', + ), + ), + array( + 'description' => __( 'The type of container to open, with custom labels for the modal buttons.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The type of container to open, `dropdown` or `modal`.' ), + 'type' => 'string', + 'enum' => array( + 'dropdown', + 'modal', + ), + ), + 'applyLabel' => array( + 'description' => __( 'Label of the modal button that applies the changes.' ), + 'type' => 'string', + ), + 'cancelLabel' => array( + 'description' => __( 'Label of the modal button that discards the changes.' ), + 'type' => 'string', + ), + ), + ), + ), + ), + 'summary' => array( + 'description' => __( 'Id(s) of the field(s) whose values are rendered in the panel button.' ), + 'oneOf' => array( + array( + 'description' => __( 'A single field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A list of field ids.' ), + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + ), + 'editVisibility' => array( + 'description' => __( 'When the edit button is visible: `always` or `on-hover`.' ), + 'type' => 'string', + 'enum' => array( + 'always', + 'on-hover', + ), + ), + ), + ), + array( + 'description' => __( 'The fields are grouped in a card container.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'card', + ), + ), + 'withHeader' => array( + 'description' => __( 'Whether the card renders a header. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'isOpened' => array( + 'description' => __( 'Whether the card content is opened. Defaults to `true`.' ), + 'type' => 'boolean', + ), + 'isCollapsible' => array( + 'description' => __( 'Whether the card can be collapsed by the user.' ), + 'type' => 'boolean', + ), + 'summary' => array( + 'description' => __( 'Id(s) of the field(s) whose values are rendered in the card header. An entry declared as an object controls when it is visible: `always` or `when-collapsed`.' ), + 'oneOf' => array( + array( + 'description' => __( 'A single field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A list of field ids, optionally with their visibility.' ), + 'type' => 'array', + 'items' => array( + 'oneOf' => array( + array( + 'description' => __( 'A field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A field id with its visibility.' ), + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'description' => __( 'Id of the field.' ), + 'type' => 'string', + ), + 'visibility' => array( + 'description' => __( 'When the field value is visible in the card header: `always` or `when-collapsed`.' ), + 'type' => 'string', + 'enum' => array( + 'always', + 'when-collapsed', + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + array( + 'description' => __( 'The fields are rendered horizontally in a single row.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'row', + ), + ), + 'alignment' => array( + 'description' => __( 'Vertical alignment of the fields in the row: `start`, `center`, or `end`.' ), + 'type' => 'string', + 'enum' => array( + 'start', + 'center', + 'end', + ), + ), + 'styles' => array( + 'description' => __( 'The styles for the fields in the row, keyed by field id. Each style accepts a `flex` value controlling how the field grows or shrinks.' ), + 'type' => 'object', + 'additionalProperties' => array( + 'type' => 'object', + 'properties' => array( + 'flex' => array( + 'description' => __( 'The CSS `flex` value for the field.' ), + 'type' => array( + 'string', + 'number', + ), + ), + ), + ), + ), + ), + ), + array( + 'description' => __( 'The fields are rendered inside a collapsible disclosure (details) element.' ), + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'description' => __( 'The layout type.' ), + 'type' => 'string', + 'enum' => array( + 'details', + ), + ), + 'summary' => array( + 'description' => __( 'Label displayed as the summary of the disclosure element.' ), + 'type' => 'string', + ), + ), + ), + ), + ), + 'children' => array( + 'description' => __( 'Fields combined under this entry, following the same shape as `fields`.' ), + 'type' => 'array', + 'items' => array( + 'oneOf' => array( + array( + 'description' => __( 'A field id.' ), + 'type' => 'string', + ), + array( + 'description' => __( 'A nested form field, following the same shape as an object entry of `fields`.' ), + 'type' => 'object', + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), +); diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index 34fcd55e2466d..929e7d9d167dd 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -410,4 +410,26 @@ public function test_get_item_schema_excludes_url_managed_view_properties() { $this->assertArrayNotHasKey( 'page', $properties, "$label should not declare a `page` property." ); } } + + /** + * The endpoint schema is derived from the canonical JSON Schema at + * `tools/rest-api/view-config.json` through the + * generated `wp-includes/rest-api/view-config-schema.php` file, so the two + * can no longer drift structurally. Here we only assert that the + * descriptions attached in PHP land on the schema. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema_attaches_translated_descriptions() { + $controller = new WP_REST_View_Config_Controller(); + $schema = $controller->get_item_schema(); + + foreach ( $schema['properties'] as $property => $property_schema ) { + $this->assertArrayHasKey( + 'description', + $property_schema, + "Top-level property `$property` should carry a translatable description." + ); + } + } } diff --git a/tools/rest-api/gen-view-config-schema-php.mjs b/tools/rest-api/gen-view-config-schema-php.mjs new file mode 100644 index 0000000000000..6bb33d935d1ae --- /dev/null +++ b/tools/rest-api/gen-view-config-schema-php.mjs @@ -0,0 +1,185 @@ +/** + * Generates the PHP copy of the view-config REST schema from the canonical + * JSON Schema. + * + * Reads from : tools/rest-api/view-config.json + * Publishes to: src/wp-includes/rest-api/view-config-schema.php + * + * The generated file returns the schema as a PHP array with all local `$ref` + * pointers dereferenced, the `definitions` map dropped, and `description` + * annotations wrapped in `__()` calls so they are picked up by the + * translation pipeline. + * + * Usage: + * node tools/rest-api/gen-view-config-schema-php.mjs # (re)generate + * node tools/rest-api/gen-view-config-schema-php.mjs --check # fail if stale + * + * This script is dependency-free on purpose so the `--check` mode can run + * in any environment. + */ + +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +/** + * Path to the canonical view-config JSON Schema. + * + * @type {string} + */ +const VIEW_CONFIG_SCHEMA_PATH = fileURLToPath( + new URL( './view-config.json', import.meta.url ) +); + +/** + * Path to the generated PHP schema file. + * + * @type {string} + */ +const PHP_SCHEMA_PATH = fileURLToPath( + new URL( + '../../src/wp-includes/rest-api/view-config-schema.php', + import.meta.url + ) +); + +/** + * Resolves local `$ref` pointers (e.g. `#/definitions/foo` or + * `#/definitions/foo/properties/bar`) against the schema root. + * + * @param {*} node Schema node to resolve. + * @param {Object} root Schema root the pointers are resolved against. + * @return {*} The resolved node. + */ +function resolveRefs( node, root ) { + if ( Array.isArray( node ) ) { + return node.map( ( item ) => resolveRefs( item, root ) ); + } + if ( ! node || typeof node !== 'object' ) { + return node; + } + if ( typeof node.$ref === 'string' && node.$ref.startsWith( '#/' ) ) { + let target = root; + for ( const rawSegment of node.$ref.slice( 2 ).split( '/' ) ) { + const segment = rawSegment + .replaceAll( '~1', '/' ) + .replaceAll( '~0', '~' ); + if ( ! target || ! ( segment in target ) ) { + throw new Error( `Unresolvable $ref \`${ node.$ref }\`.` ); + } + target = target[ segment ]; + } + return resolveRefs( target, root ); + } + return Object.fromEntries( + Object.entries( node ).map( ( [ key, value ] ) => [ + key, + resolveRefs( value, root ), + ] ) + ); +} + +/** + * Serializes a value as a PHP literal. + * + * `description` annotations are emitted as translatable strings, i.e. + * `__( '…' )`. Only string values qualify: a schema *property* named + * `description` (e.g. a form field description) maps to an object, so it is + * serialized as a regular array. + * + * @param {*} value Value to serialize. + * @param {string} indent Current indentation. + * @return {string} PHP literal. + */ +function toPhp( value, indent = '' ) { + if ( value === null ) { + return 'null'; + } + if ( typeof value === 'boolean' || typeof value === 'number' ) { + return JSON.stringify( value ); + } + if ( typeof value === 'string' ) { + return `'${ value + .replaceAll( '\\', '\\\\' ) + .replaceAll( "'", "\\'" ) }'`; + } + const inner = indent + '\t'; + if ( Array.isArray( value ) ) { + if ( value.length === 0 ) { + return 'array()'; + } + const items = value + .map( ( item ) => `${ inner }${ toPhp( item, inner ) },\n` ) + .join( '' ); + return `array(\n${ items }${ indent })`; + } + const entries = Object.entries( value ); + if ( entries.length === 0 ) { + return 'array()'; + } + const items = entries + .map( ( [ key, item ] ) => { + const serialized = + key === 'description' && typeof item === 'string' + ? `__( ${ toPhp( item ) } )` + : toPhp( item, inner ); + return `${ inner }${ toPhp( key ) } => ${ serialized },\n`; + } ) + .join( '' ); + return `array(\n${ items }${ indent })`; +} + +/** + * Generates the content of the PHP schema file. + * + * @return {string} PHP source. + */ +export function generate() { + const schema = JSON.parse( + fs.readFileSync( VIEW_CONFIG_SCHEMA_PATH, 'utf8' ) + ); + const dereferenced = resolveRefs( schema, schema ); + delete dereferenced.definitions; + + return `