/
    /
    wrapper. * - airowp_query_render_item() Renders a single item by parsing * and re-rendering the block's * innerBlocks with overridden * postId / postType context so core * blocks and Block Bindings see the * iterated item. * - airowp_query_extract_params_from_request() * Whitelisted filter/search params * pulled from $_GET. * - airowp_query_set_last_state() / airowp_query_get_last_state() * In-memory per-request registry * of each Query ID's most recent * render state (pages, items, page). * Used by pagination + no-results * siblings (Tasks 13, 15). * - airowp_query_set_item_host() / airowp_query_get_item_host() * In-memory per-request registry * of which layout block presented * each Query ID's results, paired * with * airowp_query_host_supports_infinite_scroll() * so the pagination sibling can * adapt to a carousel host. * * @package airo-wp * @since 2.1.0 */ defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/param-scoping.php'; if ( ! function_exists( 'airowp_query_status_html' ) ) { /** * The live-region element announcing result counts. Shared by first paint and * the REST refresh so both emit identical markup. * * @param string $query_id Query id. * @param int $total_items Total matching items. * @return string */ function airowp_query_status_html( $query_id, $total_items ) { return sprintf( '
    ', esc_attr( $query_id ), (int) $total_items, esc_attr__( 'No results found', 'airo-wp' ), esc_attr__( '1 result found', 'airo-wp' ), /* translators: %d: number of results */ esc_attr__( '%d results found', 'airo-wp' ) ); } } if ( ! function_exists( 'airowp_safe_css_value' ) ) : /** * Sanitize a CSS value destined for an inline `--prop: VALUE` declaration. * * Used by layout-host render.php files (slider, scroll-slides, …) which * emit block attributes as custom properties in a `style=""` attribute. * `get_block_wrapper_attributes()` HTML-encodes the outer quote, but that * alone does not prevent CSS-context injection — an editor-capability user * storing `20px; --airo-wp-slider-slides-per-view:99` would otherwise inject * an extra custom property. * * The sanitizer strips characters that could close the current declaration * (`;`, `{`, `}`), escape sequences (`\`), control/newline characters, and * rejects values that load a resource or run script — `url(`, `image-set(` * (which takes bare strings as URLs), `expression(`, `javascript:` — * entirely. None of this helper's callers take an image, so a URL here can * only be a tracking beacon. Legitimate CSS values — `var(...)`, * `calc(...)`, colors, lengths, aspect ratios — pass through unchanged. * * @param mixed $value Raw attribute value. * @return string Safe-to-concatenate CSS value (empty string if rejected). */ function airowp_safe_css_value( $value ) { if ( ! is_string( $value ) && ! is_numeric( $value ) ) { return ''; } $value = (string) $value; if ( '' === $value ) { return ''; } // Strip chars that could break out of a `--prop: VALUE` declaration // context: semicolon / braces / backslash (escape) / control + newline. // Strip first, then check: `u\rl(` only reads as `url(` once the // backslash is gone. $value = preg_replace( '/[;{}\\\\\r\n\x00-\x1F]/', '', $value ); if ( preg_match( '/url\(|image-set\(|expression\(|javascript:/i', $value ) ) { return ''; } return $value; } endif; if ( ! function_exists( 'airowp_query_item_host_block_names' ) ) : /** * Block names that may act as the item host inside a airo-wp/query. * * An "item host" is the child block whose innerBlocks define the per-item * template and whose render.php emits the iterated items (or chrome that * wraps them). The built-in hosts are airo-wp/query-results (grid * layout), airo-wp/slider, and airo-wp/scroll-slides. * * Third parties can register their own layout blocks as item hosts via the * `airowp_query_item_host_block_names` filter, provided they pair the * registration with the matching render.php contract (read pre-rendered * items from $GLOBALS['airowp_query_items_html'][ queryId ], wrap in * their own chrome, echo). * * @return string[] Registered item host block names. */ function airowp_query_item_host_block_names() { /** * Filter the list of blocks that may act as item hosts inside a Dynamic Query. * * @param string[] $hosts Default list: [ * 'airo-wp/query-results', * 'airo-wp/slider', * 'airo-wp/scroll-slides', * ]. */ $hosts = apply_filters( 'airowp_query_item_host_block_names', array( 'airo-wp/query-results', 'airo-wp/slider', 'airo-wp/scroll-slides', ) ); return array_values( array_filter( array_map( 'strval', (array) $hosts ) ) ); } endif; if ( ! function_exists( 'airowp_query_set_item_host' ) ) : /** * Record which item host block rendered a given Query ID this request. * * Sibling blocks render after the host, so they can read this to adapt * their own output to the presentation the author chose — the pagination * block uses it to decide whether infinite scroll is viable. * * Not a persistent cache — lives only for the current request, alongside * airowp_query_set_last_state(). * * @param string $query_id Unique query identifier. * @param string $host_name Block name of the resolved item host. */ function airowp_query_set_item_host( $query_id, $host_name ) { if ( ! isset( $GLOBALS['airowp_query_item_hosts'] ) || ! is_array( $GLOBALS['airowp_query_item_hosts'] ) ) { $GLOBALS['airowp_query_item_hosts'] = array(); } $GLOBALS['airowp_query_item_hosts'][ (string) $query_id ] = (string) $host_name; } /** * Retrieve the item host block name recorded for a Query ID. * * @param string $query_id Unique query identifier. * @return string Block name, or '' when the host has not rendered yet. */ function airowp_query_get_item_host( $query_id ) { $hosts = isset( $GLOBALS['airowp_query_item_hosts'] ) ? (array) $GLOBALS['airowp_query_item_hosts'] : array(); return isset( $hosts[ (string) $query_id ] ) ? (string) $hosts[ (string) $query_id ] : ''; } endif; if ( ! function_exists( 'airowp_query_host_supports_infinite_scroll' ) ) : /** * Whether an item host can carry infinite-scroll pagination. * * Infinite scroll hangs on a sentinel element placed after the items: as * the reader scrolls down the document and the sentinel enters the * viewport, the next page loads. That signal only exists when the items * grow the page vertically. * * Carousel-shaped hosts (slider, scroll-slides) lay their items out inside * a fixed-height viewport, so the sentinel's position no longer tracks how * far the reader has got through the results. It sits just below the * carousel, where it either intersects on first paint — firing page after * page until the query is exhausted — or never intersects at all. Neither * is pagination, so the presentation wins and the pagination block falls * back to a Load more button (see query-pagination/render.php). * * @param string $host_name Item host block name. * @return bool True when the host grows vertically with its items. */ function airowp_query_host_supports_infinite_scroll( $host_name ) { $host_name = (string) $host_name; // An empty host name means no host block rendered (legacy trees where // the template blocks are direct children of the query). Those emit a // vertical grid, so infinite scroll is fine. $supported = ( '' === $host_name || 'airo-wp/query-results' === $host_name ); /** * Filter whether an item host supports infinite-scroll pagination. * * Third-party hosts registered via `airowp_query_item_host_block_names` * default to unsupported (they are non-grid by definition); return true * here if the host does grow the page vertically as items are appended. * * @param bool $supported Whether infinite scroll is viable. * @param string $host_name Item host block name. */ return (bool) apply_filters( 'airowp_query_host_supports_infinite_scroll', $supported, $host_name ); } endif; if ( ! function_exists( 'airowp_query_render' ) ) : /** * Render a Dynamic Query block for any source. * * @param array $attributes Block attributes. * @param array $context Keys: query_id (string), page (int), inner_html (string), params (array), * wrapper_attrs (string|null) — pre-computed get_block_wrapper_attributes() string * from render.php; null for REST/tests. * @return array { html: string, totalPages: int, totalItems: int } */ function airowp_query_render( array $attributes, array $context ) { $attributes = airowp_query_defaults( $attributes ); $context = wp_parse_args( $context, array( 'query_id' => '', 'page' => 1, 'inner_html' => '', 'params' => array(), 'wrapper_attrs' => null, // first-paint passes string; REST/tests pass null. ) ); switch ( $attributes['source'] ) { case 'users': require_once __DIR__ . '/render-users.php'; if ( function_exists( 'airowp_query_render_users' ) ) { return airowp_query_render_users( $attributes, $context ); } break; case 'terms': require_once __DIR__ . '/render-terms.php'; if ( function_exists( 'airowp_query_render_terms' ) ) { return airowp_query_render_terms( $attributes, $context ); } break; case 'relationship': require_once __DIR__ . '/render-relationship.php'; if ( function_exists( 'airowp_query_render_relationship' ) ) { return airowp_query_render_relationship( $attributes, $context ); } break; case 'posts': case 'manual': case 'current': default: require_once __DIR__ . '/render-posts.php'; return airowp_query_render_posts( $attributes, $context ); } return array( 'html' => '', 'items_html' => '', 'totalPages' => 0, 'totalItems' => 0, ); } /** * Apply attribute defaults. Separate so tests and the REST endpoint can * build identical args without re-listing every key. * * @param array $attributes Raw block attributes. * @return array Merged attributes with defaults applied. */ function airowp_query_defaults( array $attributes ) { $defaults = array( 'queryId' => '', 'source' => 'posts', 'postType' => 'post', 'perPage' => 6, 'offset' => 0, 'orderBy' => 'date', 'orderByMetaKey' => '', 'order' => 'DESC', 'search' => '', 'bindSearchTo' => '', 'author' => array(), 'excludeCurrent' => false, 'ignoreSticky' => true, 'manualIds' => array(), 'taxQuery' => array( 'relation' => 'AND', 'clauses' => array(), ), 'metaQuery' => array( 'relation' => 'AND', 'clauses' => array(), ), 'tagName' => 'ul', 'itemTagName' => 'li', 'emitSchema' => true, 'relationshipField' => '', 'relationshipFallback' => 'empty', // One of 'empty', 'all' or 'parent'. 'groupBy' => null, ); return wp_parse_args( $attributes, $defaults ); } /** * Partition an array of post IDs into labelled groups for grouped rendering. * * Supported fields: 'taxonomy' (by term slug), 'meta' (by meta value), * 'date' (by Y, Y-m, or Y-m-d depending on $group_spec['key']). * * Posts with multiple terms (taxonomy field) appear in ALL matching groups. * Posts with no matching term land in the '__none__' / Uncategorized bucket. * * @param int[] $post_ids Ordered list of post IDs to partition. * @param array $group_spec { field: string, key: string }. * @return array[] Array of groups: each { label: string, value: string, ids: int[] } */ function airowp_query_partition_items( array $post_ids, array $group_spec ) { if ( empty( $group_spec['field'] ) || empty( $group_spec['key'] ) ) { return array( array( 'label' => '', 'value' => '', 'ids' => $post_ids, ), ); } $field = (string) $group_spec['field']; $key = (string) $group_spec['key']; if ( 'meta' === $field && is_protected_meta( $key, 'post' ) ) { return array( array( 'label' => __( 'Uncategorized', 'airo-wp' ), 'value' => '__none__', 'ids' => array_map( 'absint', $post_ids ), ), ); } $groups = array(); foreach ( $post_ids as $pid ) { $values = array(); $labels = array(); if ( 'taxonomy' === $field ) { $terms = get_the_terms( $pid, $key ); if ( empty( $terms ) || is_wp_error( $terms ) ) { $values = array( '__none__' ); $labels = array( __( 'Uncategorized', 'airo-wp' ) ); } else { $values = wp_list_pluck( $terms, 'slug' ); $labels = wp_list_pluck( $terms, 'name' ); } } elseif ( 'meta' === $field ) { $v = (string) get_post_meta( $pid, $key, true ); $values = array( $v ); $labels = array( $v ); } elseif ( 'date' === $field ) { $d = get_post_field( 'post_date', $pid ); $ts = $d ? strtotime( $d ) : 0; $format = 'Y-M-D' === $key ? 'Y-m-d' : ( 'Y-M' === $key ? 'Y-m' : 'Y' ); $values = array( gmdate( $format, $ts ) ); $labels = $values; } else { $values = array( '' ); $labels = array( '' ); } foreach ( $values as $i => $v ) { if ( ! isset( $groups[ $v ] ) ) { $groups[ $v ] = array( 'label' => $labels[ $i ] ?? $v, 'value' => $v, 'ids' => array(), ); } $groups[ $v ]['ids'][] = $pid; } } return array_values( $groups ); } /** * Emit the list wrapper around accumulated items markup. * * @param string $inner_items Accumulated
  1. …
  2. markup. * @param array $atts Attributes (already defaulted). * @param array $context Render context. * @param string|null $wrapper_attrs Optional pre-computed wrapper attrs string * from get_block_wrapper_attributes(). When * provided (first-paint), it carries all * native-supports classes and user inline * styles. When null (REST / tests), fall * back to the minimal attrs we build here. * @return string */ function airowp_query_wrap( $inner_items, array $atts, array $context, $wrapper_attrs = null ) { $tag = in_array( $atts['tagName'], array( 'ul', 'ol', 'div' ), true ) ? $atts['tagName'] : 'ul'; $query_id = sanitize_key( (string) ( $context['query_id'] ?? '' ) ); $source = sanitize_key( (string) $atts['source'] ); // Column CSS variables drive the responsive grid layout in // query-results/style.scss. $columns = isset( $atts['columns'] ) ? max( 1, (int) $atts['columns'] ) : 1; $columns_tablet = isset( $atts['columnsTablet'] ) ? (int) $atts['columnsTablet'] : 0; $columns_mobile = isset( $atts['columnsMobile'] ) ? (int) $atts['columnsMobile'] : 0; $first_col_span = isset( $atts['firstItemColumnSpan'] ) ? max( 1, (int) $atts['firstItemColumnSpan'] ) : 1; $first_row_span = isset( $atts['firstItemRowSpan'] ) ? max( 1, (int) $atts['firstItemRowSpan'] ) : 1; // Clamp the column span to the desktop column count so we never ask the // grid to span more tracks than exist. $first_col_span = min( $first_col_span, $columns ); $grid_style = sprintf( '--airo-wp-query-columns:%d;', $columns ); if ( $columns_tablet > 0 ) { $grid_style .= sprintf( '--airo-wp-query-columns-tablet:%d;', $columns_tablet ); } if ( $columns_mobile > 0 ) { $grid_style .= sprintf( '--airo-wp-query-columns-mobile:%d;', $columns_mobile ); } if ( $first_col_span > 1 ) { $grid_style .= sprintf( '--airo-wp-query-first-col-span:%d;', $first_col_span ); } if ( $first_row_span > 1 ) { $grid_style .= sprintf( '--airo-wp-query-first-row-span:%d;', $first_row_span ); } // Honor the query-results child's `style.spacing.blockGap` setting on // the frontend. The grid's CSS reads gap from // `var(--wp--style--block-gap, 1.5rem)`, and the editor gets this // variable for free via useBlockProps. On the server we bypass // get_block_wrapper_attributes() (see comment below), so we translate // the raw attr value to the same custom property ourselves. Accepts // both direct CSS values ("1.5rem", "24px") and the `var:preset|…` // reference syntax WordPress emits for preset spacing sizes. $block_gap = $atts['style']['spacing']['blockGap'] ?? null; if ( is_string( $block_gap ) && '' !== $block_gap ) { if ( 0 === strpos( $block_gap, 'var:preset|' ) ) { $parts = explode( '|', substr( $block_gap, 4 ) ); if ( 3 === count( $parts ) ) { $block_gap = sprintf( 'var(--wp--%s--%s--%s)', sanitize_key( $parts[0] ), sanitize_key( $parts[1] ), sanitize_key( $parts[2] ) ); } } $safe_gap = airowp_safe_css_value( $block_gap ); if ( '' !== $safe_gap ) { $grid_style .= sprintf( '--wp--style--block-gap:%s;', $safe_gap ); } } // Post-restructure: this element IS the query-results grid. IAPI / // context / blobs live on the outer airo-wp-query-region container // emitted by airowp_query_render_container(). The first-paint // wrapper_attrs param is no longer used here — the grid wrapper is // always constructed by this helper since it belongs to query-results, // not to the outer query block. unset( $wrapper_attrs ); $layout_variant = isset( $atts['layoutVariant'] ) ? sanitize_html_class( (string) $atts['layoutVariant'] ) : ''; $variant_class = '' !== $layout_variant ? ' is-layout-' . $layout_variant : ''; $attrs_string = sprintf( 'class="%1$s" style="%3$s" data-airo-wp-query-results-role="container" data-airo-wp-query-id="%2$s"', esc_attr( 'airo-wp-query-results airo-wp-query-results--source-' . $source . $variant_class ), esc_attr( $query_id ), esc_attr( $grid_style ) ); // Blobs + IAPI attrs are now emitted by airowp_query_render_container() // on the outer .airo-wp-query-region wrapper, so this helper just produces // the grid element wrapping the pre-rendered items. return sprintf( '<%1$s %2$s>%3$s', $tag, $attrs_string, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- assembled from esc_attr()-escaped parts. $inner_items ); } /** * Render a single iterated item by parsing innerBlocks and calling * render_block() with per-item context so child blocks and Block Bindings * resolve against the iterated post/user/term. * * IMPORTANT: uses the core `postId` / `postType` context keys (NOT * airo-wp-prefixed) so core blocks (post-title, post-featured-image, * paragraph-with-binding) and our own Block Bindings sources pick up the * iterated item. Users/Terms sources also set airo-wp/currentItemId * and /currentItemType for scenarios where a block needs to distinguish. * * @param string $inner_html Serialized innerBlocks HTML from block content. * @param array $item_context Context keys to override. * @param string $item_tag li / div / article to wrap each item; pass * 'none' to skip the wrapper entirely (used by * non-grid hosts like airo-wp/slider whose * template block — e.g. airo-wp/slide — * already provides its own outer element). * @return string */ function airowp_query_render_item( $inner_html, array $item_context, $item_tag ) { // Ensure BlockVisibility is available when this file is required directly // (e.g. in integration tests) before the plugin bootstrap has run. if ( ! class_exists( '\\airo-wp\\BlockVisibility' ) ) { require_once AIRO_WP_PLUGIN_DIR . 'includes/features/class-block-visibility.php'; } $skip_wrap = ( 'none' === $item_tag ); $tag = in_array( $item_tag, array( 'li', 'div', 'article' ), true ) ? $item_tag : 'li'; $html = ''; $parsed = parse_blocks( $inner_html ); // Push this item's context onto the global parent stack so that nested // Query blocks (and Task C2's `scope` arg on bindings) can walk the // ancestor chain. The stack is keyed by depth, so parallel items from // different queries never collide — each item fully completes before the // next begins (PHP is single-threaded, no async interleaving). if ( ! isset( $GLOBALS['airowp_parent_stack'] ) || ! is_array( $GLOBALS['airowp_parent_stack'] ) ) { $GLOBALS['airowp_parent_stack'] = array(); } array_push( $GLOBALS['airowp_parent_stack'], $item_context ); try { foreach ( $parsed as $parsed_block ) { if ( empty( $parsed_block['blockName'] ) ) { continue; } // Skip blocks whose dsgoVisibility rules don't match the current item context. $visibility = isset( $parsed_block['attrs']['dsgoVisibility'] ) ? $parsed_block['attrs']['dsgoVisibility'] : null; if ( ! \airo-wp\BlockVisibility::matches( $visibility, $item_context ) ) { continue; } // WP_Block's constructor signature is ( $block, $available_context, $registry ). // The $available_context arg is what gets filtered through child blocks' // usesContext declarations. Passing it via render_block()'s parsed-block // 'context' key would NOT work — that key is not read by WP_Block. $block_instance = new WP_Block( $parsed_block, $item_context ); $html .= $block_instance->render(); } } finally { // Always pop — even if render() throws — so the stack stays consistent // for any outer Query block that is still iterating. array_pop( $GLOBALS['airowp_parent_stack'] ); if ( empty( $GLOBALS['airowp_parent_stack'] ) ) { unset( $GLOBALS['airowp_parent_stack'] ); } } if ( $skip_wrap ) { return $html; } return sprintf( '<%1$s class="airo-wp-query__item">%2$s', $tag, $html ); } /** * Sanitize a single raw $_GET value the way * airowp_query_extract_params_from_request() always has: array * values map sanitize_text_field() over each entry, scalars coerce to * string first. * * @param mixed $value Raw, unslashed-pending value from $_GET. * @return string|string[] */ function airowp_query_sanitize_param_value( $value ) { if ( is_array( $value ) ) { return array_map( 'sanitize_text_field', wp_unslash( $value ) ); } return sanitize_text_field( wp_unslash( (string) $value ) ); } /** * Whitelisted URL params that influence query/filter output: `q`, `sort`, * any `filter_` / `query_type_` key, and WooCommerce's * filter-block params. Extensible via the `airowp_query_url_params` * filter. * * Keys are resolved per Query by airowp_query_owned_request_params() * (param-scoping.php): `{key}__{queryId}` wins over a bare `{key}`, a key * scoped for another Query is never read, and a bare key still reaches * every Query (menu links, bookmarks, WooCommerce's filter blocks, which * can't know a queryId). * * @param string $query_id Sanitized queryId this extraction is for. Empty * reads bare keys only. * @return array */ function airowp_query_extract_params_from_request( $query_id = '' ) { // `filter_*` is accepted wildcard-style below. The rest are WooCommerce's // own filter-block query vars, read from its source: min_price/max_price // (ProductFilterPrice), rating_filter (RatingFilter), and query_type_ // which pairs with filter_ to switch IN vs AND. $allowed = apply_filters( 'airowp_query_url_params', array( 'q', 'sort', 'min_price', 'max_price', 'rating_filter' ) ); $params = array(); if ( empty( $_GET ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return $params; } foreach ( airowp_query_owned_request_params( sanitize_key( (string) $query_id ) ) as $key => $entry ) { $key = (string) $key; if ( ! in_array( $key, $allowed, true ) && 0 !== strpos( $key, 'filter_' ) && 0 !== strpos( $key, 'query_type_' ) ) { continue; } $value = airowp_query_sanitize_param_value( $entry['value'] ); // An empty scoped value means "this Query clears the bare one". if ( '' === $value || array() === array_filter( (array) $value, 'strlen' ) ) { continue; } $params[ $key ] = is_array( $value ) ? array_values( array_filter( $value, 'strlen' ) ) : $value; } return $params; } /** * Store the last-render state for a Query ID so sibling blocks (pagination, * no-results) rendering later in the page can reference it without * re-running the query. * * Not a persistent cache — lives only for the current request. * * @param string $query_id Unique query identifier. * @param array $state State data: totalItems, totalPages, page. */ function airowp_query_set_last_state( $query_id, array $state ) { if ( ! isset( $GLOBALS['airowp_query_states'] ) || ! is_array( $GLOBALS['airowp_query_states'] ) ) { $GLOBALS['airowp_query_states'] = array(); } $GLOBALS['airowp_query_states'][ (string) $query_id ] = $state; } /** * The Interactivity context a Query region carries for view.js. * * A random-order Query also carries the seed its first paint used, so Load * more continues the same shuffle instead of repeating and skipping posts. * * @param string $query_id Unique query identifier. * @param string $source Query source. * @param int $page Current page number. * @return array */ function airowp_query_region_context( $query_id, $source, $page ) { $context = array( 'queryId' => $query_id, 'source' => $source, 'page' => $page, 'busy' => false, 'restUrl' => esc_url_raw( rest_url( 'airo-wp/v1/query/render' ) ), 'nonce' => airowp_query_rest_nonce(), ); $seeds = isset( $GLOBALS['airowp_query_rand_seeds'] ) ? (array) $GLOBALS['airowp_query_rand_seeds'] : array(); if ( ! empty( $seeds[ (string) $query_id ] ) ) { $context['seed'] = (int) $seeds[ (string) $query_id ]; } return $context; } /** * Retrieve the last-render state for a Query ID. * * @param string $query_id Unique query identifier. * @return array|null State array or null if not yet rendered. */ function airowp_query_get_last_state( $query_id ) { $states = isset( $GLOBALS['airowp_query_states'] ) ? (array) $GLOBALS['airowp_query_states'] : array(); return $states[ (string) $query_id ] ?? null; } /** * Render a full query region — list items + sibling blocks — wrapped in * a
    container used as the JS refresh target. * * This is the preferred entry point for both render.php (first-paint) and * the REST controller (filter/sort refresh). Using a single shared helper * ensures both paths produce byte-identical output so the JS can safely * swap the region's innerHTML. * * Sibling block names (pagination, filter, no-results) are NOT rendered * per-item; they are rendered once, AFTER the query runs (so the state * registry is populated and sibling blocks can read totalPages etc.). * * @param array $attributes Block attributes (raw — will be defaulted internally). * @param array $context Keys: * - query_id (string) Unique query identifier. * - page (int) Current page number. * - inner_html (string) Full serialized innerBlocks * (template blocks + sibling * block comment strings). The * helper splits them here. * - params (array) URL filter params. * - refresh_source_post_id (int|null) Post whose * content holds the query * (0 outside post content), * or null to emit no signed * refresh source (editor * preview, tests). * - wrapper_attrs (string|null) Pre-computed * get_block_wrapper_attributes() * string (first-paint only; * null for REST/tests). * @return array { html: string, totalPages: int, totalItems: int } */ function airowp_query_render_region( array $attributes, array $context ) { $context = wp_parse_args( $context, array( 'query_id' => '', 'page' => 1, 'inner_html' => '', 'params' => array(), 'refresh_source_post_id' => null, 'wrapper_attrs' => null, ) ); $query_id = sanitize_key( (string) $context['query_id'] ); $full_inner_html = (string) $context['inner_html']; $parsed_children = function_exists( 'parse_blocks' ) ? parse_blocks( $full_inner_html ) : array(); // The container below builds its own render context, so a Load more // request's shuffle seed is handed to the posts query by Query ID. if ( ! empty( $context['rand_seed'] ) ) { $GLOBALS['airowp_query_requested_rand_seeds'][ $query_id ] = absint( $context['rand_seed'] ); } // Delegate to the shared container renderer. The REST path has no outer // block wrapper (no $block instance), so we emit a simple class-only // wrapper here; airowp_query_render_container() appends IAPI attrs. $html = airowp_query_render_container( $attributes, $parsed_children, (int) $context['page'], $query_id, 'class="airo-wp-query airo-wp-query-region airo-wp-query--source-' . sanitize_key( (string) ( $attributes['source'] ?? 'posts' ) ) . '"', array(), null === $context['refresh_source_post_id'] ? null : absint( $context['refresh_source_post_id'] ) ); // totalPages/totalItems come from the state registry populated during // the render above. $state = airowp_query_get_last_state( $query_id ); return array( 'html' => $html, 'totalPages' => isset( $state['totalPages'] ) ? (int) $state['totalPages'] : 0, 'totalItems' => isset( $state['totalItems'] ) ? (int) $state['totalItems'] : 0, ); } endif; if ( ! function_exists( 'airowp_query_render_container' ) ) : /** * Render the outer Dynamic Query container for first-paint. * * Post-restructure (v2.6): the outer airo-wp/query block is a pure * container — it doesn't emit a list wrapper or run items inline. This * helper walks its parsed innerBlocks, runs a single WP_Query when a * airo-wp/query-results child is present, stashes the items HTML * for the child's render.php to pick up, and then manually renders each * child in tree order so filters/pagination/no-results appear exactly * where the author placed them. * * @param array $attributes Raw block attributes. * @param array $parsed_children parse_blocks() entries of the block's innerBlocks. * @param int $page Current pagination page. * @param string $query_id Sanitized queryId. * @param string $wrapper_attrs Pre-computed get_block_wrapper_attributes() * string for the outer element. IAPI attrs * (data-wp-interactive, data-wp-context, * data-airo-wp-query-id) are appended here. * @param array $base_context WP_Block context inherited from the outer * render path (passed to each child render). * @param int|null $refresh_source_post_id Post whose content holds the query * (0 outside post content) for the signed * refresh source, or null to emit none. * @return string Full HTML for the outer element, including children. */ function airowp_query_render_container( array $attributes, array $parsed_children, $page, $query_id, $wrapper_attrs, array $base_context = array(), $refresh_source_post_id = null ) { $attributes = airowp_query_defaults( $attributes ); $source = sanitize_key( (string) ( $attributes['source'] ?? 'posts' ) ); // Find the item host child — any block registered as a query item host // (airo-wp/query-results by default; slider/scroll-slides once they // opt in). Only the first match is used; multiple hosts aren't supported. // Its attrs govern presentation (columns, tagName, groupBy...) and its // innerBlocks form the per-item template. $host_block_names = airowp_query_item_host_block_names(); $results_child = null; $results_child_index = null; foreach ( $parsed_children as $child_index => $child ) { if ( in_array( ( $child['blockName'] ?? '' ), $host_block_names, true ) ) { $results_child = $child; $results_child_index = (int) $child_index; break; } } $total_items = 0; // Two shapes are accepted: // 1. First-paint path — children contain a wrapper. // Use its attrs for presentation + its innerBlocks for the template. // 2. Editor-preview / legacy path — children ARE the item template // directly (no query-results wrapper). Use parent attrs for // presentation and treat all children as template blocks. $effective_attrs = $attributes; $template_blocks = array(); $host_name = ''; if ( $results_child ) { $host_name = (string) ( $results_child['blockName'] ?? '' ); $is_grid_host = ( 'airo-wp/query-results' === $host_name ); $results_attrs = is_array( $results_child['attrs'] ?? null ) ? $results_child['attrs'] : array(); $effective_attrs = array_merge( $attributes, array( 'tagName' => $results_attrs['tagName'] ?? ( $attributes['tagName'] ?? 'ul' ), // Non-grid hosts (slider, scroll-slides) wrap items with // their own outer element (slide, scroll-slide). Suppress // render_item()'s
  3. wrapper so we don't double-wrap. 'itemTagName' => $is_grid_host ? ( $results_attrs['itemTagName'] ?? ( $attributes['itemTagName'] ?? 'li' ) ) : 'none', 'columns' => $results_attrs['columns'] ?? ( $attributes['columns'] ?? 1 ), 'columnsTablet' => $results_attrs['columnsTablet'] ?? ( $attributes['columnsTablet'] ?? 0 ), 'columnsMobile' => $results_attrs['columnsMobile'] ?? ( $attributes['columnsMobile'] ?? 0 ), 'firstItemColumnSpan' => $results_attrs['firstItemColumnSpan'] ?? ( $attributes['firstItemColumnSpan'] ?? 1 ), 'firstItemRowSpan' => $results_attrs['firstItemRowSpan'] ?? ( $attributes['firstItemRowSpan'] ?? 1 ), 'groupBy' => $results_attrs['groupBy'] ?? ( $attributes['groupBy'] ?? null ), 'layoutVariant' => $results_attrs['layoutVariant'] ?? ( $attributes['layoutVariant'] ?? '' ), // Carry the child's `style` attribute forward so // airowp_query_wrap() can translate // `style.spacing.blockGap` into `--wp--style--block-gap` // on the grid wrapper. Without this pass-through the // editor respects block-gap (via useBlockProps) but the // frontend falls back to the 1.5rem default because the // grid is constructed without get_block_wrapper_attributes. 'style' => $results_attrs['style'] ?? ( $attributes['style'] ?? null ), ) ); $inner_children = (array) ( $results_child['innerBlocks'] ?? array() ); // Grid host: all innerBlocks collectively form the per-item // template (post-title + featured-image + paragraph, etc.). // Non-grid host: the host wraps exactly one template block // (one slide, one scroll-slide). Ignore any extra authored // siblings server-side — the editor enforces single-child via // allowedBlocks + prune effect; this is a safety net. $template_blocks = $is_grid_host ? $inner_children : array_slice( $inner_children, 0, 1 ); } else { // No query-results wrapper — fall through with parent-only attrs // and treat every non-empty child as a template block. foreach ( $parsed_children as $pc ) { if ( ! empty( $pc['blockName'] ) ) { $template_blocks[] = $pc; } } } if ( '' !== $query_id ) { // Record the resolved host so sibling blocks rendered later in the // tree can adapt to the presentation — airo-wp/query-pagination // reads it to decide whether infinite scroll is viable in a carousel. airowp_query_set_item_host( $query_id, $host_name ); $template_html = ''; foreach ( $template_blocks as $tb ) { if ( function_exists( 'serialize_block' ) ) { $template_html .= serialize_block( $tb ); } } $render_context = array( 'query_id' => $query_id, 'page' => (int) $page, 'inner_html' => $template_html, 'full_inner_html' => $template_html, 'params' => airowp_query_extract_params_from_request( $query_id ), 'wrapper_attrs' => null, ); $result = airowp_query_render( $effective_attrs, $render_context ); $total_items = (int) ( $result['totalItems'] ?? 0 ); // Stash the rendered items HTML so query-results/render.php can // echo it when WordPress walks the tree below. Keyed by queryId so // nested queries can't clash. Two stashes: // - airowp_query_results_html: the fully wrapped output // (grid
      + schema). Consumed by query-results/render.php. // - airowp_query_items_html: raw per-item HTML, no outer // wrap. Consumed by non-grid item hosts (slider, scroll-slides) // that supply their own chrome around the iterated items. if ( ! isset( $GLOBALS['airowp_query_results_html'] ) || ! is_array( $GLOBALS['airowp_query_results_html'] ) ) { $GLOBALS['airowp_query_results_html'] = array(); } $GLOBALS['airowp_query_results_html'][ $query_id ] = (string) $result['html']; if ( ! isset( $GLOBALS['airowp_query_items_html'] ) || ! is_array( $GLOBALS['airowp_query_items_html'] ) ) { $GLOBALS['airowp_query_items_html'] = array(); } $GLOBALS['airowp_query_items_html'][ $query_id ] = isset( $result['items_html'] ) ? (string) $result['items_html'] : ''; // Legacy path (no query-results wrapper): since no child block will // emit the items, emit them directly here so the editor preview // REST call still returns a usable region. if ( ! $results_child ) { $children_html_direct = (string) $result['html']; $blobs = airowp_query_render_blobs( $query_id, $attributes, $parsed_children, $refresh_source_post_id ); $status = airowp_query_status_html( $query_id, $total_items ); $wp_context_legacy = wp_json_encode( airowp_query_region_context( $query_id, $source, (int) $page ), JSON_HEX_APOS ); $iapi_attrs_legacy = sprintf( 'data-airo-wp-query-id="%1$s" data-airo-wp-query-region="%1$s" data-wp-interactive="%2$s" data-wp-context=\'%3$s\'', esc_attr( $query_id ), esc_attr( 'airo-wp/query' ), $wp_context_legacy // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON_HEX_APOS output is safe in single-quoted attr. ); $merged_legacy = trim( (string) $wrapper_attrs . ' ' . $iapi_attrs_legacy ); return sprintf( '
      %2$s%3$s%4$s
      ', $merged_legacy, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wrapper + esc_attr IAPI attrs. $blobs, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- airowp_query_render_blobs escapes internally. $status, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr-assembled. $children_html_direct // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- airowp_query_wrap + render_item output. ); } } // Shared context passed to every child block. Must include everything the // outer block provides via `providesContext` in block.json. $shared_context = array_merge( $base_context, array( 'airo-wp/queryId' => $query_id, 'airo-wp/querySource' => $source, 'airo-wp/queryPostType' => (string) ( $attributes['postType'] ?? 'post' ), // Lets a sibling narrow itself to what this query can actually // return — query-filter uses it so it never offers a term the // taxQuery has already excluded. 'airo-wp/queryTaxQuery' => isset( $attributes['taxQuery'] ) && is_array( $attributes['taxQuery'] ) ? $attributes['taxQuery'] : array(), ) ); // Render each child in tree order. The query-results child's render.php // reads $GLOBALS['airowp_query_results_html'][queryId] and echoes // the pre-rendered items HTML. $children_html = ''; foreach ( $parsed_children as $child_index => $child ) { if ( empty( $child['blockName'] ) ) { continue; } $is_item_host_child = in_array( ( $child['blockName'] ?? '' ), $host_block_names, true ); if ( $is_item_host_child && null !== $results_child_index && (int) $child_index !== $results_child_index ) { continue; } if ( class_exists( 'WP_Block' ) ) { $children_html .= ( new WP_Block( $child, $shared_context ) )->render(); if ( $is_item_host_child && null !== $results_child_index && (int) $child_index === $results_child_index && '' !== $query_id ) { unset( $GLOBALS['airowp_query_results_html'][ $query_id ] ); unset( $GLOBALS['airowp_query_items_html'][ $query_id ] ); } } } // Clean up the stash so it doesn't leak to unrelated queries later in // the request (e.g. multiple Dynamic Query blocks on one page). if ( '' !== $query_id ) { unset( $GLOBALS['airowp_query_results_html'][ $query_id ] ); unset( $GLOBALS['airowp_query_items_html'][ $query_id ] ); } // IAPI + state-announcement markup. Appended to the block wrapper so // view.js has a single refresh target. $wp_context = wp_json_encode( airowp_query_region_context( $query_id, $source, (int) $page ), JSON_HEX_APOS ); // No aria-live on the region: a live region that wraps the list, filters // and pagination makes screen readers read all of it on every swap. The // one polite status element below announces the result count instead. $iapi_attrs = sprintf( 'data-airo-wp-query-id="%1$s" data-airo-wp-query-region="%1$s" data-wp-interactive="%2$s" data-wp-context=\'%3$s\'', esc_attr( $query_id ), esc_attr( 'airo-wp/query' ), $wp_context // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON_HEX_APOS output is safe in single-quoted attr. ); $merged_wrapper = trim( (string) $wrapper_attrs . ' ' . $iapi_attrs ); // Signed definition the public refresh route renders from. $blobs = airowp_query_render_blobs( $query_id, $attributes, $parsed_children, $refresh_source_post_id ); $status = airowp_query_status_html( $query_id, $total_items ); return sprintf( '
      %2$s%3$s%4$s
      ', $merged_wrapper, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wrapper from get_block_wrapper_attributes + esc_attr-assembled IAPI attrs. $blobs, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- airowp_query_render_blobs escapes internally. $status, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr-assembled; empty text content. $children_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WP_Block->render() output (WP escapes/KSES server-side). ); } endif; if ( ! function_exists( 'airowp_query_render_blobs' ) ) : /** * Build the hidden, signed refresh source embedded in the query region. * * The view script sends it back verbatim on filter / sort / Load more, and * the public REST route resolves the server-held definition only after * signature and viewer verification — see airo-wp\Blocks\Query\RefreshSource. * * @param string $query_id Sanitized queryId. * @param array $attributes Query attributes (already defaulted). * @param array $parsed_children parse_blocks() entries of the inner blocks. * @param int|null $source_post_id Post whose content holds the query (0 * outside post content), or null for none. * @return string HTML for the blobs div, or empty string when not emitted. */ function airowp_query_render_blobs( $query_id, array $attributes, array $parsed_children, $source_post_id ) { if ( '' === $query_id || null === $source_post_id || ! class_exists( 'airo-wp\\Blocks\\Query\\RefreshSource' ) ) { return ''; } $inner_blocks = ''; foreach ( $parsed_children as $child ) { if ( ! empty( $child['blockName'] ) ) { $inner_blocks .= serialize_block( $child ); } } // Record only the facts this query reads. A fact that varies per page // (the current post, an archive's term) would otherwise give every page // its own stored definition for queries that never look at it. $page_context = airowp_query_request_context(); if ( empty( $attributes['excludeCurrent'] ) ) { $page_context['currentPostId'] = 0; } if ( 'current' !== ( $attributes['source'] ?? 'posts' ) ) { $page_context['inheritedVars'] = array(); } $signed = \airo-wp\Blocks\Query\RefreshSource::sign( $query_id, $attributes, $inner_blocks, (int) $source_post_id, $page_context ); return ''; } endif; if ( ! function_exists( 'airowp_query_paginate_totals' ) ) : /** * Item and page totals once the block's fixed offset is taken out. * * WP_Query / WP_User_Query / get_terms report every match, including the * rows the offset skips, so their totals overcount: pagination offered * empty last pages, and an offset at or past the total never reached "no * results". Pages are walked at offset + (page - 1) * perPage, so the * skipped rows are never reachable and must not be counted. * * @param int $found Matches reported by the query, offset ignored. * @param int $offset The block's offset attribute. * @param int $per_page Items per page; 0 or less means one unpaged page. * @return array{totalItems: int, totalPages: int} */ function airowp_query_paginate_totals( $found, $offset, $per_page ) { $total = max( 0, (int) $found - max( 0, (int) $offset ) ); if ( $per_page < 1 ) { $pages = $total > 0 ? 1 : 0; } else { $pages = (int) ceil( $total / $per_page ); } return array( 'totalItems' => $total, 'totalPages' => $pages, ); } endif; if ( ! function_exists( 'airowp_query_inheritable_vars' ) ) : /** * The query vars the Current source may inherit from an archive. * * @return string[] */ function airowp_query_inheritable_vars() { $vars = array( 'post_type', 'category_name', 'cat', 'tag', 'tag_id', 'author_name', 'author', 'year', 'monthnum', 'day', 's', 'taxonomy', 'term' ); foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $taxonomy ) { if ( is_string( $taxonomy->query_var ) && '' !== $taxonomy->query_var ) { $vars[] = $taxonomy->query_var; } } return array_values( array_unique( $vars ) ); } endif; if ( ! function_exists( 'airowp_query_sanitize_request_context' ) ) : /** * Reduce a request context to the allowlisted, non-empty scalar facts. * * Empty values are dropped: WP_Query fills every query var with '' / 0 / * array(), and inheriting those would blank out the block's own settings. * * @param array $context Raw context. * @return array{currentPostId: int, inheritedVars: array} */ function airowp_query_sanitize_request_context( array $context ) { $raw = isset( $context['inheritedVars'] ) && is_array( $context['inheritedVars'] ) ? $context['inheritedVars'] : array(); $inherited = array(); foreach ( airowp_query_inheritable_vars() as $name ) { if ( ! isset( $raw[ $name ] ) ) { continue; } $value = $raw[ $name ]; if ( is_array( $value ) ) { $value = array_values( array_filter( $value, 'is_scalar' ) ); if ( array() === $value ) { continue; } } elseif ( ! is_scalar( $value ) || '' === (string) $value || '0' === (string) $value ) { continue; } $inherited[ $name ] = $value; } return array( 'currentPostId' => isset( $context['currentPostId'] ) ? absint( $context['currentPostId'] ) : 0, 'inheritedVars' => $inherited, ); } endif; if ( ! function_exists( 'airowp_query_request_context' ) ) : /** * Page facts a query reads from the request, as a plain array. * * "Exclude current post" and the Current source (archive inheritance) read * the page's main query. A REST refresh has no such page, so first paint * records these facts in the signed definition and the refresh route * replays them through $GLOBALS['airowp_query_request_context']. * * @return array{currentPostId: int, inheritedVars: array} */ function airowp_query_request_context() { if ( isset( $GLOBALS['airowp_query_request_context'] ) && is_array( $GLOBALS['airowp_query_request_context'] ) ) { return airowp_query_sanitize_request_context( $GLOBALS['airowp_query_request_context'] ); } $vars = array(); if ( isset( $GLOBALS['wp_query'] ) && $GLOBALS['wp_query'] instanceof WP_Query && is_array( $GLOBALS['wp_query']->query_vars ) ) { $vars = $GLOBALS['wp_query']->query_vars; } return airowp_query_sanitize_request_context( array( 'currentPostId' => is_singular() ? (int) get_queried_object_id() : 0, 'inheritedVars' => $vars, ) ); } endif; if ( ! function_exists( 'airowp_query_rest_nonce' ) ) : /** * REST nonce for the refresh request — logged-in users only. * * A cached page outlives its nonce (12–24h), and core rejects a stale * X-WP-Nonce with rest_cookie_invalid_nonce before the route's own * permission check runs. The public route needs no nonce, so visitors get * none; logged-in users need one so the route can see who they are. * * @return string Nonce, or empty string for visitors. */ function airowp_query_rest_nonce() { return is_user_logged_in() ? wp_create_nonce( 'wp_rest' ) : ''; } endif; if ( ! function_exists( 'airowp_query_merge_inline_style' ) ) : /** * Merges extra inline CSS declarations into an existing pre-serialized * HTML attribute string. If a `style="..."` attribute already exists * (as emitted by `get_block_wrapper_attributes()` when the user has * applied padding/margin/etc), the new declarations are appended inside * it; otherwise a new `style="..."` attribute is added to the string. * * Values passed in `$extra_style` must already be safe for an HTML * attribute context — this helper only concatenates, it does not * sanitize. * * @param string $wrapper_attrs Existing attributes string from * `get_block_wrapper_attributes()`. * @param string $extra_style Raw CSS declarations to append, terminated * with a trailing `;`. * @return string The merged attributes string. */ function airowp_query_merge_inline_style( $wrapper_attrs, $extra_style ) { $extra_style = trim( (string) $extra_style ); if ( '' === $extra_style ) { return $wrapper_attrs; } if ( preg_match( '/\bstyle\s*=\s*"([^"]*)"/i', $wrapper_attrs, $m ) ) { $existing = rtrim( $m[1], '; ' ); $merged = ( '' === $existing ? '' : $existing . ';' ) . $extra_style; return str_replace( $m[0], 'style="' . esc_attr( $merged ) . '"', $wrapper_attrs ); } return $wrapper_attrs . ' style="' . esc_attr( $extra_style ) . '"'; } endif;