array( 'valid' => true, 'offset' => 0, ), ); } if ( ! str_starts_with( $cursor, 'c_' ) ) { return self::error( 'cursor must be an opaque page.next value returned by the same query.' ); } $encoded = substr( $cursor, 2 ); $decoded = base64_decode( strtr( $encoded, '-_', '+/' ), true ); $data = false === $decoded ? null : json_decode( $decoded, true ); if ( ! is_array( $data ) || ! isset( $data['offset'] ) || ! is_int( $data['offset'] ) || $data['offset'] < 0 ) { return self::error( 'cursor is not a valid page.next value.' ); } if ( array() !== $context ) { $resource = isset( $context['resource'] ) && is_scalar( $context['resource'] ) ? (string) $context['resource'] : ''; $query = self::context_hash( $context ); if ( (string) ( $data['resource'] ?? '' ) !== $resource || (string) ( $data['query'] ?? '' ) !== $query ) { return self::error( 'cursor does not match this resource and query; restart from the first page.' ); } } return array( 'data' => array( 'valid' => true, 'offset' => $data['offset'], ), ); } /** * Return one page slice plus the next offset cursor. * * @param array $items Full result set. * @param int $limit Page size. * @param mixed $cursor Cursor value. * @param array $context Query-shaping context that the cursor must match. * * @return array{items?: array, next_cursor?: string|null, offset?: int, error?: array} */ public static function page( array $items, int $limit, $cursor, array $context = array() ): array { $validated = self::validate( $cursor, $context ); if ( isset( $validated['error'] ) ) { return $validated; } $offset = (int) $validated['data']['offset']; $page = array_slice( $items, $offset, $limit ); $next_offset = $offset + count( $page ); return array( 'items' => $page, 'next_cursor' => $next_offset < count( $items ) ? self::encode( $next_offset, $context ) : null, 'offset' => $offset, ); } /** * Encode an offset into the opaque cursor format shared with wp.posts.query. */ public static function encode( int $offset, array $context = array() ): string { $payload = array( 'offset' => $offset ); if ( array() !== $context ) { $payload['resource'] = isset( $context['resource'] ) && is_scalar( $context['resource'] ) ? (string) $context['resource'] : ''; $payload['query'] = self::context_hash( $context ); } $json = function_exists( 'wp_json_encode' ) ? wp_json_encode( $payload ) : json_encode( $payload ); return 'c_' . rtrim( strtr( base64_encode( (string) $json ), '+/', '-_' ), '=' ); } private static function error( string $message ): array { return array( 'error' => array( 'code' => 'invalid_value', 'message' => $message, 'path' => array( 'input', 'cursor' ), 'details' => array( 'reason' => 'invalid_cursor' ), ), ); } private static function context_hash( array $context ): string { $normalized = self::normalize( $context ); $options = defined( 'JSON_UNESCAPED_SLASHES' ) ? JSON_UNESCAPED_SLASHES : 0; $json = function_exists( 'wp_json_encode' ) ? wp_json_encode( $normalized, $options ) : json_encode( $normalized, $options ); return hash( 'sha256', (string) $json ); } private static function normalize( $value ) { if ( ! is_array( $value ) ) { return $value; } $keys = array_keys( $value ); if ( range( 0, count( $value ) - 1 ) !== $keys ) { ksort( $value ); } foreach ( $value as $key => $item ) { $value[ $key ] = self::normalize( $item ); } return $value; } }