*/ private array $contexts = array(); /** * Request-wide facts shared by every context, resolved once. * * @var array|null */ private ?array $request = null; /** * Blocks that run a Query Loop's per-post iteration via the_post(): core's * Post Template and WooCommerce's Product Template (Product Collection). */ private const LOOP_BLOCKS = array( 'core/post-template', 'woocommerce/product-template' ); /** * How many Query Loops (Post Template blocks) are currently rendering. * * @var int */ private int $loop_depth = 0; /** * Returns the Context for the post currently being rendered. * * @return Context */ public function current(): Context { /* * A long-running process (WP-CLI, cron) may render content for several * users in turn via wp_set_current_user(); the cached facts belong to * one user only. */ if ( null !== $this->request && get_current_user_id() !== $this->request['user_id'] ) { $this->request = null; $this->contexts = array(); } $post_id = $this->resolve_post_id(); return $this->contexts[ $post_id ] ??= $this->build( $post_id ); } /** * Builds the Context for a given current post id. * * @param int $post_id Current post id (0 when there is none). * @return Context */ private function build( int $post_id ): Context { $request = $this->request(); return new Context( $request['user_id'], $request['roles'], $request['is_logged_in'], $request['datetime'], $this->resolve_post_type( $post_id ), $post_id, $request['is_archive'], $request['archive_type'], $request['is_home'], $request['is_front_page'], $request['is_search'], $request['is_404'], // A REST response renders one post at a time: treat it as a singular view of that post. $request['is_single'] || ( $post_id > 0 && $request['is_rest'] ), $request['url'], $request['query_params'] ); } /** * Resolves the request-wide facts shared by every context, once per request. * * @return array */ private function request(): array { if ( null !== $this->request ) { return $this->request; } $user = wp_get_current_user(); $this->request = array( 'user_id' => (int) $user->ID, 'roles' => array_values( (array) $user->roles ), 'is_logged_in' => is_user_logged_in(), 'datetime' => current_datetime(), 'is_archive' => is_archive(), 'archive_type' => $this->resolve_archive_type(), 'is_home' => is_home(), 'is_front_page' => is_front_page(), 'is_search' => is_search(), 'is_404' => is_404(), 'is_single' => is_singular(), 'is_rest' => $this->is_rest_request(), 'url' => $this->resolve_url(), 'query_params' => $this->resolve_query_params(), ); return $this->request; } /** * Resolves the post type of the post being rendered, or of a post-type archive. * * @param int $post_id Current post id (0 when there is none). * @return string|null */ private function resolve_post_type( int $post_id ): ?string { if ( $post_id > 0 ) { $type = get_post_type( $post_id ); if ( is_string( $type ) && '' !== $type ) { return $type; } } if ( is_post_type_archive() ) { $type = get_query_var( 'post_type' ); if ( is_array( $type ) ) { $type = $type[0] ?? ''; } return is_string( $type ) && '' !== $type ? $type : null; } return null; } /** * Resolves the id of the post currently being rendered. * * Inside a loop (including the Query Loop block) this is the loop's current * post, so blocks repeated per post evaluate against their own post. Outside * any loop only singular views, the posts page, and REST responses (which * render one post at a time) have a post identity; archives deliberately * report none rather than leaking the first result of the query. * * @return int */ private function resolve_post_id(): int { if ( in_the_loop() ) { $id = get_the_ID(); return $id ? (int) $id : 0; } /* * REST responses (e.g. `content.rendered` of /wp/v2/posts/) render a * post outside any loop; the controller sets up the global post instead. * Without this, post conditions would see "no post" and hide their block * for headless consumers. A Query Loop sets each item as the global post * and core restores the response's post after every dynamic block. */ if ( $this->is_rest_request() ) { return isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof \WP_Post ? (int) $GLOBALS['post']->ID : 0; } /* * A non-inherited Query Loop rendered outside the main loop on a singular * view (a sidebar widget after a classic theme's loop, a template core * does not wrap in the main loop) must report each item, not the viewed * post. Only an actual Query Loop qualifies: other code that swaps the * global post (popups, mega menus) must not change the viewed post. */ if ( $this->loop_depth > 0 && isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof \WP_Post ) { return (int) $GLOBALS['post']->ID; } if ( is_singular() || $this->is_posts_page() ) { return (int) get_queried_object_id(); } return $this->secondary_loop_post_id(); } /** * Counts a Query Loop (Post Template) starting to render. * * Hooked to `render_block_data`, which fires before a block renders. * * @param mixed $parsed_block Parsed block (a filter value: any type is possible). * @return mixed Unchanged parsed block. */ public function enter_block( $parsed_block ) { if ( is_array( $parsed_block ) && in_array( $parsed_block['blockName'] ?? null, self::LOOP_BLOCKS, true ) ) { ++$this->loop_depth; } return $parsed_block; } /** * Counts a Query Loop (Post Template) finishing. * * Hooked to `render_block`, which fires after a block renders. Core skips * both filters together when `pre_render_block` short-circuits a block. * * @param mixed $block_content Rendered block HTML. * @param mixed $parsed_block Parsed block (a filter value: any type is possible). * @return mixed Unchanged block HTML. */ public function leave_block( $block_content, $parsed_block ) { if ( is_array( $parsed_block ) && in_array( $parsed_block['blockName'] ?? null, self::LOOP_BLOCKS, true ) && $this->loop_depth > 0 ) { --$this->loop_depth; } return $block_content; } /** * Reports whether the main query is the posts page (a static page set as * "Posts page"), whose queried object is that page. * * @return bool */ private function is_posts_page(): bool { return is_home() && get_queried_object() instanceof \WP_Post; } /** * Resolves the current post of a secondary loop, if one is running. * * A Query Loop block that does not inherit the main query (any Query Loop on * a home, archive, search or 404 template) iterates its own WP_Query. Its * the_post() sets up the global post for each item but never enters the main * query's loop, so in_the_loop() stays false. Inside such a loop the global * post is an object the main query never produced; wp_reset_postdata() * restores the main query's own post afterwards. * * Outside any loop the global post is one of the main query's own results: * the first one, as WP::register_globals() assigned it, or — once a classic * theme's main loop has finished — the last one iterated, because * WP_Query::have_posts() rewinds the query's post to the first result while * leaving the global post on the last. Neither may pass for a current post * on an archive, so the global post is checked against every result object * of the main query, not just its current one. * * @return int Post id, or 0 when no secondary loop is running. */ private function secondary_loop_post_id(): int { $post = $GLOBALS['post'] ?? null; if ( ! $post instanceof \WP_Post ) { return 0; } $main_query = $GLOBALS['wp_query'] ?? null; if ( $main_query instanceof \WP_Query ) { if ( $post === $main_query->post ) { return 0; } if ( is_array( $main_query->posts ) && in_array( $post, $main_query->posts, true ) ) { return 0; } } return (int) $post->ID; } /** * Reports whether the current request is served by the REST API. * * @return bool */ private function is_rest_request(): bool { return defined( 'REST_REQUEST' ) && REST_REQUEST; } /** * Resolves the current archive kind. * * @return string|null */ private function resolve_archive_type(): ?string { if ( is_category() ) { return 'category'; } if ( is_tag() ) { return 'tag'; } if ( is_tax() ) { return 'taxonomy'; } if ( is_date() ) { return 'date'; } if ( is_author() ) { return 'author'; } if ( is_post_type_archive() ) { return 'post_type'; } return null; } /** * Resolves the full current request URL. * * @return string */ private function resolve_url(): string { $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( '' === $uri ) { return home_url( '/' ); } /* * REQUEST_URI already carries the full path, including any subdirectory * WordPress is installed in, so only the origin comes from home_url(). * (home_url( $uri ) would repeat the subdirectory: /wp/wp/page.) */ $home = wp_parse_url( home_url() ); $origin = ( $home['scheme'] ?? 'http' ) . '://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ); return $origin . '/' . ltrim( $uri, '/' ); } /** * Resolves the URL query parameters. * * Values are sanitised as text (recursively, so array parameters such as * `?tag[]=a&tag[]=b` keep their shape) before conditions compare them. * * @return array */ private function resolve_query_params(): array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only inspection of the public URL. if ( empty( $_GET ) ) { return array(); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only inspection of the public URL. $params = (array) wp_unslash( $_GET ); return (array) map_deep( $params, 'sanitize_text_field' ); } }