[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/abilities/ -> class-wp-abilities-content.php (source)

   1  <?php
   2  /**
   3   * Abilities API: WP_Abilities_Content class.
   4   *
   5   * @package WordPress
   6   * @subpackage Abilities API
   7   * @since 7.2.0
   8   */
   9  
  10  declare( strict_types = 1 );
  11  
  12  /**
  13   * Core class used to register content-related abilities.
  14   *
  15   * Registers the read-only `core/content-query` ability, which queries the readable posts of a
  16   * post type exposed to abilities via `show_in_abilities`, filtered by any of post IDs, slugs,
  17   * status, author, and parent. Raw fields are only returned for posts the current user can
  18   * edit, and the author's name and slug only for authors the current user may see.
  19   *
  20   * Unlike the core abilities registered directly in wp_register_core_abilities() as
  21   * self-contained closures, the content ability lives in a dedicated class because
  22   * its callbacks and schemas share helpers: the permission and execute callbacks read the
  23   * post type and the filters through the same code, and the input schema, output schema,
  24   * and field projection are built from the same field definitions. Future write-oriented
  25   * content abilities can reuse them as well.
  26   *
  27   * Only register() is public. The ability callbacks are closures that call private
  28   * methods, so callers go through the Abilities API, such as
  29   * `wp_get_ability( 'core/content-query' )->execute()`, which validates the input and
  30   * checks permissions before running them.
  31   *
  32   * @since 7.2.0
  33   *
  34   * @access private
  35   */
  36  final class WP_Abilities_Content {
  37  
  38      /**
  39       * The ability category used for content abilities.
  40       *
  41       * @since 7.2.0
  42       * @var string
  43       */
  44      private const CATEGORY = 'content';
  45  
  46      /**
  47       * Default number of posts returned per page, unless `where` lists posts by ID or slug.
  48       *
  49       * @since 7.2.0
  50       * @var int
  51       */
  52      private const DEFAULT_PER_PAGE = 10;
  53  
  54      /**
  55       * Maximum number of posts returned per page.
  56       *
  57       * The `ids` and `slugs` lists in `where` each hold at most this many values, so the posts
  58       * that `ids` lists fit on one page.
  59       *
  60       * @since 7.2.0
  61       * @var int
  62       */
  63      private const MAX_PER_PAGE = 100;
  64  
  65      /**
  66       * Field paths whose output may read post meta or terms.
  67       *
  68       * Requests that include any of these, directly or through their whole object, prime
  69       * the post meta and term caches for the page. Rendered excerpts and content may read
  70       * either, and permalinks read terms when the permalink structure contains `%category%`.
  71       * Other fields, such as the rendered title, do not need that cache priming.
  72       *
  73       * @since 7.2.0
  74       * @var list<string>
  75       */
  76      private const CACHE_PRIMING_FIELDS = array( 'link', 'excerpt.rendered', 'content.rendered' );
  77  
  78      /**
  79       * Default field paths returned when the caller does not request a field subset.
  80       *
  81       * @since 7.2.0
  82       * @var list<string>
  83       */
  84      private const DEFAULT_FIELDS = array( 'id', 'type', 'status', 'date', 'slug', 'title.rendered' );
  85  
  86      /**
  87       * Globals that rendering a post changes: the global post and the globals that
  88       * setup_postdata() populates.
  89       *
  90       * @since 7.2.0
  91       * @var list<string>
  92       */
  93      private const LOOP_GLOBALS = array( 'post', 'id', 'authordata', 'currentday', 'currentmonth', 'page', 'pages', 'multipage', 'more', 'numpages' );
  94  
  95      /**
  96       * Whether each author seen in the current query has posts the current user may read.
  97       *
  98       * @since 7.2.0
  99       * @var array<int, bool>
 100       */
 101      private $visible_authors = array();
 102  
 103      /**
 104       * Registers all content abilities.
 105       *
 106       * Must run on the `wp_abilities_api_init` hook.
 107       *
 108       * @since 7.2.0
 109       */
 110  	public function register(): void {
 111          $this->register_content_query();
 112  
 113          /*
 114           * Future write-oriented content abilities can be registered here, reusing the
 115           * shared helpers below (get_exposed_post_type(), format_post(), check_permission()).
 116           */
 117      }
 118  
 119      /**
 120       * Registers the read-only `core/content-query` ability.
 121       *
 122       * @since 7.2.0
 123       */
 124  	private function register_content_query(): void {
 125          /*
 126           * Post types must be registered with `show_in_abilities` before the ability is
 127           * registered so they are included in its input schema.
 128           */
 129          $post_types = array_values( get_post_types( array( 'show_in_abilities' => true ) ) );
 130          if ( empty( $post_types ) ) {
 131              return;
 132          }
 133  
 134          /*
 135           * Internal statuses (e.g. `inherit`) are excluded here and from the statuses a query
 136           * covers by default, so the posts of post types that rely on them (attachments)
 137           * cannot be queried, not even by ID. Revisit if such a post type is ever exposed via
 138           * `show_in_abilities`.
 139           */
 140          $statuses = array_values( get_post_stati( array( 'internal' => false ) ) );
 141  
 142          wp_register_ability(
 143              'core/content-query',
 144              array(
 145                  'label'               => __( 'Query Content' ),
 146                  'description'         => __( 'Retrieves a paginated list of the readable posts of a post type exposed to abilities, filtered by any of post IDs, slugs, status, author, and parent. Requires an authenticated user. Filters are exact-match only; the ability does not perform full-text search.' ),
 147                  'category'            => self::CATEGORY,
 148                  'input_schema'        => $this->get_content_query_input_schema( $post_types, $statuses ),
 149                  'output_schema'       => $this->get_content_query_output_schema(),
 150                  'execute_callback'    => function ( $input = array() ) {
 151                      return $this->execute_content_query( $input );
 152                  },
 153                  'permission_callback' => function ( $input = array() ): bool {
 154                      return $this->check_permission( $input );
 155                  },
 156                  'meta'                => array(
 157                      'annotations' => array(
 158                          'readonly'    => true,
 159                          'destructive' => false,
 160                          'idempotent'  => true,
 161                      ),
 162                      'public'      => true,
 163                  ),
 164              )
 165          );
 166      }
 167  
 168      /**
 169       * Permission callback for the `core/content-query` ability.
 170       *
 171       * The request is only gated as a whole here: it needs a logged-in user, a post type
 172       * exposed to abilities, and the capabilities to query the explicitly requested statuses.
 173       * It reads no post. {@see self::execute_content_query()} enforces row-level read
 174       * permissions, since individual rows are unknown until the query runs.
 175       *
 176       * @since 7.2.0
 177       *
 178       * @param mixed $input Optional. The ability input. Default empty array.
 179       * @return bool True if the request may proceed, false otherwise.
 180       */
 181  	private function check_permission( $input = array() ): bool {
 182          $input = rest_sanitize_object( $input );
 183  
 184          if ( ! is_user_logged_in() ) {
 185              return false;
 186          }
 187  
 188          /*
 189           * A `where` that is not an object is let through, so that the execute callback can
 190           * report it as an invalid filter.
 191           */
 192          $where = $this->get_input_object( $input, 'where' );
 193          if ( null === $where ) {
 194              return true;
 195          }
 196  
 197          // A query requires an exposed post type.
 198          $post_type_object = $this->get_exposed_post_type( $where['type'] ?? null );
 199          if ( ! $post_type_object ) {
 200              return false;
 201          }
 202  
 203          return $this->can_query_statuses( $where, $post_type_object );
 204      }
 205  
 206      /**
 207       * Parses a raw input value into an integer of at least a minimum, or null when invalid.
 208       *
 209       * Accepts the whole numbers that the integer schema accepts: native integers, floats such
 210       * as the 2.0 that JSON encoders produce, and integer strings such as "2", "2.0", or "+2".
 211       * Only the REST run controller converts input to the schema types, so other callers, such
 212       * as the MCP adapter or a direct WP_Ability::execute() call, pass them on unconverted.
 213       * Anything else, such as a fraction, is rejected rather than coerced: a malformed ID would
 214       * otherwise resolve to another post, and a malformed `parent` filter would be read as 0,
 215       * which asks for top-level posts.
 216       *
 217       * @since 7.2.0
 218       *
 219       * @param mixed $value The raw input value.
 220       * @param int   $min   The smallest acceptable value.
 221       * @return int|null The parsed integer, or null when the value is not an integer >= $min.
 222       */
 223  	private function parse_filter_int( $value, int $min ): ?int {
 224          if ( is_int( $value ) ) {
 225              return $value >= $min ? $value : null;
 226          }
 227  
 228          if ( is_string( $value ) && '' !== $value && ctype_digit( $value ) ) {
 229              $int = (int) $value;
 230  
 231              return $int >= $min ? $int : null;
 232          }
 233  
 234          if ( ! rest_is_integer( $value ) ) {
 235              return null;
 236          }
 237  
 238          /*
 239           * Other whole numbers are read as floats, which hold every integer only up to 2 ** 53.
 240           * Past that, the value may not be the one that was sent, and casting a float beyond the
 241           * integer range wraps it to an arbitrary integer, such as the ID of another post.
 242           */
 243          $number = (float) $value;
 244  
 245          return $number >= $min && $number <= 2 ** 53 ? (int) $number : null;
 246      }
 247  
 248      /**
 249       * Parses a raw list input into a list of strings.
 250       *
 251       * Like schema validation, it also accepts a scalar or a comma-separated string, and
 252       * parses it the same way, with wp_parse_list(). A number, such as a slug sent as 2024,
 253       * is read as its string form.
 254       *
 255       * @since 7.2.0
 256       *
 257       * @param array<mixed> $input The ability input, or one of its objects, such as `where`.
 258       * @param string       $key   The input key holding the list.
 259       * @return list<string> The parsed string values; empty when absent or unparseable.
 260       */
 261  	private function parse_list_input( array $input, string $key ): array {
 262          $value = $input[ $key ] ?? null;
 263          if ( is_scalar( $value ) ) {
 264              $value = (string) $value;
 265          } elseif ( ! is_array( $value ) ) {
 266              return array();
 267          }
 268  
 269          return array_values( array_filter( wp_parse_list( $value ), 'is_string' ) );
 270      }
 271  
 272      /**
 273       * Checks whether the current user may query the explicitly requested statuses.
 274       *
 275       * The statuses a query covers by default, see {@see self::normalize_statuses()}, only
 276       * hold statuses the current user may query, so they need no check.
 277       *
 278       * @since 7.2.0
 279       *
 280       * @param array<mixed> $where            The `where` filters.
 281       * @param WP_Post_Type $post_type_object The post type object.
 282       * @return bool True if the requested statuses may be queried.
 283       */
 284  	private function can_query_statuses( array $where, WP_Post_Type $post_type_object ): bool {
 285          return array_all(
 286              $this->get_requested_statuses( $where ),
 287              fn( $status ) => $this->can_query_status( $status, $post_type_object )
 288          );
 289      }
 290  
 291      /**
 292       * Checks whether the current user may query posts in a status.
 293       *
 294       * Like the REST posts controller, any user may query `publish`, while other statuses, such
 295       * as `draft`, require edit access. Any user may also query the other publicly viewable
 296       * statuses, and `private`, since the query only covers the private posts the user can
 297       * read, see {@see self::execute_content_query()}.
 298       *
 299       * @since 7.2.0
 300       *
 301       * @param string       $status           The post status slug.
 302       * @param WP_Post_Type $post_type_object The post type object.
 303       * @return bool True if the status may be queried.
 304       */
 305  	private function can_query_status( string $status, WP_Post_Type $post_type_object ): bool {
 306          return in_array( $status, array( 'publish', 'private' ), true ) || is_post_status_viewable( $status ) || current_user_can( $post_type_object->cap->edit_posts );
 307      }
 308  
 309      /**
 310       * Checks if a post can be read by the current user.
 311       *
 312       * Mirrors the REST posts controller's read permission, while keeping this ability
 313       * authenticated-only via {@see self::check_permission()}.
 314       *
 315       * @since 7.2.0
 316       *
 317       * @param WP_Post          $post             Post object.
 318       * @param array<int, true> $checked_post_ids Post IDs already checked while walking inherited parents.
 319       * @return bool Whether the post can be read.
 320       */
 321  	private function check_read_permission( WP_Post $post, array $checked_post_ids = array() ): bool {
 322          if ( isset( $checked_post_ids[ $post->ID ] ) ) {
 323              return false;
 324          }
 325  
 326          $checked_post_ids[ $post->ID ] = true;
 327  
 328          if ( ! $this->get_exposed_post_type( $post->post_type ) ) {
 329              return false;
 330          }
 331  
 332          /*
 333           * Treat publicly viewable posts as readable. This checks both the post type
 334           * and post status using Core's viewability helpers, which is stricter than
 335           * checking the status object's `public` flag alone.
 336           */
 337          if ( is_post_publicly_viewable( $post ) ) {
 338              return true;
 339          }
 340  
 341          /*
 342           * Use the normalized status for the status object lookup. For attachments,
 343           * get_post_status() resolves `inherit` through the parent before returning.
 344           */
 345          $post_status = get_post_status( $post );
 346          if ( ! is_string( $post_status ) ) {
 347              return false;
 348          }
 349  
 350          $post_status_object = get_post_status_object( $post_status );
 351          if ( ! $post_status_object instanceof stdClass ) {
 352              return false;
 353          }
 354  
 355          /*
 356           * Core maps `read_post` for public statuses to the post type's plain `read`
 357           * capability. Publicly viewable posts already returned above, so a remaining
 358           * public status is public but not viewable and should require edit access.
 359           */
 360          if ( $post_status_object->public ) {
 361              return current_user_can( 'edit_post', $post->ID );
 362          }
 363  
 364          /*
 365           * For non-public statuses, defer to Core's meta-capability mapping. This
 366           * handles own drafts, private posts, and statuses that require edit access.
 367           */
 368          if ( current_user_can( 'read_post', $post->ID ) ) {
 369              return true;
 370          }
 371  
 372          /*
 373           * Mirror the REST posts controller's inherited-parent behavior, but keep the
 374           * ability fail-closed for missing parents or parent loops. No input can request
 375           * `inherit`, since internal statuses are left out of the input schema and of the
 376           * statuses a query covers by default, but a query filter such as a `pre_get_posts`
 377           * callback can still add it, so such posts keep their parent's read check.
 378           */
 379          if ( 'inherit' === $post->post_status && $post->post_parent > 0 ) {
 380              $parent = get_post( $post->post_parent );
 381              if ( $parent instanceof WP_Post ) {
 382                  return $this->check_read_permission( $parent, $checked_post_ids );
 383              }
 384          }
 385  
 386          return false;
 387      }
 388  
 389      /**
 390       * Executes the `core/content-query` ability.
 391       *
 392       * {@see WP_Ability::execute()} always runs {@see self::check_permission()} first, which
 393       * gates the request as a whole. The query still filters every row by read permission,
 394       * because the gate cannot know the rows before the query runs.
 395       *
 396       * @since 7.2.0
 397       *
 398       * @param mixed $input Optional. The ability input. Default empty array.
 399       * @return array<string, mixed>|WP_Error A page of posts, or a WP_Error on failure.
 400       */
 401  	private function execute_content_query( $input = array() ) {
 402          $input  = rest_sanitize_object( $input );
 403          $fields = $this->normalize_fields( $input );
 404  
 405          // The author visibility memo is per query; see get_visible_author().
 406          $this->visible_authors = array();
 407  
 408          /*
 409           * A `where` that is not an object cannot be honored. Reject it rather than reading it
 410           * as no filter at all.
 411           */
 412          $where = $this->get_input_object( $input, 'where' );
 413          if ( null === $where ) {
 414              return $this->invalid_filter_error( 'where', __( 'The where filter must be an object.' ) );
 415          }
 416  
 417          // Pagination that is not an object falls back to the defaults, like page values that cannot be parsed.
 418          $pagination = $this->get_input_object( $input, 'pagination' ) ?? array();
 419          $page       = $this->parse_filter_int( $pagination['page'] ?? 1, 1 ) ?? 1;
 420  
 421          /*
 422           * Unreachable through gated transports, which run check_permission() first and deny a
 423           * request without a post type exposed to abilities. A direct call to the execute
 424           * callback still fails closed here, but it bypasses the checks of the request as a
 425           * whole that the permission callback performed, such as the status capabilities, so
 426           * only invoke the callback through WP_Ability::execute().
 427           */
 428          $post_type_object = $this->get_exposed_post_type( $where['type'] ?? null );
 429          if ( ! $post_type_object ) {
 430              return new WP_Error(
 431                  'content_not_found',
 432                  __( 'The requested content was not found.' ),
 433                  array( 'status' => 404 )
 434              );
 435          }
 436  
 437          $post_type = $post_type_object->name;
 438  
 439          /*
 440           * REST only registers the equivalent collection filters for post types that
 441           * support them; a shared input schema cannot express that per post type. On
 442           * transports that skip schema validation a malformed value would otherwise
 443           * coerce to a benign default and silently *widen* the query (`author => 0`
 444           * drops the author filter, an empty `post__in` or `post_name__in` is ignored,
 445           * `post_parent => 0` becomes a top-level query). Reject unsupported filters and
 446           * invalid filter values loudly so a filter that cannot be honored fails closed
 447           * instead.
 448           */
 449          $parent = null;
 450          if ( isset( $where['parent'] ) ) {
 451              if ( ! is_post_type_hierarchical( $post_type ) ) {
 452                  return $this->invalid_filter_error( 'where[parent]', __( 'The parent filter is only supported for hierarchical post types.' ) );
 453              }
 454  
 455              $parent = $this->parse_filter_int( $where['parent'], 0 );
 456              if ( null === $parent ) {
 457                  return $this->invalid_filter_error( 'where[parent]', __( 'The parent filter must be a non-negative integer.' ) );
 458              }
 459          }
 460  
 461          /*
 462           * The author filter is an object with an `id` or a `slug`, the keys the `author`
 463           * field returns. Null, int, or false: no filter, the user ID to query, or a filter
 464           * that names no user, which matches no post (see below).
 465           */
 466          $author_id = null;
 467          if ( array_key_exists( 'author', $where ) ) {
 468              if ( ! post_type_supports( $post_type, 'author' ) ) {
 469                  return $this->invalid_filter_error(
 470                      'where[author]',
 471                      /* translators: %s: Parameter. */
 472                      sprintf( __( 'The %s filter is only supported for post types that support authors.' ), 'author' )
 473                  );
 474              }
 475  
 476              /*
 477               * An object without `id` or `slug`, such as `{}` or an empty string, and one with
 478               * an unknown key, such as a list or a misspelled property, name no user and must not
 479               * fall through to an unrestricted query, like an empty `ids` filter.
 480               */
 481              $author_filter = $this->get_input_object( $where, 'author' );
 482              if ( empty( $author_filter ) || ! empty( array_diff_key( $author_filter, array_flip( array( 'id', 'slug' ) ) ) ) ) {
 483                  return $this->invalid_filter_error(
 484                      'where[author]',
 485                      /* translators: %s: Parameter. */
 486                      sprintf( __( 'The %s filter must be an object with an id or a slug.' ), 'author' )
 487                  );
 488              }
 489  
 490              /*
 491               * An `id` of 0 must not reach the query: WP_Query reads `author => 0` as no author
 492               * filter, which would return every post of the type.
 493               */
 494              if ( array_key_exists( 'id', $author_filter ) ) {
 495                  $author_id = $this->parse_filter_int( $author_filter['id'], 1 );
 496                  if ( null === $author_id ) {
 497                      return $this->invalid_filter_error(
 498                          'where[author][id]',
 499                          /* translators: %s: Parameter. */
 500                          sprintf( __( 'The %s ID must be a positive integer.' ), 'author' )
 501                      );
 502                  }
 503              }
 504  
 505              if ( array_key_exists( 'slug', $author_filter ) ) {
 506                  if ( ! is_string( $author_filter['slug'] ) || '' === $author_filter['slug'] ) {
 507                      return $this->invalid_filter_error(
 508                          'where[author][slug]',
 509                          /* translators: %s: Parameter. */
 510                          sprintf( __( 'The %s slug must be a non-empty string.' ), 'author' )
 511                      );
 512                  }
 513  
 514                  /*
 515                   * The slug is looked up as given, like the `slug` of the users endpoint: the
 516                   * collation of the users table decides whether the case matters. A slug that
 517                   * names no user, or a user the current user may not see, or names another user
 518                   * than `id`, matches no post, as a post must match every given filter. The
 519                   * visibility rule is the one the `author` field follows, so a slug the field
 520                   * withholds cannot be confirmed through the filter.
 521                   */
 522                  $user = get_user_by( 'slug', $author_filter['slug'] );
 523                  $user = $user ? $this->get_visible_author( $user->ID ) : null;
 524                  if ( ! $user || ( null !== $author_id && $user->ID !== $author_id ) ) {
 525                      $author_id = false;
 526                  } else {
 527                      $author_id = $user->ID;
 528                  }
 529              }
 530          }
 531  
 532          /*
 533           * An `ids` or `slugs` filter that was given but kept no valid entry must not fall
 534           * through to an unrestricted query: WP_Query ignores an empty `post__in` or
 535           * `post_name__in`, which would return every post of the type, the opposite of the
 536           * caller's intent.
 537           */
 538          $ids = null;
 539          if ( isset( $where['ids'] ) ) {
 540              $ids = $this->normalize_ids( $where );
 541              if ( empty( $ids ) ) {
 542                  return $this->invalid_filter_error(
 543                      'where[ids]',
 544                      /* translators: %s: Parameter. */
 545                      sprintf( __( 'The %s filter must list one or more valid post IDs.' ), 'ids' )
 546                  );
 547              }
 548          }
 549  
 550          $slugs = null;
 551          if ( isset( $where['slugs'] ) ) {
 552              $slugs = $this->normalize_slugs( $where );
 553              if ( empty( $slugs ) ) {
 554                  return $this->invalid_filter_error(
 555                      'where[slugs]',
 556                      /* translators: %s: Parameter. */
 557                      sprintf( __( 'The %s filter must list one or more valid post slugs.' ), 'slugs' )
 558                  );
 559              }
 560          }
 561  
 562          /*
 563           * A `status` filter that was given but kept no status must not fall through to the
 564           * default statuses, which with `ids` or `slugs` cover every status the current user may
 565           * query, wider than the caller asked for.
 566           */
 567          $statuses = $this->get_requested_statuses( $where );
 568          if ( isset( $where['status'] ) && empty( $statuses ) ) {
 569              return $this->invalid_filter_error(
 570                  'where[status]',
 571                  /* translators: %s: Parameter. */
 572                  sprintf( __( 'The %s filter must list one or more post statuses.' ), 'status' )
 573              );
 574          }
 575  
 576          /*
 577           * The input schema lists the statuses registered when the ability was, so check them
 578           * again: WP_Query ignores a status that is not registered, and drops the status clause
 579           * when none is left, which would cover every status, including internal ones such as
 580           * `trash`.
 581           */
 582          if ( ! empty( array_diff( $statuses, get_post_stati( array( 'internal' => false ) ) ) ) ) {
 583              return $this->invalid_filter_error(
 584                  'where[status]',
 585                  /* translators: %s: Parameter. */
 586                  sprintf( __( 'The %s filter must list valid post statuses.' ), 'status' )
 587              );
 588          }
 589  
 590          $lists_posts = null !== $ids || null !== $slugs;
 591          $per_page    = $this->normalize_per_page( $pagination, $lists_posts );
 592  
 593          $prime_post_caches = $this->should_prime_post_caches( $fields );
 594  
 595          /*
 596           * `orderby` is left unset, which orders by `post_date` descending, matching the
 597           * default of the REST posts controller.
 598           *
 599           * `perm` leaves out the other users' private posts for a user who cannot read them,
 600           * so the totals do not count them. It is not set for a user who can: when a query
 601           * filter adds post types, WP_Query checks `read_private_multiple_post_types`
 602           * instead, which no role has, and would leave the posts out too.
 603           */
 604          $query_args = array(
 605              'post_type'              => $post_type,
 606              'post_status'            => $this->normalize_statuses( $statuses, $post_type_object, $lists_posts ),
 607              'posts_per_page'         => $per_page,
 608              'paged'                  => $page,
 609              'perm'                   => current_user_can( $post_type_object->cap->read_private_posts ) ? '' : 'readable',
 610              'ignore_sticky_posts'    => true,
 611              'update_post_meta_cache' => $prime_post_caches,
 612              'update_post_term_cache' => $prime_post_caches,
 613          );
 614  
 615          if ( null !== $ids ) {
 616              $query_args['post__in'] = $ids;
 617          }
 618  
 619          if ( null !== $slugs ) {
 620              $query_args['post_name__in'] = $slugs;
 621          }
 622  
 623          if ( is_int( $author_id ) ) {
 624              $query_args['author'] = $author_id;
 625          }
 626  
 627          if ( null !== $parent ) {
 628              $query_args['post_parent'] = $parent;
 629          }
 630  
 631          /*
 632           * A slug that names no user or a user the current user may not see, or an ID and a slug
 633           * that name different users, match no post, like IDs and slugs that name no post, rather
 634           * than failing the query. An `id` is not looked up: the query matches the stored author
 635           * ID, so the posts of a deleted user still match it.
 636           */
 637          if ( false === $author_id ) {
 638              return $this->prepare_output( array(), 0, 0 );
 639          }
 640  
 641          $query = new WP_Query( $query_args );
 642          $total = $this->get_query_total( $query, $query_args, $page );
 643  
 644          /*
 645           * Count the pages with the page size the query ran with, as the REST posts controller
 646           * does, since query filters such as `pre_get_posts` callbacks can change it. Without
 647           * paging, the query returns every post on one page.
 648           */
 649          $query_per_page = (int) $query->get( 'posts_per_page' );
 650          if ( $query->get( 'nopaging' ) || $query_per_page < 1 ) {
 651              $total_pages = $total > 0 ? 1 : 0;
 652          } else {
 653              $total_pages = (int) ceil( $total / $query_per_page );
 654          }
 655  
 656          /*
 657           * A page past the last one does not exist, so report it as not found instead of
 658           * returning a bare empty list. A genuinely empty result set still returns zero
 659           * totals and no error.
 660           */
 661          if ( $total > 0 && $page > $total_pages ) {
 662              return $this->invalid_page_number_error();
 663          }
 664  
 665          /*
 666           * Prime the parent and author caches with a single query each instead of one
 667           * lookup per post, as the REST posts controller does. Hierarchical permalinks and
 668           * inherited read permissions read the parent, and format_post() sets up every post
 669           * with setup_postdata(), which reads the author.
 670           */
 671          update_post_parent_caches( $query->posts );
 672          update_post_author_caches( $query->posts );
 673  
 674          $posts = array();
 675          foreach ( $query->posts as $post ) {
 676              /*
 677               * Skip posts of other post types, which query filters can add, such as a
 678               * `pre_get_posts` callback that adds post types to the blog home without an
 679               * is_main_query() check.
 680               */
 681              if ( ! $post instanceof WP_Post || $post_type !== $post->post_type ) {
 682                  continue;
 683              }
 684              if ( ! $this->check_read_permission( $post ) ) {
 685                  continue;
 686              }
 687              $posts[] = $this->format_post( $post, $fields );
 688          }
 689  
 690          /*
 691           * Mirror the REST posts controller: totals come from the underlying WP_Query,
 692           * while the row-level checks above may withhold individual returned rows. The
 693           * totals can therefore count posts the current user cannot read, such as another
 694           * author's draft listed in `ids`. This is deliberate: it only reveals that such a
 695           * post exists, as a REST collection request with `include` and `status` does.
 696           */
 697          return $this->prepare_output( $posts, $total, $total_pages );
 698      }
 699  
 700      /**
 701       * Reads an object member of the input, such as `where` or `pagination`.
 702       *
 703       * Accepts what schema validation reads as an object: an array, a `stdClass`, or an
 704       * empty string. Only the REST run controller converts input to the schema types, so
 705       * callers that bypass it, such as a direct WP_Ability::execute() call, can pass any of them.
 706       *
 707       * @since 7.2.0
 708       *
 709       * @param array<mixed> $input The ability input.
 710       * @param string       $key   The member name.
 711       * @return array<mixed>|null The member as an array, an empty array when it is omitted,
 712       *                           or null when it is not an object.
 713       */
 714  	private function get_input_object( array $input, string $key ): ?array {
 715          if ( ! array_key_exists( $key, $input ) ) {
 716              return array();
 717          }
 718  
 719          return rest_is_object( $input[ $key ] ) ? rest_sanitize_object( $input[ $key ] ) : null;
 720      }
 721  
 722      /**
 723       * Normalizes the requested per-page value to the supported bounds.
 724       *
 725       * An explicit `per_page` always wins. Otherwise a request that lists posts by ID or slug
 726       * pages to the maximum, as `core/users-query` does for its lists, so a caller loading a
 727       * known set of posts receives them in one call rather than silently losing the ones past
 728       * the default page size. The input schema caps each list at {@see self::MAX_PER_PAGE},
 729       * so every post that `ids` lists fits on the first page. A slug can match several posts,
 730       * though, so the matches of `slugs` only continue past the first page when more than
 731       * that many posts match.
 732       *
 733       * @since 7.2.0
 734       *
 735       * @param array<mixed> $pagination  The `pagination` input.
 736       * @param bool         $lists_posts Whether the query lists `ids` or `slugs`.
 737       * @return int The clamped per-page value.
 738       */
 739  	private function normalize_per_page( array $pagination, bool $lists_posts ): int {
 740          $default_per_page = $lists_posts ? self::MAX_PER_PAGE : self::DEFAULT_PER_PAGE;
 741  
 742          return min( self::MAX_PER_PAGE, $this->parse_filter_int( $pagination['per_page'] ?? null, 1 ) ?? $default_per_page );
 743      }
 744  
 745      /**
 746       * Returns the query total, recovering it when WP_Query skipped the count.
 747       *
 748       * WP_Query leaves `found_posts` at 0 when a requested page has no rows. Re-run a
 749       * minimal unpaged query so the caller can distinguish an out-of-range page from
 750       * an empty result set, matching the REST posts controller behavior.
 751       *
 752       * @since 7.2.0
 753       *
 754       * @param WP_Query     $query      The executed query.
 755       * @param array<mixed> $query_args The arguments used for the executed query.
 756       * @param int          $page       The requested page.
 757       * @return int Total matching rows across all pages.
 758       */
 759  	private function get_query_total( WP_Query $query, array $query_args, int $page ): int {
 760          $total = (int) $query->found_posts;
 761  
 762          if ( $total > 0 || $page <= 1 ) {
 763              return $total;
 764          }
 765  
 766          $count_args                           = $query_args;
 767          $count_args['fields']                 = 'ids';
 768          $count_args['posts_per_page']         = 1;
 769          $count_args['update_post_meta_cache'] = false;
 770          $count_args['update_post_term_cache'] = false;
 771          unset( $count_args['paged'] );
 772  
 773          $count_query = new WP_Query( $count_args );
 774  
 775          return (int) $count_query->found_posts;
 776      }
 777  
 778      /**
 779       * Checks whether requested fields benefit from page-level cache priming.
 780       *
 781       * A whole object counts as a request for its sub-fields, so `content` primes the
 782       * caches as `content.rendered` does.
 783       *
 784       * @since 7.2.0
 785       *
 786       * @param list<string> $fields The requested field paths.
 787       * @return bool True when post meta and term caches should be primed.
 788       */
 789  	private function should_prime_post_caches( array $fields ): bool {
 790          return array_any(
 791              self::CACHE_PRIMING_FIELDS,
 792              fn( $field ) => $this->is_field_requested( $field, $fields )
 793          );
 794      }
 795  
 796      /**
 797       * Checks whether a field path is requested.
 798       *
 799       * Matches paths as the REST API's `_fields` parameter does, with rest_is_field_included():
 800       * a field such as `title` is requested when it or one of its paths, such as
 801       * `title.rendered`, is requested, and a path such as `title.rendered` is requested when it
 802       * or its whole field is.
 803       *
 804       * @since 7.2.0
 805       *
 806       * @param string       $path   The field path, such as `title` or `title.rendered`.
 807       * @param list<string> $fields The requested field paths.
 808       * @return bool Whether the field path is requested.
 809       */
 810  	private function is_field_requested( string $path, array $fields ): bool {
 811          return rest_is_field_included( $path, $fields );
 812      }
 813  
 814      /**
 815       * Returns a post type exposed through the Abilities API.
 816       *
 817       * Deliberately resolved on every call rather than cached: post types can be
 818       * unregistered or re-registered with different arguments between the ability
 819       * being registered and the ability being used.
 820       *
 821       * @since 7.2.0
 822       *
 823       * @param mixed $post_type The post type name.
 824       * @return WP_Post_Type|null The post type object, or null when the post type is not exposed.
 825       */
 826  	private function get_exposed_post_type( $post_type ): ?WP_Post_Type {
 827          $post_type_object = is_string( $post_type ) ? get_post_type_object( $post_type ) : null;
 828  
 829          return empty( $post_type_object->show_in_abilities ) ? null : $post_type_object;
 830      }
 831  
 832      /**
 833       * Returns the statuses the `status` filter explicitly requests.
 834       *
 835       * @since 7.2.0
 836       *
 837       * @param array<mixed> $where The `where` filters.
 838       * @return list<string> Sanitized post status slugs; empty when none is requested.
 839       */
 840  	private function get_requested_statuses( array $where ): array {
 841          return array_map( 'sanitize_key', $this->parse_list_input( $where, 'status' ) );
 842      }
 843  
 844      /**
 845       * Normalizes the statuses a query covers.
 846       *
 847       * Explicitly requested statuses are queried as given, once the permission callback has
 848       * allowed them. Otherwise a query covers `publish`, as in the REST posts controller,
 849       * unless it lists `ids` or `slugs`. Such a query names the posts it wants, which may be
 850       * drafts or private posts, so it covers every non-internal status that
 851       * {@see self::can_query_status()} allows instead. The row-level read checks still
 852       * withhold the posts the current user cannot read, such as other users' drafts.
 853       *
 854       * @since 7.2.0
 855       *
 856       * @param list<string> $statuses         The explicitly requested statuses; empty when none is requested.
 857       * @param WP_Post_Type $post_type_object The post type object.
 858       * @param bool         $lists_posts      Whether the query lists `ids` or `slugs`.
 859       * @return list<string> Post status slugs.
 860       */
 861  	private function normalize_statuses( array $statuses, WP_Post_Type $post_type_object, bool $lists_posts ): array {
 862          if ( ! empty( $statuses ) ) {
 863              return $statuses;
 864          }
 865  
 866          if ( ! $lists_posts ) {
 867              return array( 'publish' );
 868          }
 869  
 870          foreach ( get_post_stati( array( 'internal' => false ) ) as $status ) {
 871              if ( $this->can_query_status( $status, $post_type_object ) ) {
 872                  $statuses[] = $status;
 873              }
 874          }
 875  
 876          return $statuses;
 877      }
 878  
 879      /**
 880       * Normalizes the post IDs the `ids` filter lists.
 881       *
 882       * Like schema validation, it also accepts a single ID or a comma-separated string. Each
 883       * ID is read with {@see self::parse_filter_int()}, and one that is not a whole number of
 884       * at least 1, such as a fraction, a negative number, or a float too large to hold its
 885       * integer exactly, is dropped rather than coerced, so it can never select another post.
 886       * A digit string beyond the integer range saturates to PHP_INT_MAX, as REST sanitization
 887       * does, which selects no post either.
 888       *
 889       * @since 7.2.0
 890       *
 891       * @param array<mixed> $where The `where` filters.
 892       * @return list<int> Unique post IDs; empty when none is valid.
 893       */
 894  	private function normalize_ids( array $where ): array {
 895          $ids = $where['ids'] ?? null;
 896          if ( is_string( $ids ) ) {
 897              $ids = wp_parse_list( $ids );
 898          } elseif ( is_int( $ids ) || is_float( $ids ) ) {
 899              $ids = array( $ids );
 900          } elseif ( ! is_array( $ids ) ) {
 901              return array();
 902          }
 903  
 904          $post_ids = array();
 905          foreach ( $ids as $id ) {
 906              $post_id = $this->parse_filter_int( $id, 1 );
 907              if ( null !== $post_id ) {
 908                  $post_ids[] = $post_id;
 909              }
 910          }
 911  
 912          return array_values( array_unique( $post_ids ) );
 913      }
 914  
 915      /**
 916       * Normalizes the post slugs the `slugs` filter lists.
 917       *
 918       * Like schema validation, it also accepts a single slug, even one sent as a number, or a
 919       * comma-separated string. Each slug is sanitized with sanitize_title(), as WordPress
 920       * sanitizes a post slug when it saves the post, so `Hello-World` matches `hello-world`.
 921       * A slug that sanitizes to an empty string, such as `!!!`, is dropped: it names no post,
 922       * and querying it would match the posts that have no slug yet, such as drafts.
 923       *
 924       * @since 7.2.0
 925       *
 926       * @param array<mixed> $where The `where` filters.
 927       * @return list<string> Unique post slugs; empty when none is valid.
 928       */
 929  	private function normalize_slugs( array $where ): array {
 930          $slugs = array();
 931          foreach ( $this->parse_list_input( $where, 'slugs' ) as $slug ) {
 932              $slug = sanitize_title( $slug );
 933              if ( '' !== $slug ) {
 934                  $slugs[] = $slug;
 935              }
 936          }
 937  
 938          return array_values( array_unique( $slugs ) );
 939      }
 940  
 941      /**
 942       * Returns the requested field paths, or a lean default set when none are given.
 943       *
 944       * An empty or absent `fields` value selects a lean set of common read fields.
 945       * Otherwise the requested paths are returned. The input schema has already
 946       * validated them against the supported set before the ability executes, and
 947       * {@see self::build_post_fields()} ignores any other path.
 948       *
 949       * @since 7.2.0
 950       *
 951       * @param array<mixed> $input The ability input.
 952       * @return list<string> List of requested field paths.
 953       */
 954  	private function normalize_fields( array $input ): array {
 955          $fields = $this->parse_list_input( $input, 'fields' );
 956  
 957          return empty( $fields ) ? self::DEFAULT_FIELDS : $fields;
 958      }
 959  
 960      /**
 961       * Returns the post field definitions, keyed by field name in output order.
 962       *
 963       * This is the single source of truth for the ability's post fields: the output
 964       * schema uses the definitions directly, while the input schema `fields` enum lists
 965       * each field, followed by the path of each of its sub-fields, such as `title.rendered`.
 966       * Read-context fields are returned for readable posts; the `raw` sub-fields only for
 967       * posts the current user can edit.
 968       *
 969       * @since 7.2.0
 970       *
 971       * @return array<string, mixed> Post field definitions.
 972       */
 973  	private function get_post_properties(): array {
 974          return array(
 975              'id'       => array(
 976                  'type'        => 'integer',
 977                  'description' => __( 'The post ID.' ),
 978              ),
 979              'type'     => array(
 980                  'type'        => 'string',
 981                  'description' => __( 'The post type.' ),
 982              ),
 983              'status'   => array(
 984                  'type'        => 'string',
 985                  'description' => __( 'The post status.' ),
 986              ),
 987              'date'     => array(
 988                  'type'                 => 'object',
 989                  'additionalProperties' => false,
 990                  'properties'           => array(
 991                      'site' => array(
 992                          'type'        => array( 'string', 'null' ),
 993                          'format'      => 'date-time',
 994                          'description' => __( "The publication date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ),
 995                      ),
 996                      'gmt'  => array(
 997                          'type'        => array( 'string', 'null' ),
 998                          'format'      => 'date-time',
 999                          'description' => __( 'The publication date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ),
1000                      ),
1001                  ),
1002                  'description'          => __( 'The publication date.' ),
1003              ),
1004              'modified' => array(
1005                  'type'                 => 'object',
1006                  'additionalProperties' => false,
1007                  'properties'           => array(
1008                      'site' => array(
1009                          'type'        => array( 'string', 'null' ),
1010                          'format'      => 'date-time',
1011                          'description' => __( "The last modified date, in ISO 8601 format using the site's timezone. Null when the date cannot be resolved." ),
1012                      ),
1013                      'gmt'  => array(
1014                          'type'        => array( 'string', 'null' ),
1015                          'format'      => 'date-time',
1016                          'description' => __( 'The last modified date, in ISO 8601 format as GMT. Null when the date cannot be resolved.' ),
1017                      ),
1018                  ),
1019                  'description'          => __( 'The last modified date.' ),
1020              ),
1021              'slug'     => array(
1022                  'type'        => 'string',
1023                  'description' => __( 'The post slug.' ),
1024              ),
1025              'link'     => array(
1026                  'type'        => 'string',
1027                  'description' => __( 'The permalink URL.' ),
1028              ),
1029              'title'    => array(
1030                  'type'                 => 'object',
1031                  'additionalProperties' => false,
1032                  'properties'           => array(
1033                      'raw'      => array(
1034                          'type'        => 'string',
1035                          'description' => __( 'The raw post title. Present when the current user can edit the post.' ),
1036                      ),
1037                      'rendered' => array(
1038                          'type'        => 'string',
1039                          'description' => __( 'The rendered post title.' ),
1040                      ),
1041                  ),
1042                  'description'          => __( 'The post title. Present when the post type supports titles.' ),
1043              ),
1044              'excerpt'  => array(
1045                  'type'                 => 'object',
1046                  'additionalProperties' => false,
1047                  'properties'           => array(
1048                      'raw'       => array(
1049                          'type'        => 'string',
1050                          'description' => __( 'The raw post excerpt. Present when the current user can edit the post.' ),
1051                      ),
1052                      'rendered'  => array(
1053                          'type'        => 'string',
1054                          'description' => __( 'The rendered post excerpt (HTML). Empty when withheld for a password-protected post.' ),
1055                      ),
1056                      'protected' => array(
1057                          'type'        => 'boolean',
1058                          'description' => __( 'Whether the excerpt is protected with a password.' ),
1059                      ),
1060                  ),
1061                  'description'          => __( 'The post excerpt. Present when the post type supports excerpts.' ),
1062              ),
1063              'content'  => array(
1064                  'type'                 => 'object',
1065                  'additionalProperties' => false,
1066                  'properties'           => array(
1067                      'raw'       => array(
1068                          'type'        => 'string',
1069                          'description' => __( 'The raw, unfiltered post content (block markup). Present when the current user can edit the post.' ),
1070                      ),
1071                      'rendered'  => array(
1072                          'type'        => 'string',
1073                          'description' => __( 'The rendered post content. Empty when withheld for a password-protected post.' ),
1074                      ),
1075                      'protected' => array(
1076                          'type'        => 'boolean',
1077                          'description' => __( 'Whether the content is protected with a password.' ),
1078                      ),
1079                  ),
1080                  'description'          => __( 'The post content. Present when the post type supports the editor.' ),
1081              ),
1082              'author'   => array(
1083                  'type'                 => 'object',
1084                  'additionalProperties' => false,
1085                  'properties'           => array(
1086                      'id'   => array(
1087                          'type'        => 'integer',
1088                          'description' => __( 'The user ID of the author, as the `author.id` filter takes it.' ),
1089                      ),
1090                      'name' => array(
1091                          'type'        => array( 'string', 'null' ),
1092                          'description' => __( "The author's display name, as the REST API users endpoint returns it in `name`. Null when the author no longer exists or the current user may not see them." ),
1093                      ),
1094                      'slug' => array(
1095                          'type'        => array( 'string', 'null' ),
1096                          'description' => __( "The author's user slug (nicename), as the REST API users endpoint returns it in `slug`, and as the `author.slug` filter takes it. Null when the author no longer exists or the current user may not see them." ),
1097                      ),
1098                  ),
1099                  'description'          => __( 'The post author. Present when the post type supports authors.' ),
1100              ),
1101              'parent'   => array(
1102                  'type'        => 'integer',
1103                  'description' => __( 'The parent post ID. Present for hierarchical post types.' ),
1104              ),
1105          );
1106      }
1107  
1108      /**
1109       * Returns the field paths the `fields` input accepts, in output order.
1110       *
1111       * Each post field is followed by the path of each of its sub-fields, such as `title`,
1112       * `title.raw` and `title.rendered`. Requesting a field returns all of its sub-fields
1113       * that apply, while a sub-field path returns only that sub-field, as the REST API's
1114       * `_fields` parameter does.
1115       *
1116       * @since 7.2.0
1117       *
1118       * @return list<string> The field paths.
1119       */
1120  	private function get_field_paths(): array {
1121          $paths = array();
1122          foreach ( $this->get_post_properties() as $name => $schema ) {
1123              $paths[] = $name;
1124  
1125              foreach ( array_keys( $schema['properties'] ?? array() ) as $sub_field ) {
1126                  $paths[] = "{$name}.{$sub_field}";
1127              }
1128          }
1129  
1130          return $paths;
1131      }
1132  
1133      /**
1134       * Builds the schema of the `fields` input, shared by all content abilities.
1135       *
1136       * @since 7.2.0
1137       *
1138       * @return array<string, mixed> The `fields` JSON Schema.
1139       */
1140  	private function get_fields_input_schema(): array {
1141          return array(
1142              'type'        => 'array',
1143              'uniqueItems' => true,
1144              'items'       => array(
1145                  'type' => 'string',
1146                  'enum' => $this->get_field_paths(),
1147              ),
1148              'description' => sprintf(
1149                  /* translators: %s: Comma-separated list of field paths. */
1150                  __( 'Limit each returned post to these fields. A field such as `title` returns all of its sub-fields the current user can see, while a path such as `title.rendered` returns only that sub-field. The `id` is always included. If omitted or empty, the default fields are returned: %s. Requested fields that do not apply to a post are left out of it: raw sub-fields, such as `content.raw`, when the current user cannot edit the post, and fields its post type does not support, such as `parent` for a post type that is not hierarchical. A returned post may therefore hold only its `id`. Password-protected posts differ: when the current user cannot edit one, its `excerpt.rendered` and `content.rendered` are returned as empty strings rather than left out, with `protected` set to true.' ),
1151                  '`' . implode( '`, `', self::DEFAULT_FIELDS ) . '`'
1152              ),
1153          );
1154      }
1155  
1156      /**
1157       * Builds the input schema for the `core/content-query` ability.
1158       *
1159       * The input has three members: `where` selects the posts, `fields` selects the fields
1160       * returned for each post, and `pagination` selects the page of results. `where` holds
1161       * the post type to query in `type`, and any of the filters `ids`, `slugs`, `status`,
1162       * `author`, and `parent`, which all combine. Every object sets
1163       * `additionalProperties: false`, so an unknown key, such as a misspelled filter, fails
1164       * validation instead of being silently ignored.
1165       *
1166       * @since 7.2.0
1167       *
1168       * @param list<string> $post_types Exposed post type names.
1169       * @param list<string> $statuses   Requestable post status slugs.
1170       * @return array<string, mixed> The input JSON Schema.
1171       */
1172  	private function get_content_query_input_schema( array $post_types, array $statuses ): array {
1173          return array(
1174              'type'                 => 'object',
1175              'additionalProperties' => false,
1176              'required'             => array( 'where' ),
1177              'properties'           => array(
1178                  'where'      => array(
1179                      'type'                 => 'object',
1180                      'additionalProperties' => false,
1181                      'required'             => array( 'type' ),
1182                      'properties'           => array(
1183                          'type'   => array(
1184                              'type'        => 'string',
1185                              'enum'        => $post_types,
1186                              'description' => __( 'Post type to query for readable posts.' ),
1187                          ),
1188                          'ids'    => array(
1189                              'type'        => 'array',
1190                              'uniqueItems' => true,
1191                              'minItems'    => 1,
1192                              'maxItems'    => self::MAX_PER_PAGE,
1193                              'items'       => array(
1194                                  'type'    => 'integer',
1195                                  'minimum' => 1,
1196                              ),
1197                              'description' => __( 'Limit the query to these post IDs. The order of the IDs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page.' ),
1198                          ),
1199                          'slugs'  => array(
1200                              'type'        => 'array',
1201                              'uniqueItems' => true,
1202                              'minItems'    => 1,
1203                              'maxItems'    => self::MAX_PER_PAGE,
1204                              'items'       => array(
1205                                  'type'      => 'string',
1206                                  'minLength' => 1,
1207                              ),
1208                              'description' => __( 'Limit the query to posts with these slugs. Each slug is sanitized as a post slug is, so `Hello-World` matches `hello-world`. A slug can match several posts, such as pages under different parents, and every match is returned. The order of the slugs does not affect the order of the results. If `pagination.per_page` is omitted, every matching post is returned on the first page, up to the `per_page` maximum.' ),
1209                          ),
1210                          'status' => array(
1211                              'type'        => 'array',
1212                              'uniqueItems' => true,
1213                              'minItems'    => 1,
1214                              'items'       => array(
1215                                  'type' => 'string',
1216                                  'enum' => $statuses,
1217                              ),
1218                              'description' => __( 'Filter readable posts by one or more post statuses. Defaults to publish, or, when `ids` or `slugs` is given, to every status the current user may query. Statuses that are not publicly viewable, other than private, require edit access.' ),
1219                          ),
1220                          'author' => array(
1221                              'type'                 => 'object',
1222                              'additionalProperties' => false,
1223                              'minProperties'        => 1,
1224                              'properties'           => array(
1225                                  'id'   => array(
1226                                      'type'        => 'integer',
1227                                      'minimum'     => 1,
1228                                      'description' => __( 'The user ID of the author, as `author.id` is returned.' ),
1229                                  ),
1230                                  'slug' => array(
1231                                      'type'        => 'string',
1232                                      'minLength'   => 1,
1233                                      'description' => __( "The author's user slug (nicename), as `author.slug` is returned and as the REST API users endpoint returns it in `slug`." ),
1234                                  ),
1235                              ),
1236                              'description'          => __( 'Filter by the post author, given as the `id` or the `slug` the `author` field of a post returns. An `id` matches the author ID stored on the post, even when that user no longer exists. A `slug` that names no user or a user the current user may not see, as the `author` field reports them, or an `id` and a `slug` that name different users, match no post. Only supported for post types that support authors.' ),
1237                          ),
1238                          'parent' => array(
1239                              'type'        => 'integer',
1240                              'minimum'     => 0,
1241                              'description' => __( 'Filter by parent post ID. Only supported for hierarchical post types. Use 0 for top-level posts.' ),
1242                          ),
1243                      ),
1244                      'description'          => __( 'Selects the posts to return: the readable posts of a `type`, filtered by any of `ids`, `slugs`, `status`, `author`, and `parent`. A post must match every given filter.' ),
1245                  ),
1246                  'fields'     => $this->get_fields_input_schema(),
1247                  'pagination' => array(
1248                      'type'                 => 'object',
1249                      'additionalProperties' => false,
1250                      'properties'           => array(
1251                          'page'     => array(
1252                              'type'        => 'integer',
1253                              'minimum'     => 1,
1254                              'description' => __( 'Page of results to return. Defaults to 1. Requesting a page beyond the last one is an error, unless no post matches the query: then any page returns empty `items` and zero totals. Check `pagination.total_pages` in the result before requesting later pages.' ),
1255                          ),
1256                          'per_page' => array(
1257                              'type'        => 'integer',
1258                              'minimum'     => 1,
1259                              'maximum'     => self::MAX_PER_PAGE,
1260                              'description' => sprintf(
1261                                  /* translators: 1: Default number of posts per page, 2: Maximum number of posts per page. */
1262                                  __( 'Maximum number of posts to return per page. Defaults to %1$d, or to %2$d when `where.ids` or `where.slugs` is given.' ),
1263                                  self::DEFAULT_PER_PAGE,
1264                                  self::MAX_PER_PAGE
1265                              ),
1266                          ),
1267                      ),
1268                      'description'          => __( 'Selects the page of results to return.' ),
1269                  ),
1270              ),
1271          );
1272      }
1273  
1274      /**
1275       * Builds the output schema of a single post, shared by all content abilities.
1276       *
1277       * Only `id` is required, because it is always returned. The other fields are optional
1278       * because the `fields` input lets the caller request any subset, and a field is only
1279       * present when its post type supports it.
1280       *
1281       * @since 7.2.0
1282       *
1283       * @return array<string, mixed> The post JSON Schema.
1284       */
1285  	private function get_content_output_schema(): array {
1286          return array(
1287              'type'                 => 'object',
1288              'additionalProperties' => false,
1289              'required'             => array( 'id' ),
1290              'properties'           => $this->get_post_properties(),
1291          );
1292      }
1293  
1294      /**
1295       * Builds the output schema for the `core/content-query` ability.
1296       *
1297       * Every request returns the same shape: a page of posts in `items`, and the totals
1298       * across all pages in `pagination`.
1299       *
1300       * @since 7.2.0
1301       *
1302       * @return array<string, mixed> The output JSON Schema.
1303       */
1304  	private function get_content_query_output_schema(): array {
1305          return array(
1306              'type'                 => 'object',
1307              'additionalProperties' => false,
1308              'required'             => array( 'items', 'pagination' ),
1309              'properties'           => array(
1310                  'items'      => array(
1311                      'type'        => 'array',
1312                      'description' => __( 'The readable posts on the requested page, ordered by post date, newest first. Can be empty even when `pagination.total` is not zero, because the totals can count matching posts the current user cannot read.' ),
1313                      'items'       => $this->get_content_output_schema(),
1314                  ),
1315                  'pagination' => array(
1316                      'type'                 => 'object',
1317                      'additionalProperties' => false,
1318                      'required'             => array( 'total', 'total_pages' ),
1319                      'properties'           => array(
1320                          'total'       => array(
1321                              'type'        => 'integer',
1322                              'description' => __( 'Total number of posts matching the underlying query, across all pages. May exceed the number of returned posts when row-level permission checks withhold some of them.' ),
1323                          ),
1324                          'total_pages' => array(
1325                              'type'        => 'integer',
1326                              'description' => __( 'Total number of query result pages available for the underlying query. May include pages whose rows are withheld by row-level permission checks.' ),
1327                          ),
1328                      ),
1329                      'description'          => __( 'The totals of the request, across all pages.' ),
1330                  ),
1331              ),
1332          );
1333      }
1334  
1335      /**
1336       * Returns a user when the current user may see them as an author.
1337       *
1338       * The current user may see themselves, the users they can edit, every user when they can
1339       * list users, and otherwise the authors of posts they can read in a publicly viewable post
1340       * type exposed to abilities: this ability returns those posts with their author, so the
1341       * author is already in view. Which post types the REST API exposes plays no part. A user
1342       * the current user may not see is reported like a missing one.
1343       *
1344       * The `author` field and the `author` slug filter share this rule, so the filter never
1345       * confirms a slug the field withholds. The post count is kept per author for the query,
1346       * since count_user_posts() is not cached.
1347       *
1348       * @since 7.2.0
1349       *
1350       * @param int $user_id The user ID.
1351       * @return WP_User|null The user, or null when they no longer exist or may not be seen.
1352       */
1353  	private function get_visible_author( int $user_id ): ?WP_User {
1354          $user = get_userdata( $user_id );
1355          if ( ! $user ) {
1356              return null;
1357          }
1358  
1359          if ( get_current_user_id() === $user_id || current_user_can( 'list_users' ) || current_user_can( 'edit_user', $user_id ) ) {
1360              return $user;
1361          }
1362  
1363          if ( ! isset( $this->visible_authors[ $user_id ] ) ) {
1364              $post_types                        = array_values( array_filter( get_post_types( array( 'show_in_abilities' => true ), 'names' ), 'is_post_type_viewable' ) );
1365              $this->visible_authors[ $user_id ] = (int) count_user_posts( $user_id, $post_types ) > 0;
1366          }
1367  
1368          return $this->visible_authors[ $user_id ] ? $user : null;
1369      }
1370  
1371      /**
1372       * Formats a post into the ability output shape.
1373       *
1374       * As the REST posts controller does, the post is set up as the global post while its
1375       * fields are built, so filters that rely on loop globals, including title filters, see
1376       * the requested post. For an editor of a password-protected post, the cookie-based
1377       * password gate is also suspended, so rendered fields resolve to real values instead of
1378       * protected-post placeholders. The field projection itself is delegated to
1379       * {@see self::build_post_fields()}.
1380       *
1381       * @since 7.2.0
1382       *
1383       * @param WP_Post      $post   The post object.
1384       * @param list<string> $fields The requested field paths.
1385       * @return array<string, mixed> The formatted post data.
1386       */
1387  	private function format_post( WP_Post $post, array $fields ): array {
1388          $can_edit          = current_user_can( 'edit_post', $post->ID );
1389          $password_required = post_password_required( $post );
1390          $unlock_password   = $password_required && $can_edit;
1391          $previous_context  = $this->get_post_context();
1392          $the_post_fired    = did_action( 'the_post' ) > 0;
1393  
1394          /*
1395           * The filter unlocks only posts the current user can edit, mirroring the REST posts
1396           * controller's check_password_required(): an unconditional bypass (e.g. __return_false)
1397           * would also expose other protected posts that the content filter may render, such as
1398           * posts pulled in by a Query Loop block. The closure is kept in a variable, so the same
1399           * instance can be removed again.
1400           */
1401          $allow_password_content = function ( $required, $checked_post ): bool {
1402              return $this->allow_password_content( $required, $checked_post );
1403          };
1404  
1405          /*
1406           * Set up both inside the try block and undo them in the finally block, so a throw from
1407           * a `the_post` callback or mid-render cannot leave the password gate disabled or the
1408           * global post pointing at this post for the rest of the request.
1409           */
1410          try {
1411              $this->set_up_post_context( $post );
1412              if ( $unlock_password ) {
1413                  add_filter( 'post_password_required', $allow_password_content, 10, 2 );
1414              }
1415  
1416              return $this->build_post_fields( $post, $fields, $can_edit, $password_required && ! $can_edit );
1417          } finally {
1418              if ( $unlock_password ) {
1419                  remove_filter( 'post_password_required', $allow_password_content, 10 );
1420              }
1421  
1422              $this->restore_post_context( $previous_context, $the_post_fired );
1423          }
1424      }
1425  
1426      /**
1427       * Builds the requested field projection for a post.
1428       *
1429       * Fields are selected as the REST API's `_fields` parameter selects them, with
1430       * {@see self::is_field_requested()}. The `id` is always included, whether or not it
1431       * was requested, so every returned post can be identified. This also means a post is
1432       * never empty, so it always encodes as a JSON object.
1433       *
1434       * Only the fields that the post type supports and the current user can see are included,
1435       * and a field none of whose requested sub-fields remain is left out, so it never encodes
1436       * as an empty JSON array. Raw sub-fields are edit-context fields, included only when the
1437       * current user can edit the post, whether they were requested on their own or through
1438       * their field. Rendered sub-fields are read-context fields and are withheld for
1439       * password-protected posts unless the current user can edit the post, mirroring the REST
1440       * API behavior.
1441       *
1442       * @since 7.2.0
1443       *
1444       * @param WP_Post      $post         The post object.
1445       * @param list<string> $fields       The requested field paths.
1446       * @param bool         $can_edit     Whether the current user can edit the post.
1447       * @param bool         $is_protected Whether rendered fields must be withheld as password-protected.
1448       * @return array<string, mixed> The formatted post data.
1449       */
1450  	private function build_post_fields( WP_Post $post, array $fields, bool $can_edit, bool $is_protected ): array {
1451          $post_type = $post->post_type;
1452          $data      = array( 'id' => (int) $post->ID );
1453  
1454          if ( $this->is_field_requested( 'type', $fields ) ) {
1455              $data['type'] = $post_type;
1456          }
1457          if ( $this->is_field_requested( 'status', $fields ) ) {
1458              $data['status'] = $post->post_status;
1459          }
1460  
1461          foreach ( array( 'date', 'modified' ) as $field ) {
1462              $dates = array();
1463              if ( $this->is_field_requested( "{$field}.site", $fields ) ) {
1464                  $dates['site'] = $this->format_date( $post, $field, false );
1465              }
1466              if ( $this->is_field_requested( "{$field}.gmt", $fields ) ) {
1467                  $dates['gmt'] = $this->format_date( $post, $field, true );
1468              }
1469              if ( ! empty( $dates ) ) {
1470                  $data[ $field ] = $dates;
1471              }
1472          }
1473  
1474          if ( $this->is_field_requested( 'slug', $fields ) ) {
1475              $data['slug'] = $post->post_name;
1476          }
1477          if ( $this->is_field_requested( 'link', $fields ) ) {
1478              $data['link'] = (string) get_permalink( $post );
1479          }
1480  
1481          /*
1482           * The rendered fields share a shape: `raw` for a user who can edit the post,
1483           * `rendered`, and `protected` for the excerpt and content, whose rendered values are
1484           * withheld for a password-protected post. The title is never withheld, so it has no
1485           * `protected` sub-field. Each field lists the feature the post type must support, the
1486           * post property holding the raw value, the method rendering it, and whether it can be
1487           * protected.
1488           */
1489          $rendered_fields = array(
1490              'title'   => array( 'title', 'post_title', 'get_title', false ),
1491              'excerpt' => array( 'excerpt', 'post_excerpt', 'get_rendered_excerpt', true ),
1492              'content' => array( 'editor', 'post_content', 'get_rendered_content', true ),
1493          );
1494          foreach ( $rendered_fields as $field => $spec ) {
1495              list( $feature, $property, $render, $protectable ) = $spec;
1496              if ( ! $this->is_field_requested( $field, $fields ) || ! post_type_supports( $post_type, $feature ) ) {
1497                  continue;
1498              }
1499  
1500              $value = array();
1501              if ( $can_edit && $this->is_field_requested( "{$field}.raw", $fields ) ) {
1502                  $value['raw'] = $post->$property;
1503              }
1504              if ( $this->is_field_requested( "{$field}.rendered", $fields ) ) {
1505                  $value['rendered'] = ( $protectable && $is_protected ) ? '' : $this->$render( $post );
1506              }
1507              if ( $protectable && $this->is_field_requested( "{$field}.protected", $fields ) ) {
1508                  $value['protected'] = (bool) $post->post_password;
1509              }
1510              if ( ! empty( $value ) ) {
1511                  $data[ $field ] = $value;
1512              }
1513          }
1514  
1515          if ( $this->is_field_requested( 'author', $fields ) && post_type_supports( $post_type, 'author' ) ) {
1516              $user = null;
1517              if ( $this->is_field_requested( 'author.name', $fields ) || $this->is_field_requested( 'author.slug', $fields ) ) {
1518                  $user = $this->get_visible_author( (int) $post->post_author );
1519              }
1520              $author = array();
1521              if ( $this->is_field_requested( 'author.id', $fields ) ) {
1522                  $author['id'] = (int) $post->post_author;
1523              }
1524              if ( $this->is_field_requested( 'author.name', $fields ) ) {
1525                  $author['name'] = $user ? $user->display_name : null;
1526              }
1527              if ( $this->is_field_requested( 'author.slug', $fields ) ) {
1528                  $author['slug'] = $user ? $user->user_nicename : null;
1529              }
1530              if ( ! empty( $author ) ) {
1531                  $data['author'] = $author;
1532              }
1533          }
1534  
1535          if ( $this->is_field_requested( 'parent', $fields ) && is_post_type_hierarchical( $post_type ) ) {
1536              $data['parent'] = (int) $post->post_parent;
1537          }
1538  
1539          return $data;
1540      }
1541  
1542      /**
1543       * Filters {@see post_password_required()} to unlock only posts the current user can edit.
1544       *
1545       * Added by {@see self::format_post()} while formatting a password-protected post the
1546       * current user can edit, so rendered fields resolve to real values without also unlocking
1547       * other protected posts that the content filter may render. Mirrors the REST posts
1548       * controller's check_password_required().
1549       *
1550       * @since 7.2.0
1551       *
1552       * @param bool         $required Whether the post currently requires a password.
1553       * @param WP_Post|null $post     The post being checked, or null if no post is found.
1554       * @return bool Whether the post still requires a password.
1555       */
1556  	private function allow_password_content( $required, $post ): bool {
1557          if ( ! $required || ! $post instanceof WP_Post ) {
1558              return (bool) $required;
1559          }
1560  
1561          return ! current_user_can( 'edit_post', $post->ID );
1562      }
1563  
1564      /**
1565       * Returns the post title with the protected/private prefixes stripped.
1566       *
1567       * @since 7.2.0
1568       *
1569       * @param WP_Post $post The post object.
1570       * @return string The post title.
1571       */
1572  	private function get_title( WP_Post $post ): string {
1573          $strip = static function (): string {
1574              return '%s';
1575          };
1576          add_filter( 'protected_title_format', $strip );
1577          add_filter( 'private_title_format', $strip );
1578  
1579          /*
1580           * The format filters are removed in a finally block so a throw from a title
1581           * filter cannot leave them attached for the rest of the request.
1582           */
1583          try {
1584              $title = get_the_title( $post );
1585  
1586              /*
1587               * A title filter that returns a non-string would fail the return type, so guard
1588               * it as the excerpt and content are.
1589               */
1590              return is_string( $title ) ? $title : '';
1591          } finally {
1592              remove_filter( 'protected_title_format', $strip );
1593              remove_filter( 'private_title_format', $strip );
1594          }
1595      }
1596  
1597      /**
1598       * Returns the post excerpt transformed for display.
1599       *
1600       * Applies the `get_the_excerpt` and `the_excerpt` filter chains, as the REST posts
1601       * controller does. {@see self::format_post()} has set the post up as the global post.
1602       *
1603       * @since 7.2.0
1604       *
1605       * @param WP_Post $post The post object.
1606       * @return string Rendered post excerpt.
1607       */
1608  	private function get_rendered_excerpt( WP_Post $post ): string {
1609          /** This filter is documented in wp-includes/post-template.php */
1610          $excerpt = apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );
1611  
1612          /** This filter is documented in wp-includes/post-template.php */
1613          $excerpt = apply_filters( 'the_excerpt', $excerpt );
1614  
1615          return is_string( $excerpt ) ? $excerpt : '';
1616      }
1617  
1618      /**
1619       * Returns post content transformed for display.
1620       *
1621       * Applies `the_content`, as the REST posts controller does. {@see self::format_post()}
1622       * has set the post up as the global post.
1623       *
1624       * @since 7.2.0
1625       *
1626       * @param WP_Post $post The post object.
1627       * @return string Rendered post content.
1628       */
1629  	private function get_rendered_content( WP_Post $post ): string {
1630          /** This filter is documented in wp-includes/post-template.php */
1631          $content = apply_filters( 'the_content', $post->post_content );
1632  
1633          return is_string( $content ) ? $content : '';
1634      }
1635  
1636      /**
1637       * Returns the global post context, so it can be restored after a post is rendered.
1638       *
1639       * @since 7.2.0
1640       *
1641       * @return array<string, mixed> The loop globals, keyed by name, leaving out those that are not set.
1642       */
1643  	private function get_post_context(): array {
1644          /*
1645           * Copy each global by value. A calling function that binds a global with `global`,
1646           * as load_template() and WP_Block::render() do, makes it a reference, which
1647           * array_intersect_key( $GLOBALS, ... ) would keep. The saved copy would then follow
1648           * the global to the rendered post, and the restore would put that post back.
1649           */
1650          $context = array();
1651          foreach ( self::LOOP_GLOBALS as $name ) {
1652              if ( ! array_key_exists( $name, $GLOBALS ) ) {
1653                  continue;
1654              }
1655  
1656              $context[ $name ] = $GLOBALS[ $name ];
1657          }
1658  
1659          return $context;
1660      }
1661  
1662      /**
1663       * Sets up the global post context for rendering a post.
1664       *
1665       * Sets the global post and calls setup_postdata(), as the REST posts controller does
1666       * before it renders a post, so filters that rely on loop globals render against the
1667       * requested post.
1668       *
1669       * @since 7.2.0
1670       *
1671       * @param WP_Post $post The post to render.
1672       */
1673  	private function set_up_post_context( WP_Post $post ): void {
1674          // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Temporarily mirrors REST post context for rendering.
1675          $GLOBALS['post'] = $post;
1676          setup_postdata( $post );
1677      }
1678  
1679      /**
1680       * Restores the global post context saved by {@see self::get_post_context()}.
1681       *
1682       * Each loop global gets its previous value back, or is unset again when it was not set.
1683       * wp_reset_postdata() alone is not enough: it does nothing when the main query has no
1684       * post, which would leave the rendered post's data in the globals. When a post was set
1685       * up before, setup_postdata() first runs for it again, as wp_reset_postdata() would, so
1686       * callbacks on the `the_post` action can restore their own globals too. The saved values
1687       * are put back even when such a callback throws. A global post that was never set up
1688       * keeps the loop globals that setup_postdata() gives it. The `the_post` count is reset
1689       * only when the ability was the first thing to fire that action and no global post was
1690       * set, so rendering a post before the main query does not count as starting the loop.
1691       *
1692       * @since 7.2.0
1693       *
1694       * @param array<string, mixed> $previous_context The loop globals that get_post_context() returned.
1695       * @param bool                 $the_post_fired   Whether the `the_post` action had fired before the ability set up the post.
1696       */
1697  	private function restore_post_context( array $previous_context, bool $the_post_fired ): void {
1698          $previous_post = $previous_context['post'] ?? null;
1699  
1700          /*
1701           * A global post that was never set up, such as the main post before the loop
1702           * starts, keeps what setup_postdata() gives it below. Do not put back the values
1703           * saved before: `the_post` has fired then, so get_the_content() called without a
1704           * post, as the Post Content block and the_content() outside the loop do, reads the
1705           * loop globals instead of the post, and an unset `$pages` makes it throw a
1706           * TypeError that breaks the page being rendered.
1707           */
1708          $restore_saved_values = ! $previous_post instanceof WP_Post || is_array( $previous_context['pages'] ?? null );
1709  
1710          try {
1711              if ( $previous_post instanceof WP_Post ) {
1712                  // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restores the previous global post context.
1713                  $GLOBALS['post'] = $previous_post;
1714                  setup_postdata( $previous_post );
1715              }
1716          } finally {
1717              if ( $restore_saved_values ) {
1718                  foreach ( self::LOOP_GLOBALS as $name ) {
1719                      if ( array_key_exists( $name, $previous_context ) ) {
1720                          $GLOBALS[ $name ] = $previous_context[ $name ];
1721                      } else {
1722                          unset( $GLOBALS[ $name ] );
1723                      }
1724                  }
1725              }
1726  
1727              /*
1728               * Only reset the `the_post` count when the ability was the first thing to fire it
1729               * and no global post was set. Setting up the rendered post counted the action, and
1730               * once it has fired, get_the_content() called without a post reads the loop globals
1731               * unset above instead of the post. A global post set later without being set up,
1732               * as WP::register_globals() sets the main post after the ability runs on `init`,
1733               * would then make it throw a TypeError. When a loop had already fired the action,
1734               * or a post was set, the count is left alone.
1735               */
1736              if ( ! $previous_post instanceof WP_Post && ! $the_post_fired ) {
1737                  unset( $GLOBALS['wp_actions']['the_post'] );
1738              }
1739          }
1740      }
1741  
1742      /**
1743       * Formats a post date field as an ISO 8601 string, in the site's timezone or in GMT.
1744       *
1745       * In GMT, it reads the stored GMT date, deriving it from the local date when it is
1746       * missing (e.g. drafts), mirroring the REST posts controller.
1747       *
1748       * @since 7.2.0
1749       *
1750       * @param WP_Post $post  The post object.
1751       * @param string  $field Either 'date' or 'modified'.
1752       * @param bool    $gmt   Whether to format the date in GMT instead of the site's timezone.
1753       * @return string|null The ISO 8601 date, or null if unavailable.
1754       */
1755  	private function format_date( WP_Post $post, string $field, bool $gmt ): ?string {
1756          $datetime = $gmt ? get_post_datetime( $post, $field, 'gmt' ) : false;
1757          if ( ! $datetime ) {
1758              $datetime = get_post_datetime( $post, $field );
1759          }
1760  
1761          if ( ! $datetime ) {
1762              return null;
1763          }
1764  
1765          /*
1766           * A malformed stored date can still parse: a zero month formats with a negative
1767           * year. The `date-time` format rejects it, which would fail output validation for
1768           * the whole call, so it is reported as null too.
1769           */
1770          $date = ( $gmt ? $datetime->setTimezone( new DateTimeZone( 'UTC' ) ) : $datetime )->format( 'c' );
1771  
1772          return rest_parse_date( $date ) ? $date : null;
1773      }
1774  
1775      /**
1776       * Builds the ability output for a page of formatted posts.
1777       *
1778       * @since 7.2.0
1779       *
1780       * @param array<int, array<string, mixed>> $items       The formatted posts on the page.
1781       * @param int                              $total       Total number of posts across all pages.
1782       * @param int                              $total_pages Total number of pages.
1783       * @return array<string, mixed> The ability output.
1784       */
1785  	private function prepare_output( array $items, int $total, int $total_pages ): array {
1786          return array(
1787              'items'      => $items,
1788              'pagination' => array(
1789                  'total'       => $total,
1790                  'total_pages' => $total_pages,
1791              ),
1792          );
1793      }
1794  
1795      /**
1796       * Builds the error for a page past the last page of results.
1797       *
1798       * A page that does not exist is reported as not found rather than as an empty list,
1799       * so callers can tell it apart from a request that matches no posts.
1800       *
1801       * @since 7.2.0
1802       *
1803       * @return WP_Error The invalid page number error.
1804       */
1805  	private function invalid_page_number_error(): WP_Error {
1806          return new WP_Error(
1807              'content_invalid_page_number',
1808              __( 'The page number requested is larger than the number of pages available.' ),
1809              array( 'status' => 404 )
1810          );
1811      }
1812  
1813      /**
1814       * Builds the error for a filter that cannot be honored.
1815       *
1816       * As in the REST API's `rest_invalid_param` errors, the error data maps the filter's
1817       * input path to the message under `params`, so callers can tell which filter failed
1818       * without parsing the translated message.
1819       *
1820       * @since 7.2.0
1821       *
1822       * @param string $param   The filter's input path, such as `where[ids]`.
1823       * @param string $message The error message.
1824       * @return WP_Error The invalid filter error.
1825       */
1826  	private function invalid_filter_error( string $param, string $message ): WP_Error {
1827          return new WP_Error(
1828              'content_invalid_filter',
1829              $message,
1830              array(
1831                  'status' => 400,
1832                  'params' => array( $param => $message ),
1833              )
1834          );
1835      }
1836  }


Generated : Fri Nov 6 08:20:37 2026 Cross-referenced by PHPXref