array]` or `['error' => array]`. */ public function run( array $input, string $phase ): array { $target = isset( $input['target'] ) && is_array( $input['target'] ) ? $input['target'] : null; if ( null === $target || ! $this->has_positive_int( $target, 'id' ) ) { return $this->error( 'invalid_target', 'wp.posts.get requires a target with a positive integer id.', array( 'input', 'target' ) ); } $id = (int) $target['id']; $post = get_post( $id ); if ( null === $post ) { return $this->error( 'not_found', 'No post exists for the requested id.', array( 'input', 'target', 'id' ) ); } if ( isset( $target['post_type'] ) ) { $asserted = is_scalar( $target['post_type'] ) ? (string) $target['post_type'] : ''; if ( $asserted !== (string) $post->post_type ) { return $this->error( 'invalid_target', 'Target post_type assertion did not match the stored post.', array( 'input', 'target', 'post_type' ), array( 'expected' => $asserted, 'current' => (string) $post->post_type, ) ); } } if ( ! current_user_can( 'read_post', $id ) ) { return $this->error( 'permission_denied', 'The current user cannot read this post.', array( 'input', 'target', 'id' ), array( 'layer' => 'wp_capability' ) ); } $select = $input['select'] ?? null; if ( null !== $select && ! $this->is_path_list( $select ) ) { return $this->error( 'invalid_command', 'select must be a list of array-segment paths.', array( 'input', 'select' ) ); } $projection = $this->project( $post, $select ); if ( isset( $projection['error'] ) ) { return $projection; } // validate is execute minus the response body; the read has no mutation. if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } return array( 'data' => array( 'target' => array( 'resource' => 'posts', 'id' => $id, 'post_type' => (string) $post->post_type, ), 'document' => $projection['document'], 'facts' => $this->facts( $post ), ), ); } /** * Project the requested select paths over the post document. * * @param object $post WordPress post object. * @param array|null $select List of array-segment paths, or null for default projection. * @return array `['document' => array]` or `['error' => array]`. */ private function project( object $post, ?array $select ): array { $document = $this->document( $post ); if ( null === $select || array() === $select ) { return array( 'document' => $this->default_projection( $document ) ); } $projected = array(); foreach ( $select as $index => $path ) { $segments = array_values( $path ); if ( ! $this->path_exists( $document, $segments ) ) { if ( $this->is_nullable_meta_path( $document, $segments ) ) { $projected = $this->path_set( $projected, $segments, null ); continue; } return $this->error( 'invalid_path', 'Selected path is not part of the posts document model for this post.', array( 'input', 'select', $index ) ); } $value = $this->path_get( $document, $segments ); $projected = $this->path_set( $projected, $segments, $value ); } return array( 'document' => $projected ); } /** * Build the full readable document for one post. * * @param object $post WordPress post object. */ private function document( object $post ): array { return array( 'id' => (int) $post->ID, 'post_type' => (string) $post->post_type, 'status' => (string) $post->post_status, 'slug' => (string) $post->post_name, 'parent' => (int) ( $post->post_parent ?? 0 ), 'menu_order' => (int) ( $post->menu_order ?? 0 ), 'comment_status' => (string) ( $post->comment_status ?? 'open' ), 'ping_status' => (string) ( $post->ping_status ?? 'open' ), 'render_url' => $this->render_url( $post ), 'public_url' => $this->public_url( $post ), 'preview_url' => $this->preview_url( $post ), 'title' => array( 'raw' => (string) $post->post_title, 'rendered' => (string) $post->post_title, ), 'excerpt' => array( 'raw' => (string) $post->post_excerpt ), 'content' => array( 'raw' => (string) $post->post_content ), 'blocks' => $this->blocks_document( (string) $post->post_content ), 'date_gmt' => (string) $post->post_date_gmt, 'modified_gmt' => (string) $post->post_modified_gmt, 'meta' => $this->meta( (int) $post->ID ), 'acf' => PostTypesWriteCapability::acf_values_for_post( (int) $post->ID, (string) $post->post_type ), 'jetengine' => PostTypesWriteCapability::jetengine_values_for_post( (int) $post->ID, (string) $post->post_type ), 'seo_meta' => $this->seo_meta( (int) $post->ID ), 'custom_fields' => $this->custom_fields( (int) $post->ID ), 'template' => $this->page_template( (int) $post->ID ), 'terms' => $this->terms( $post ), 'edit_context' => $this->edit_context( $post ), 'locks' => $this->locks( (int) $post->ID ), 'content_source' => array( 'kind' => 'post', 'id' => (string) $post->ID, ), ); } /** * Parse serialized block content into the public round-trippable tree. * * @param string $content Serialized post content. * @return array> */ private function blocks_document( string $content ): array { if ( ! function_exists( 'parse_blocks' ) ) { return array(); } $blocks = parse_blocks( $content ); if ( ! is_array( $blocks ) ) { return array(); } return $this->project_blocks( $blocks ); } /** * Project parsed Gutenberg blocks without exposing implementation-only interleaving. * * @param array $blocks Parsed block list. * @return array> */ private function project_blocks( array $blocks ): array { $projected = array(); foreach ( $blocks as $block ) { if ( ! is_array( $block ) ) { continue; } $inner_blocks = isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ? $block['innerBlocks'] : array(); $projected[] = array( 'blockName' => isset( $block['blockName'] ) && is_scalar( $block['blockName'] ) ? (string) $block['blockName'] : null, 'attrs' => isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(), 'markup' => $this->serialize_block_node( $block ), 'innerBlocks' => $this->project_blocks( $inner_blocks ), ); } return $projected; } /** * Serialize one parsed block back to canonical block markup. * * @param array $block Parsed block. */ private function serialize_block_node( array $block ): string { if ( function_exists( 'serialize_block' ) ) { return serialize_block( $block ); } if ( function_exists( 'serialize_blocks' ) ) { return serialize_blocks( array( $block ) ); } return ''; } /** * Read the family default projection from the full document. * * @param array $document Full post document. */ private function default_projection( array $document ): array { return array( 'id' => $document['id'], 'post_type' => $document['post_type'], 'status' => $document['status'], 'slug' => $document['slug'], 'title' => $document['title'], 'modified_gmt' => $document['modified_gmt'], ); } /** * Read flattened post meta as a path-addressable namespace. * * @param int $post_id Post id. */ private function meta( int $post_id ): array { $raw = get_post_meta( $post_id ); $meta = array(); if ( ! is_array( $raw ) ) { return $meta; } foreach ( $raw as $key => $values ) { if ( is_array( $values ) ) { $meta[ (string) $key ] = 1 === count( $values ) ? array_values( $values )[0] : array_values( $values ); continue; } $meta[ (string) $key ] = $values; } return $meta; } /** * Read SEO-owned post meta without exposing the entire custom-field bag. * * @param int $post_id Post id. */ private function seo_meta( int $post_id ): array { return array_filter( $this->meta( $post_id ), fn ( $value, string $key ): bool => $this->is_seo_meta_key( $key ), ARRAY_FILTER_USE_BOTH ); } /** * Return public custom-field refs and values separately from SEO/template internals. * * @param int $post_id Post id. */ private function custom_fields( int $post_id ): array { $fields = array(); foreach ( $this->meta( $post_id ) as $key => $value ) { if ( str_starts_with( $key, '_' ) || $this->is_seo_meta_key( $key ) ) { continue; } $fields[] = array( 'key' => $key, 'ref' => array( 'meta', $key ), 'value' => $value, ); } return array( 'fields' => $fields ); } /** * Return the assigned page template, or null when no template is stored. * * @param int $post_id Post id. */ private function page_template( int $post_id ): ?string { $value = get_post_meta( $post_id, '_wp_page_template', true ); return is_scalar( $value ) && '' !== (string) $value ? (string) $value : null; } /** * Return assigned terms keyed by taxonomy. * * @param object $post WordPress post object. */ private function terms( object $post ): array { $taxonomies = function_exists( 'get_object_taxonomies' ) ? get_object_taxonomies( (string) $post->post_type ) : array(); if ( ! is_array( $taxonomies ) ) { return array(); } $terms = array(); foreach ( $taxonomies as $taxonomy ) { if ( ! is_scalar( $taxonomy ) ) { continue; } $name = (string) $taxonomy; $assigned = function_exists( 'wp_get_post_terms' ) ? wp_get_post_terms( (int) $post->ID, $name, array( 'fields' => 'ids' ) ) : array(); if ( ! is_array( $assigned ) || array() === $assigned ) { continue; } $terms[ $name ] = array_values( array_map( fn ( $term ) => is_object( $term ) && isset( $term->term_id ) ? (int) $term->term_id : ( is_numeric( $term ) ? (int) $term : $term ), $assigned ) ); } return $terms; } /** * Return editor-facing context facts for detail reads. * * @param object $post WordPress post object. */ private function edit_context( object $post ): array { return array( 'latest_revision' => $this->latest_revision( (int) $post->ID ), 'edit_lock' => $this->edit_lock( (int) $post->ID ), ); } /** * Return lock state facts for the post. * * @param int $post_id Post id. */ private function locks( int $post_id ): array { return array( 'edit_lock' => $this->edit_lock( $post_id ) ); } /** * Return a compact latest-revision identity, or null when no revisions exist. * * @param int $post_id Post id. */ private function latest_revision( int $post_id ): ?array { $revision = self::latest_revision_object( $post_id ); if ( null === $revision ) { return null; } return array( 'id' => (int) ( $revision->ID ?? 0 ), 'modified_gmt' => (string) ( $revision->post_modified_gmt ?? '' ), ); } public static function latest_revision_id( int $post_id ): ?int { $revision = self::latest_revision_object( $post_id ); return null === $revision ? null : (int) ( $revision->ID ?? 0 ); } private static function latest_revision_object( int $post_id ): ?object { $revisions = function_exists( 'wp_get_post_revisions' ) ? wp_get_post_revisions( $post_id ) : array(); if ( ! is_array( $revisions ) || array() === $revisions ) { return null; } usort( $revisions, static fn ( $a, $b ): int => strcmp( (string) ( $b->post_modified_gmt ?? '' ), (string) ( $a->post_modified_gmt ?? '' ) ) ); return is_object( $revisions[0] ?? null ) ? $revisions[0] : null; } /** * Return the raw edit-lock meta with parsed timestamp/user when present. * * @param int $post_id Post id. */ private function edit_lock( int $post_id ): ?array { $raw = get_post_meta( $post_id, '_edit_lock', true ); if ( ! is_scalar( $raw ) || '' === (string) $raw ) { return null; } $value = (string) $raw; $parts = explode( ':', $value, 2 ); return array( 'raw' => $value, 'timestamp' => isset( $parts[0] ) && is_numeric( $parts[0] ) ? (int) $parts[0] : null, 'user_id' => isset( $parts[1] ) && is_numeric( $parts[1] ) ? (int) $parts[1] : null, ); } /** * Return whether a meta key belongs to known SEO integrations. * * @param string $key Meta key. */ private function is_seo_meta_key( string $key ): bool { if ( in_array( $key, array( 'seo_title', 'seo_description' ), true ) ) { return true; } foreach ( array( '_yoast_wpseo_', 'rank_math_', '_aioseo_' ) as $prefix ) { if ( str_starts_with( $key, $prefix ) ) { return true; } } return false; } /** * Resolve the URL that renders this post for the logged-in admin viewer. * * Published/private posts render at their permalink (private requires the * viewer's read capability, which the admin session has). Non-published * statuses (draft, pending, future, auto-draft) are not publicly * queryable, so WordPress's preview link is used; loaded inside the * same-origin admin iframe it carries the editor's session and renders the * draft, rather than 404ing on the public permalink. * * @param object $post WordPress post object. */ private function render_url( object $post ): string { $status = (string) $post->post_status; if ( ! in_array( $status, array( 'publish', 'private' ), true ) ) { $preview = $this->preview_url( $post ); if ( '' !== $preview ) { return $preview; } } return $this->public_url( $post ); } private function public_url( object $post ): string { $permalink = function_exists( 'get_permalink' ) ? get_permalink( $post ) : ''; return is_string( $permalink ) ? $permalink : ''; } private function preview_url( object $post ): string { if ( ! function_exists( 'get_preview_post_link' ) ) { return ''; } $preview = get_preview_post_link( $post ); return is_string( $preview ) ? $preview : ''; } /** * Build the precondition facts a write can guard on. * * @param object $post WordPress post object. */ private function facts( object $post ): array { return array( 'content_hash' => self::content_hash( (string) $post->post_content ), 'modified_gmt' => (string) $post->post_modified_gmt, 'revision_id' => self::latest_revision_id( (int) $post->ID ), ); } /** * Compute the content-hash precondition fact for a serialized body. * * @param string $content Serialized post content. */ public static function content_hash( string $content ): string { return 'r_' . substr( md5( $content ), 0, 12 ); } /** * Return whether a path resolves to an existing node in the document. * * @param mixed $document Document subtree. * @param array $segments Path segments. */ private function path_exists( $document, array $segments ): bool { $node = $document; foreach ( $segments as $segment ) { $key = is_scalar( $segment ) ? (string) $segment : ''; if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return false; } $node = $node[ $key ]; } return true; } /** * Return whether a missing selected path is a nullable meta-style leaf. * * Missing concrete meta keys vary by post and should project as null rather * than fail the whole read. Unknown top-level namespaces still fail closed. * * @param array $document Full post document. * @param array $segments Path segments. */ private function is_nullable_meta_path( array $document, array $segments ): bool { if ( count( $segments ) < 2 ) { return false; } $namespace = is_scalar( $segments[0] ) ? (string) $segments[0] : ''; return in_array( $namespace, array( 'meta', 'acf', 'seo_meta' ), true ) && array_key_exists( $namespace, $document ); } /** * Read the value at a path (prefix paths return the whole subtree). * * @param mixed $document Document subtree. * @param array $segments Path segments. * @return mixed */ private function path_get( $document, array $segments ) { $node = $document; foreach ( $segments as $segment ) { $node = $node[ (string) $segment ]; } return $node; } /** * Set a value at a path, building intermediate objects. * * @param array $document Document being assembled. * @param array $segments Path segments. * @param mixed $value Value to set. */ private function path_set( array $document, array $segments, $value ): array { $key = (string) $segments[0]; if ( 1 === count( $segments ) ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->path_set( $child, array_slice( $segments, 1 ), $value ); return $document; } /** * Return whether select is a list of non-empty scalar-segment paths. * * @param mixed $select Select input. */ private function is_path_list( $select ): bool { if ( ! is_array( $select ) ) { return false; } foreach ( $select as $path ) { if ( ! is_array( $path ) || array() === $path ) { return false; } foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } } return true; } /** * Return whether an array field is a positive integer identifier. * * @param array $values Source array. * @param string $field Field name. */ private function has_positive_int( array $values, string $field ): bool { return isset( $values[ $field ] ) && is_numeric( $values[ $field ] ) && (int) $values[ $field ] > 0; } /** * Build an internal error result. * * @param string $code Twelve-code error code. * @param string $message Human/LLM message. * @param array|null $path Pointer into the request, if any. * @param array|null $details Extra actionable detail, if any. */ private function error( string $code, string $message, ?array $path = null, ?array $details = null ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( null !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }