\WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_render' ), 'permission_callback' => array( $this, 'check_public_render_permission' ), 'args' => array( 'queryId' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), // Signed query definition embedded at first paint. See RefreshSource. 'source' => array( 'type' => 'string', 'required' => true, ), 'signature' => array( 'type' => 'string', 'required' => true, ), 'page' => array( 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', ), 'params' => array( 'type' => 'object', 'default' => array(), ), 'currentUrl' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'esc_url_raw', ), // Whether the page holds other Queries, so the rendered // controls and pagination use per-Query URL keys. 'multiQuery' => array( 'type' => 'boolean', 'default' => false, ), // A random-order Query's first-paint seed, so later pages // continue the same shuffle. 'seed' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/query/render-preview', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_preview_render' ), 'permission_callback' => array( $this, 'check_edit_posts_permission' ), 'args' => array( 'queryId' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), 'attributes' => array( 'type' => 'object', 'required' => true, ), 'page' => array( 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint', ), 'innerBlocks' => array( 'type' => 'string', 'default' => '', ), 'params' => array( 'type' => 'object', 'default' => array(), ), 'currentUrl' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'esc_url_raw', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/query/filter-register', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_filter_register' ), 'permission_callback' => array( $this, 'check_manage_options_permission' ), 'args' => array( 'filter_key' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), 'config' => array( 'type' => 'object', 'required' => true, ), ), ) ); // Admin-only routes (manage_options). register_rest_route( self::REST_NAMESPACE, '/query/filter-status', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_filter_status' ), 'permission_callback' => array( $this, 'check_manage_options_permission' ), ) ); register_rest_route( self::REST_NAMESPACE, '/query/filter-rebuild', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'handle_filter_rebuild' ), 'permission_callback' => array( $this, 'check_manage_options_permission' ), ) ); register_rest_route( self::REST_NAMESPACE, '/query/preview', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_preview' ), 'permission_callback' => array( $this, 'check_edit_posts_permission' ), 'args' => array( 'attributes' => array( 'type' => 'object', 'required' => true, ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/query/filters', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'handle_filters_list' ), 'permission_callback' => array( $this, 'check_manage_options_permission' ), ), array( 'methods' => \WP_REST_Server::DELETABLE, 'callback' => array( $this, 'handle_filter_unregister' ), 'permission_callback' => array( $this, 'check_manage_options_permission' ), 'args' => array( 'filter_key' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key', ), ), ), ) ); } /** * Checks that the request carries a valid nonce and the user has manage_options. * * Used by every admin-only filter route: /filter-register, /filter-status, * /filter-rebuild, /filters (list), /filters/{key} (delete). Editor-level * users go through check_edit_posts_permission instead (used only by the * /query/preview route). * * @param \WP_REST_Request $request The REST request. * @return true|\WP_Error */ public function check_manage_options_permission( \WP_REST_Request $request ) { if ( ! is_user_logged_in() ) { return new \WP_Error( 'rest_forbidden', __( 'You must be logged in.', 'airo-wp' ), array( 'status' => 401 ) ); } $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Invalid nonce.', 'airo-wp' ), array( 'status' => 401 ) ); } if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Insufficient permissions.', 'airo-wp' ), array( 'status' => 403 ) ); } return true; } /** * Handles the preview REST request. * * For `source === 'posts'` this returns a WP_Error (client uses useEntityRecords). * For `source === 'users'` runs WP_User_Query limited to perPage results. * For `source === 'terms'` runs get_terms() limited to perPage results. * * @param \WP_REST_Request $request The REST request. * @return \WP_REST_Response|\WP_Error */ public function handle_preview( \WP_REST_Request $request ) { $attributes = (array) $request->get_param( 'attributes' ); $source = isset( $attributes['source'] ) ? sanitize_key( $attributes['source'] ) : 'posts'; $per_page = isset( $attributes['perPage'] ) ? max( 1, min( 100, (int) $attributes['perPage'] ) ) : 6; if ( 'posts' === $source ) { return new \WP_Error( 'not_needed', __( 'Use useEntityRecords for posts source preview.', 'airo-wp' ), array( 'status' => 400 ) ); } if ( 'users' === $source ) { $user_query = new \WP_User_Query( self::restrict_preview_users( array( 'number' => $per_page, 'orderby' => 'registered', 'order' => 'DESC', 'fields' => array( 'ID', 'display_name' ), ) ) ); $items = array(); foreach ( $user_query->get_results() as $user ) { $items[] = array( 'id' => (int) $user->ID, 'name' => (string) $user->display_name, 'type' => 'user', ); } return rest_ensure_response( $items ); } if ( 'terms' === $source ) { $taxonomy = isset( $attributes['taxonomy'] ) ? sanitize_key( $attributes['taxonomy'] ) : 'category'; if ( ! self::can_preview_taxonomy( $taxonomy ) ) { return new \WP_Error( 'rest_forbidden', __( 'You cannot preview this taxonomy.', 'airo-wp' ), array( 'status' => 403 ) ); } $terms = get_terms( array( 'taxonomy' => $taxonomy, 'number' => $per_page, 'hide_empty' => false, 'fields' => 'id=>name', ) ); if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { return rest_ensure_response( array() ); } $items = array(); foreach ( $terms as $term_id => $term_name ) { $items[] = array( 'id' => (int) $term_id, 'name' => (string) $term_name, 'type' => 'term', ); } return rest_ensure_response( $items ); } return rest_ensure_response( array() ); } /** * Whether the current user may see a taxonomy's terms in an editor preview. * * Only taxonomies meant to be seen (public or with an admin UI) that the user * can assign terms from. Internal taxonomies stay hidden from contributors. * * @param string $taxonomy Taxonomy slug. * @return bool */ public static function can_preview_taxonomy( string $taxonomy ): bool { $object = get_taxonomy( $taxonomy ); if ( ! $object ) { return false; } return ( ! empty( $object->public ) || ! empty( $object->show_ui ) ) && current_user_can( $object->cap->assign_terms ); } /** * Returns the current filter index status. * * @return \WP_REST_Response */ public function handle_filter_status() { return rest_ensure_response( FilterIndexRebuilder::status() ); } /** * Runs a full filter index rebuild synchronously and returns the result. * * Note: on large sites this may approach PHP's max_execution_time. * For v2.2 the synchronous model is acceptable; the dashboard polls * /filter-status every 2 s so even a timeout is handled gracefully. * * @return \WP_REST_Response */ public function handle_filter_rebuild() { return rest_ensure_response( FilterIndexRebuilder::rebuild_all() ); } /** * Returns all registered filters. * * @return \WP_REST_Response */ public function handle_filters_list() { return rest_ensure_response( FilterRegistry::all() ); } /** * Unregisters a filter by key. * * @param \WP_REST_Request $request The REST request. * @return \WP_REST_Response|\WP_Error */ public function handle_filter_unregister( \WP_REST_Request $request ) { $key = $request->get_param( 'filter_key' ); if ( empty( $key ) ) { return new \WP_Error( 'airowp_filter_unregister_invalid', __( 'filter_key is required.', 'airo-wp' ), array( 'status' => 400 ) ); } if ( null === FilterRegistry::get( $key ) ) { return new \WP_Error( 'airowp_filter_not_found', __( 'Filter not found.', 'airo-wp' ), array( 'status' => 404 ) ); } FilterRegistry::unregister( $key ); return rest_ensure_response( array( 'unregistered' => true, 'filter_key' => $key, ) ); } /** * Checks that the request carries a valid nonce and the user can edit posts. * * Used by the /query/preview route — any editor-level user may use the live * preview endpoint; only admins may mutate the filter registry (see * check_manage_options_permission). * * @param \WP_REST_Request $request The REST request. * @return true|\WP_Error */ public function check_edit_posts_permission( \WP_REST_Request $request ) { if ( ! is_user_logged_in() ) { return new \WP_Error( 'rest_forbidden', __( 'You must be logged in.', 'airo-wp' ), array( 'status' => 401 ) ); } $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Invalid nonce.', 'airo-wp' ), array( 'status' => 401 ) ); } if ( ! current_user_can( 'edit_posts' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Insufficient permissions.', 'airo-wp' ), array( 'status' => 403 ) ); } return true; } /** * Handles the filter-register REST request. * * Stores the filter configuration in FilterRegistry so the PHP filter index * knows how to resolve values for this filter key. * * @param \WP_REST_Request $request The REST request. * @return \WP_REST_Response|\WP_Error */ public function handle_filter_register( \WP_REST_Request $request ) { $key = $request->get_param( 'filter_key' ); $config = (array) $request->get_param( 'config' ); if ( empty( $key ) || empty( $config['type'] ) || empty( $config['source'] ) ) { return new \WP_Error( 'airowp_filter_register_invalid', __( 'filter_key, type, and source are required.', 'airo-wp' ), array( 'status' => 400 ) ); } $type = (string) $config['type']; if ( ! in_array( $type, array( 'taxonomy', 'meta' ), true ) ) { return new \WP_Error( 'airowp_filter_invalid_type', __( 'config.type must be "taxonomy" or "meta".', 'airo-wp' ), array( 'status' => 400 ) ); } FilterRegistry::register( $key, $config ); return rest_ensure_response( array( 'registered' => true, 'filter_key' => sanitize_key( $key ), 'config' => FilterRegistry::get( $key ), ) ); } /** * Allow public rendering only of a query the site itself rendered. * * The signature proves the definition came from a first paint. A query * placed in a post's content is further limited to people who can see that * post; one in a template, pattern or widget was public wherever it rendered. * * @param \WP_REST_Request $request The REST request. * @return true|\WP_Error */ public function check_public_render_permission( \WP_REST_Request $request ) { $source = $this->verified_source( $request ); if ( null === $source ) { return new \WP_Error( 'rest_forbidden', __( 'This query could not be verified.', 'airo-wp' ), array( 'status' => 403 ) ); } if ( ! RefreshSource::can_view_source( $source['sourcePostId'] ) ) { return new \WP_Error( 'rest_forbidden', __( 'This query is not publicly available.', 'airo-wp' ), array( 'status' => 404 ) ); } return true; } /** * Decode the request's signed source, or null when it doesn't verify. * * @param \WP_REST_Request $request The REST request. * @return array|null Verified definition. */ private function verified_source( \WP_REST_Request $request ) { return RefreshSource::verify( $request->get_param( 'source' ), $request->get_param( 'signature' ), (string) $request->get_param( 'queryId' ) ); } /** * Handles the render REST request and returns HTML + pagination metadata. * * @param \WP_REST_Request $request The REST request. * @return \WP_REST_Response|\WP_Error */ public function handle_render( \WP_REST_Request $request ) { $source = $this->verified_source( $request ); if ( null === $source ) { return new \WP_Error( 'rest_forbidden', __( 'This query could not be verified.', 'airo-wp' ), array( 'status' => 403 ) ); } return $this->render_request( $source['attributes'], $source['queryId'], $source['innerBlocks'], $request, $source['sourcePostId'], isset( $source['pageContext'] ) && is_array( $source['pageContext'] ) ? $source['pageContext'] : array() ); } /** * Render arbitrary attributes for the authenticated editor preview only. * * Output carries no refresh source: signing editor-supplied settings would * let a preview mint a definition the public route then trusts. * * @param \WP_REST_Request $request The REST request. * @return \WP_REST_Response|\WP_Error */ public function handle_preview_render( \WP_REST_Request $request ) { $attributes = (array) $request->get_param( 'attributes' ); $query_id = (string) $request->get_param( 'queryId' ); $source = sanitize_key( (string) ( $attributes['source'] ?? 'posts' ) ); // The terms renderer reads its taxonomies from taxQuery clauses // (defaulting to category), so those are what must be previewable. if ( 'terms' === $source ) { $taxonomies = self::clause_taxonomies( (array) ( $attributes['taxQuery']['clauses'] ?? array() ) ); foreach ( $taxonomies ? $taxonomies : array( 'category' ) as $taxonomy ) { if ( taxonomy_exists( $taxonomy ) && ! self::can_preview_taxonomy( $taxonomy ) ) { return new \WP_Error( 'rest_forbidden', __( 'You cannot preview this taxonomy.', 'airo-wp' ), array( 'status' => 403 ) ); } } } // Anyone who can edit posts can preview, so a query is limited to post // types they could already see or edit — never coupons, orders, etc. // Every source but users and terms can reach the posts renderer: // unknown sources and `current` fall through to it, and relationship's // 'all' fallback becomes it, each reading postType. if ( ! in_array( $source, array( 'users', 'terms' ), true ) && ! self::can_preview_post_type( (string) ( $attributes['postType'] ?? 'post' ) ) ) { return new \WP_Error( 'rest_forbidden', __( 'You cannot preview this content type.', 'airo-wp' ), array( 'status' => 403 ) ); } // Manual IDs (and an empty manual list) query post_type 'any', which // spans every searchable type. Narrow the final WP_Query args instead of // each path to them, after the query-scoped filter too: its name comes // from the caller's queryId. $restrict = array( __CLASS__, 'restrict_preview_post_types' ); $users = array( __CLASS__, 'restrict_preview_users' ); $terms = array( __CLASS__, 'restrict_preview_terms' ); $hooks = array( 'airowp_query_args', 'airowp_query_' . $query_id . '_args' ); foreach ( $hooks as $hook ) { add_filter( $hook, $restrict, PHP_INT_MAX ); if ( 'users' === $source ) { add_filter( $hook, $users, PHP_INT_MAX ); } if ( 'terms' === $source ) { add_filter( $hook, $terms, PHP_INT_MAX ); } } try { return $this->render_request( $attributes, $query_id, (string) $request->get_param( 'innerBlocks' ), $request, null ); } finally { foreach ( $hooks as $hook ) { remove_filter( $hook, $restrict, PHP_INT_MAX ); remove_filter( $hook, $users, PHP_INT_MAX ); remove_filter( $hook, $terms, PHP_INT_MAX ); } } } /** * Taxonomies named by taxQuery clauses, including those in nested groups. * * @param array $clauses taxQuery clauses. * @return string[] Taxonomy slugs. */ private static function clause_taxonomies( array $clauses ): array { $taxonomies = array(); foreach ( $clauses as $clause ) { if ( ! is_array( $clause ) ) { continue; } if ( isset( $clause['clauses'] ) && is_array( $clause['clauses'] ) ) { $taxonomies = array_merge( $taxonomies, self::clause_taxonomies( $clause['clauses'] ) ); } elseif ( ! empty( $clause['taxonomy'] ) ) { $taxonomies[] = sanitize_key( (string) $clause['taxonomy'] ); } } return array_values( array_unique( array_filter( $taxonomies ) ) ); } /** * Narrow an editor preview's term query to taxonomies the user may preview. * * A backstop for the request check: it applies to the final get_terms() * args, however a renderer or filter chose the taxonomies. * * @param mixed $args get_terms() args (other sources carry no taxonomy). * @return mixed Args limited to previewable taxonomies. */ public static function restrict_preview_terms( $args ) { if ( ! is_array( $args ) || ! isset( $args['taxonomy'] ) ) { return $args; } $allowed = array_values( array_filter( (array) $args['taxonomy'], array( __CLASS__, 'can_preview_taxonomy' ) ) ); if ( empty( $allowed ) ) { // An empty taxonomy list means "every taxonomy" to get_terms(). $args['include'] = array( 0 ); return $args; } $args['taxonomy'] = $allowed; return $args; } /** * Narrow an editor preview's user query for someone who can't list users. * * They see only people with published posts, who are public already through * their author archives, rather than every account on the site. * * @param mixed $args WP_User_Query args. * @return mixed Args, limited to authors when the user can't list users. */ public static function restrict_preview_users( $args ) { if ( ! is_array( $args ) || current_user_can( 'list_users' ) ) { return $args; } $args['has_published_posts'] = true; return $args; } /** * Narrow an editor preview's WP_Query to post types the user may preview. * * @param mixed $args Query args (WP_Query, or WP_User_Query / get_terms for * other sources, which carry no post_type). * @return mixed Args with post_type narrowed. */ public static function restrict_preview_post_types( $args ) { if ( ! is_array( $args ) || ! isset( $args['post_type'] ) ) { return $args; } $requested = 'any' === $args['post_type'] ? get_post_types( array( 'exclude_from_search' => false ) ) : (array) $args['post_type']; $allowed = array_values( array_filter( $requested, static function ( $post_type ) { return self::can_preview_post_type( (string) $post_type ); } ) ); if ( empty( $allowed ) ) { $args['post__in'] = array( 0 ); } else { $args['post_type'] = $allowed; } return $args; } /** * Whether the current user may preview posts of a type. * * @param string $post_type Post type name. * @return bool True for types the public can view or the user can edit. */ private static function can_preview_post_type( $post_type ) { $object = get_post_type_object( sanitize_key( $post_type ) ); return $object && ( is_post_type_viewable( $object ) || current_user_can( $object->cap->edit_posts ) ); } /** * Render a query request after its source has been authorised. * * @param array $attributes Saved or editor-preview attributes. * @param string $query_id Query ID. * @param string $inner_html Serialized child blocks. * @param \WP_REST_Request $request The REST request. * @param int|null $source_post_id Post holding the query (0 outside * post content), or null to emit * no refresh source. * @param array|null $page_context Page facts recorded in the signed * definition at first paint (current * post, inherited archive vars), or * null for an editor preview. * @return \WP_REST_Response|\WP_Error */ private function render_request( array $attributes, $query_id, $inner_html, \WP_REST_Request $request, $source_post_id, $page_context = null ) { $page = max( 1, (int) $request->get_param( 'page' ) ); $params = (array) $request->get_param( 'params' ); $current_url = (string) $request->get_param( 'currentUrl' ); // Sibling filter blocks (search / sort / checkbox / select / active / // reset) read filter state from $_GET and the current page URL // (`add_query_arg(array())`) so the no-JS fallback can build chip/ // reset links that navigate back to the page. On the REST refresh // path, $_GET is empty and REQUEST_URI points at the REST endpoint, // so we overlay both for the duration of the render and restore // afterwards to avoid leaking state into later request-scoped code. $original_get = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Snapshot REQUEST_URI raw — sanitize_text_field() mangles URL encoding // (eats `+`, collapses whitespace) and this value is only ever restored // to the superglobal, never echoed or used in HTML. $original_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- restore-only, see comment above. $scoping = AIRO_WP_PLUGIN_DIR . 'build/blocks/query/param-scoping.php'; if ( ! file_exists( $scoping ) ) { return new \WP_Error( 'airowp_query_unavailable', __( 'Query rendering is unavailable.', 'airo-wp' ), array( 'status' => 500 ) ); } require_once $scoping; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- build artifact; path resolved from plugin directory // Same list first paint reads: WooCommerce's filter blocks write // min_price / max_price / rating_filter and query_type_ (which // pairs with filter_). They are bare keys, never query-scoped. $allowed_keys = apply_filters( 'airowp_query_url_params', array( 'q', 'sort', 'min_price', 'max_price', 'rating_filter' ) ); $own_id = sanitize_key( (string) $query_id ); foreach ( $params as $key => $value ) { $key = (string) $key; // Per-Query keys (`q__{queryId}`) resolve by their bare name, the // same way airowp_query_owned_request_params() reads them back. // Other Queries' filter_* keys pass through so this render's // controls and chips see the whole URL, as on first paint. list( $bare, $scope ) = airowp_query_split_param_key( sanitize_key( $key ), $own_id ); $is_wildcard = 0 === strpos( $bare, 'filter_' ) || 0 === strpos( $bare, 'query_type_' ); $allowed = in_array( $bare, $allowed_keys, true ) || $is_wildcard; if ( $allowed && ( '' === $scope || $scope === $own_id || $is_wildcard ) ) { // REST-supplied values are sanitized downstream before use in // WP_Query / SQL / HTML, but nested block renders may pass // through filter hooks or third-party code that reads $_GET // directly — sanitize at the overlay boundary too. if ( is_array( $value ) ) { $_GET[ $key ] = array_map( 'sanitize_text_field', wp_unslash( (array) $value ) ); } else { $_GET[ $key ] = sanitize_text_field( wp_unslash( (string) $value ) ); } } } // The browser can see the whole page; this render sees one Query. $original_multi = $GLOBALS['airowp_query_force_multi'] ?? null; $GLOBALS['airowp_query_force_multi'] = (bool) $request->get_param( 'multiQuery' ); // This request has no page of its own: replay the facts first paint saw. $original_page_context = $GLOBALS['airowp_query_request_context'] ?? null; if ( is_array( $page_context ) ) { $GLOBALS['airowp_query_request_context'] = $page_context; } if ( '' !== $current_url ) { $parsed = wp_parse_url( $current_url ); if ( is_array( $parsed ) && isset( $parsed['path'] ) ) { $_SERVER['REQUEST_URI'] = $parsed['path'] . ( isset( $parsed['query'] ) ? '?' . $parsed['query'] : '' ); } } $context = array( 'query_id' => $query_id, 'page' => $page, 'inner_html' => $inner_html, 'params' => $params, 'refresh_source_post_id' => $source_post_id, 'rand_seed' => absint( $request->get_param( 'seed' ) ), ); try { // A public refresh renders within its source post, so queries nested // in the region are re-signed behind the same gate. $result = null === $source_post_id ? self::render( $attributes, $context ) : RefreshSource::render_within( $source_post_id, static function () use ( $attributes, $context ) { return self::render( $attributes, $context ); } ); } finally { $_GET = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $_SERVER['REQUEST_URI'] = $original_uri; if ( null === $original_multi ) { unset( $GLOBALS['airowp_query_force_multi'] ); } else { $GLOBALS['airowp_query_force_multi'] = $original_multi; } if ( null === $original_page_context ) { unset( $GLOBALS['airowp_query_request_context'] ); } else { $GLOBALS['airowp_query_request_context'] = $original_page_context; } } return rest_ensure_response( $result ); } /** * Shared render entrypoint used by both REST and first-paint render.php. * * Delegates to airowp_query_render_region() so the REST response * contains the full region (list + sibling blocks wrapped in * .airo-wp-query-region) — identical to the first-paint output. The JS * refresh handler swaps the outer region's innerHTML in one operation, * updating pagination + no-results + chips together with the list. * * @param array $attributes Block attributes. * @param array $context Keys: query_id, page, inner_html (full serialized * innerBlocks including siblings), params. * @return array { html: string, totalPages: int, totalItems: int } */ public static function render( array $attributes, array $context ) { $helpers = AIRO_WP_PLUGIN_DIR . 'build/blocks/query/render-helpers.php'; if ( file_exists( $helpers ) ) { require_once $helpers; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- build artifact; path resolved from plugin directory if ( function_exists( 'airowp_query_render_region' ) ) { return airowp_query_render_region( $attributes, $context ); } // Fallback to bare render (no region wrapper) for environments where // the build artefact predates the region helper (e.g. older build cache). if ( function_exists( 'airowp_query_render' ) ) { return airowp_query_render( $attributes, $context ); } } return array( 'html' => '', 'totalPages' => 0, 'totalItems' => 0, ); } }