$relation ), $children );
}
// Leaf clause.
if ( empty( $entry['taxonomy'] ) || empty( $entry['terms'] ) ) {
return null;
}
$operator = $entry['operator'] ?? 'IN';
if ( ! in_array( $operator, array( 'IN', 'NOT IN', 'AND' ), true ) ) {
$operator = 'IN';
}
return array(
'taxonomy' => sanitize_key( (string) $entry['taxonomy'] ),
'terms' => array_map( 'absint', (array) $entry['terms'] ),
'operator' => $operator,
'include_children' => isset( $entry['include_children'] ) ? (bool) $entry['include_children'] : true,
);
}
endif;
if ( ! function_exists( 'airowp_build_meta_query_entry' ) ) :
/**
* Recursively builds a WP_Query meta_query clause or nested group.
*
* @param array $entry Clause or group from block attributes.
* @return array|null WP_Query meta_query entry, or null if invalid.
*/
function airowp_build_meta_query_entry( array $entry ) {
if ( isset( $entry['clauses'] ) ) {
// Nested group.
$relation = ( 'OR' === ( $entry['relation'] ?? 'AND' ) ) ? 'OR' : 'AND';
$children = array();
foreach ( (array) $entry['clauses'] as $child ) {
$built = airowp_build_meta_query_entry( $child );
if ( null !== $built ) {
$children[] = $built;
}
}
if ( empty( $children ) ) {
return null;
}
// Unwrap single-child groups to avoid WP_Meta_Query's
// `relation`-with-one-child _doing_it_wrong notice.
if ( 1 === count( $children ) ) {
return $children[0];
}
return array_merge( array( 'relation' => $relation ), $children );
}
// Leaf clause.
if ( empty( $entry['key'] ) ) {
return null;
}
$valid_compare = array( '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'EXISTS', 'NOT EXISTS' );
$valid_type = array( 'CHAR', 'NUMERIC', 'DATE' );
$compare = $entry['compare'] ?? '=';
if ( ! in_array( $compare, $valid_compare, true ) ) {
$compare = '=';
}
$type = $entry['type'] ?? 'CHAR';
if ( ! in_array( $type, $valid_type, true ) ) {
$type = 'CHAR';
}
$result = array(
'key' => sanitize_text_field( (string) $entry['key'] ),
'compare' => $compare,
'type' => $type,
);
if ( ! in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$result['value'] = sanitize_text_field( (string) ( $entry['value'] ?? '' ) );
}
return $result;
}
endif;
if ( ! function_exists( 'airowp_query_render_posts' ) ) :
/**
* Render a Dynamic Query block for the Posts (or Manual/Current) source.
*
* Builds WP_Query args, applies filter hooks, iterates posts, and delegates
* item rendering to airowp_query_render_item() with per-item context
* using core `postId` / `postType` keys so core blocks and Block Bindings
* resolve against the iterated post, not the outer page's post.
*
* @param array $atts Block attributes (already defaulted).
* @param array $context Render context (query_id, page, inner_html, params).
* @return array { html: string, totalPages: int, totalItems: int }
*/
function airowp_query_render_posts( array $atts, array $context ) {
global $post;
$saved_post = $post;
$args = airowp_query_build_posts_args( $atts, $context );
/**
* Filter the WP_Query args for a airo-wp Dynamic Query (all queries).
*
* @param array $args WP_Query args.
* @param array $atts Block attributes (already defaulted).
* @param array $context Render context.
*/
$args = apply_filters( 'airowp_query_args', $args, $atts, $context );
$query_id = isset( $context['query_id'] ) ? (string) $context['query_id'] : '';
if ( '' !== $query_id ) {
/**
* Filter WP_Query args for a specific Query ID.
*
* Fired only when queryId matches — useful for "related posts"
* recipes without polluting unrelated queries.
*
* @param array $args WP_Query args.
* @param array $atts Block attributes.
* @param array $context Render context.
*/
$args = apply_filters( 'airowp_query_' . $query_id . '_args', $args, $atts, $context );
}
// Capture render telemetry only when Query Monitor is loaded — otherwise
// the action exposes raw SQL to any third-party callback on every public
// page render, which is data we don't want to broadcast.
if ( defined( 'QM_VERSION' ) ) {
$t_start = microtime( true );
$query = new WP_Query( $args );
$duration_ms = ( microtime( true ) - $t_start ) * 1000;
do_action(
'airowp_query_did_render',
array(
'query_id' => $atts['queryId'] ?? '',
'source' => $atts['source'] ?? 'posts',
'wp_args' => $args,
'found_posts' => $query->found_posts,
// $query->request is the actual posts SELECT WP_Query just
// ran; $wpdb->last_query at this point would be the
// trailing FOUND_ROWS()/COUNT instead.
'sql' => (string) ( $query->request ?? '' ),
'filters' => array(),
'duration_ms' => round( $duration_ms, 2 ),
)
);
} else {
$query = new WP_Query( $args );
}
// Determine whether grouped rendering is requested.
// Parse inner_html to split group-header blocks from the item template.
$group_header_blocks = array();
$item_template_html = (string) $context['inner_html'];
if ( ! empty( $atts['groupBy'] ) && is_array( $atts['groupBy'] ) ) {
$parsed_inner = parse_blocks( $item_template_html );
$template_parts = array();
foreach ( $parsed_inner as $pb ) {
if ( empty( $pb['blockName'] ) ) {
continue;
}
if ( 'airo-wp/query-group-header' === $pb['blockName'] ) {
$group_header_blocks[] = $pb;
} else {
$template_parts[] = serialize_block( $pb );
}
}
if ( ! empty( $group_header_blocks ) ) {
$item_template_html = implode( '', $template_parts );
}
}
$use_groups = ! empty( $atts['groupBy'] ) && ! empty( $group_header_blocks );
$items_html = '';
$post_urls = array();
$collected_ids = array();
$collected_indexes = array();
$flat_counter = 0;
try {
while ( $query->have_posts() ) {
$query->the_post();
$post_id = get_the_ID();
$post_urls[] = get_permalink();
if ( $use_groups ) {
$collected_ids[] = $post_id;
if ( ! isset( $collected_indexes[ $post_id ] ) ) {
$collected_indexes[ $post_id ] = $flat_counter;
}
} else {
$items_html .= airowp_query_render_item(
$item_template_html,
array(
'postId' => $post_id,
'postType' => get_post_type(),
'index' => $flat_counter,
'airo-wp/itemIndex' => $flat_counter,
),
$atts['itemTagName']
);
}
++$flat_counter;
}
} finally {
wp_reset_postdata();
$post = $saved_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
// If grouped, partition collected IDs and render per group.
if ( $use_groups && ! empty( $collected_ids ) ) {
require_once __DIR__ . '/render-helpers.php';
$groups = airowp_query_partition_items( $collected_ids, $atts['groupBy'] );
try {
foreach ( $groups as $group ) {
// Render group header block(s) with group context.
$header_html_out = '';
foreach ( $group_header_blocks as $hb ) {
$header_block = new WP_Block(
$hb,
array(
'airo-wp/queryId' => sanitize_key( (string) ( $context['query_id'] ?? '' ) ),
'airo-wp/groupLabel' => (string) $group['label'],
'airo-wp/groupValue' => (string) $group['value'],
)
);
$header_html_out .= $header_block->render();
}
// Render items in this group.
// Pass both the flat cross-group index (airo-wp/itemIndex) and a
// per-group zero-based index (airo-wp/groupItemIndex) so authors
// can target either in custom bindings or visibility rules.
$group_items_html = '';
$group_item_index = 0;
foreach ( $group['ids'] as $gid ) {
$post_obj = get_post( $gid );
if ( ! $post_obj ) {
continue;
}
// Core blocks in the item template read the global post, so it
// must be this item, not the page the loop sits on.
// setup_postdata() fills the other globals but not $post.
$post = $post_obj; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
setup_postdata( $post );
$item_index = isset( $collected_indexes[ $gid ] ) ? (int) $collected_indexes[ $gid ] : 0;
$group_items_html .= airowp_query_render_item(
$item_template_html,
array(
'postId' => (int) $gid,
'postType' => $post_obj->post_type,
'index' => $item_index,
'airo-wp/itemIndex' => $item_index,
'airo-wp/groupItemIndex' => $group_item_index,
),
$atts['itemTagName']
);
++$group_item_index;
}
$items_html .= sprintf(
'',
esc_attr( (string) $group['value'] ),
$header_html_out, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- block render() output.
$group_items_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- assembled from airowp_query_render_item().
);
}
} finally {
wp_reset_postdata();
$post = $saved_post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
}
}
$totals = airowp_query_paginate_totals(
$query->found_posts,
(int) $atts['offset'],
(int) ( $args['posts_per_page'] ?? 0 )
);
$state = array(
'totalItems' => $totals['totalItems'],
'totalPages' => $totals['totalPages'],
'page' => max( 1, (int) $context['page'] ),
);
airowp_query_set_last_state( $query_id, $state );
// JSON-LD ItemList schema for Posts-source queries.
$emit_schema = ! isset( $atts['emitSchema'] ) || (bool) $atts['emitSchema'];
$schema_html = '';
if ( $emit_schema && 'posts' === $atts['source'] && ! empty( $post_urls ) ) {
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'ItemList',
'itemListElement' => array_map(
function ( $i, $url ) {
return array(
'@type' => 'ListItem',
'position' => $i + 1,
'url' => $url,
);
},
array_keys( $post_urls ),
$post_urls
),
);
// JSON_HEX_TAG defends against a stray `` inside a URL breaking
// out of the script context; JSON_HEX_AMP keeps the payload entity-safe.
$schema_html = '';
}
return array(
'html' => airowp_query_wrap( $items_html, $atts, $context, $context['wrapper_attrs'] ?? null ) . $schema_html,
'items_html' => $items_html,
'totalPages' => $state['totalPages'],
'totalItems' => $state['totalItems'],
);
}
/**
* Build WP_Query args from block attributes and render context.
*
* Handles: pagination, ordering, search, author, excludeCurrent,
* tax_query, meta_query, manual IDs (post__in), and current-archive
* var inheritance.
*
* @param array $atts Block attributes (already defaulted).
* @param array $context Render context.
* @return array WP_Query args.
*/
function airowp_query_build_posts_args( array $atts, array $context ) {
$per_page = max( 1, (int) $atts['perPage'] );
$page = max( 1, (int) $context['page'] );
$post_type = 'manual' === $atts['source'] ? 'any' : sanitize_key( (string) $atts['postType'] );
$args = array(
'post_type' => $post_type,
'posts_per_page' => $per_page,
'offset' => max( 0, (int) $atts['offset'] ) + ( ( $page - 1 ) * $per_page ),
'orderby' => sanitize_key( (string) $atts['orderBy'] ),
'order' => 'ASC' === strtoupper( (string) $atts['order'] ) ? 'ASC' : 'DESC',
'ignore_sticky_posts' => (bool) $atts['ignoreSticky'],
'post_status' => 'publish',
);
if ( in_array( $atts['orderBy'], array( 'meta_value', 'meta_value_num' ), true ) && ! empty( $atts['orderByMetaKey'] ) ) {
$args['meta_key'] = sanitize_text_field( (string) $atts['orderByMetaKey'] ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
}
// Search — attribute-bound OR URL-bound via bindSearchTo.
$search = (string) $atts['search'];
if ( ! empty( $atts['bindSearchTo'] ) && isset( $context['params'][ $atts['bindSearchTo'] ] ) ) {
$bound = $context['params'][ $atts['bindSearchTo'] ];
$search = is_scalar( $bound ) ? (string) $bound : '';
}
if ( '' !== $search ) {
$args['s'] = $search;
}
if ( ! empty( $atts['author'] ) && is_array( $atts['author'] ) ) {
$args['author__in'] = array_map( 'absint', $atts['author'] );
}
// The page's facts come from the request on first paint and from the
// signed definition on a REST refresh — see airowp_query_request_context().
$page_context = airowp_query_request_context();
if ( ! empty( $atts['excludeCurrent'] ) && $page_context['currentPostId'] ) {
// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- Bounded to the single current post when "exclude current" is enabled on a singular view.
$args['post__not_in'] = array( $page_context['currentPostId'] );
}
// Tax query (supports nested AND/OR groups).
$tax_clauses = isset( $atts['taxQuery']['clauses'] ) ? (array) $atts['taxQuery']['clauses'] : array();
if ( ! empty( $tax_clauses ) ) {
$tax_query = array(
'relation' => ( 'OR' === ( $atts['taxQuery']['relation'] ?? 'AND' ) ) ? 'OR' : 'AND',
);
foreach ( $tax_clauses as $entry ) {
$built = airowp_build_tax_query_entry( $entry );
if ( null !== $built ) {
$tax_query[] = $built;
}
}
if ( count( $tax_query ) > 1 ) {
$args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
}
}
// Meta query (supports nested AND/OR groups).
$meta_clauses = isset( $atts['metaQuery']['clauses'] ) ? (array) $atts['metaQuery']['clauses'] : array();
if ( ! empty( $meta_clauses ) ) {
$meta_query = array(
'relation' => ( 'OR' === ( $atts['metaQuery']['relation'] ?? 'AND' ) ) ? 'OR' : 'AND',
);
foreach ( $meta_clauses as $entry ) {
$built = airowp_build_meta_query_entry( $entry );
if ( null !== $built ) {
$meta_query[] = $built;
}
}
if ( count( $meta_query ) > 1 ) {
$args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
}
}
// Date query.
$date_clauses = isset( $atts['dateQuery']['clauses'] ) ? (array) $atts['dateQuery']['clauses'] : array();
if ( ! empty( $date_clauses ) ) {
$valid_columns = array( 'post_date', 'post_modified', 'post_date_gmt', 'post_modified_gmt' );
$date_query = array(
'relation' => ( 'OR' === ( $atts['dateQuery']['relation'] ?? 'AND' ) ) ? 'OR' : 'AND',
);
foreach ( $date_clauses as $clause ) {
$mode = $clause['mode'] ?? 'after';
$after = sanitize_text_field( (string) ( $clause['after'] ?? '' ) );
$before = sanitize_text_field( (string) ( $clause['before'] ?? '' ) );
// Skip clauses with no date value.
if ( 'between' === $mode && ( '' === $after || '' === $before ) ) {
continue;
}
if ( 'after' === $mode && '' === $after ) {
continue;
}
if ( 'before' === $mode && '' === $before ) {
continue;
}
$column = $clause['column'] ?? 'post_date';
if ( ! in_array( $column, $valid_columns, true ) ) {
$column = 'post_date';
}
$entry = array(
'column' => $column,
'inclusive' => ! empty( $clause['inclusive'] ),
);
if ( 'after' === $mode || 'between' === $mode ) {
$entry['after'] = $after;
}
if ( 'before' === $mode || 'between' === $mode ) {
$entry['before'] = $before;
}
$date_query[] = $entry;
}
if ( count( $date_query ) > 1 ) {
$args['date_query'] = $date_query;
}
}
// Manual source: override with specific post IDs in user-defined order.
if ( 'manual' === $atts['source'] && ! empty( $atts['manualIds'] ) && is_array( $atts['manualIds'] ) ) {
$ids = array_values( array_filter( array_map( 'absint', $atts['manualIds'] ) ) );
if ( ! empty( $ids ) ) {
$args['post__in'] = $ids;
$args['orderby'] = 'post__in';
$args['post_type'] = 'any';
if ( empty( $atts['manualPaginated'] ) ) {
$args['posts_per_page'] = count( $ids );
}
}
}
// Current source: inherit a narrow subset of query vars from the outer archive.
if ( 'current' === $atts['source'] && ! empty( $page_context['inheritedVars'] ) ) {
$args = array_merge( $args, $page_context['inheritedVars'] );
}
// URL-param-driven filters (Task 14).
// filter_=slug[,slug] or filter_[]=slug style.
// Also handles ?q= directly (overrides static search attr when present).
//
// Query param scoping (v2.6): $context['params'] already resolved a
// query-scoped key (`{key}__{queryId}`) in preference to a same-named
// bare one, and never surfaces a key scoped for a DIFFERENT query —
// see airowp_query_extract_params_from_request(). A bare/legacy
// key below is intentionally NOT gated by whether this query has a
// matching filter block: that was tried and reverted (PR #592) — a
// site owner routinely links to `?filter_category=news` from a menu
// or widget with no filter control anywhere on the landing page, and
// every Query on that page has always honored it. Scoping (not
// declaration) is what stops one query's filter INTERACTION from
// leaking into another's; a bare URL still reaches everyone, exactly
// as before this task.
$params = isset( $context['params'] ) ? (array) $context['params'] : array();
// Direct ?q= support: override search when bindSearchTo is not set or empty.
if ( isset( $params['q'] ) && '' === (string) $atts['bindSearchTo'] ) {
$q_raw = airowp_query_scalar_param( $params['q'] );
$q_val = is_array( $q_raw ) ? implode( ' ', $q_raw ) : $q_raw;
$q_val = sanitize_text_field( $q_val );
if ( '' !== $q_val ) {
$args['s'] = $q_val;
}
}
foreach ( $params as $key => $value ) {
$key = (string) $key;
if ( 0 !== strpos( $key, 'filter_' ) ) {
continue;
}
$taxonomy = substr( $key, strlen( 'filter_' ) );
if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
continue;
}
// `filter_x=a,b` is the documented slug[,slug] form (the filter
// control ticks both); split it like the control does, or the pair
// collapses into one slug "ab".
$terms = array();
foreach ( is_array( $value ) ? $value : array( $value ) as $raw_term ) {
if ( is_scalar( $raw_term ) ) {
$terms = array_merge( $terms, explode( ',', (string) $raw_term ) );
}
}
$terms = array_unique( array_filter( array_map( 'sanitize_title', $terms ) ) );
if ( empty( $terms ) ) {
continue;
}
// If an attribute-level tax_query exists with relation=OR, wrap it so
// the URL-param clause ANDs against the entire OR group.
if ( isset( $args['tax_query'] ) && 'OR' === ( $args['tax_query']['relation'] ?? 'AND' ) ) {
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Re-wrapping an existing tax_query the user explicitly configured; same query, just AND-grouped with the URL-param clause.
$args['tax_query'] = array(
'relation' => 'AND',
$args['tax_query'],
);
} elseif ( ! isset( $args['tax_query'] ) ) {
$args['tax_query'] = array( 'relation' => 'AND' ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
}
$args['tax_query'][] = array(
'taxonomy' => $taxonomy,
'field' => 'slug',
'terms' => array_values( $terms ),
'operator' => 'IN',
);
}
// WooCommerce catalog rules + the URL params Woo's own filter blocks emit.
// Applied after the generic taxonomy filters so it can see, and safely
// AND against, whatever they built.
if ( ! function_exists( 'airowp_query_apply_woo_args' ) ) {
$woo_helpers = __DIR__ . '/render-woo.php';
if ( file_exists( $woo_helpers ) ) {
require_once $woo_helpers; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- sibling build file; path resolved from __DIR__
}
}
if ( function_exists( 'airowp_query_apply_woo_args' ) ) {
$args = airowp_query_apply_woo_args( $args, $atts, $params );
}
// URL-param sort override (?sort=orderby.DIR).
if ( isset( $params['sort'] ) && is_string( $params['sort'] ) && '' !== $params['sort'] ) {
$parts = explode( '.', $params['sort'], 2 );
$sort_by = sanitize_key( $parts[0] );
$sort_dir = isset( $parts[1] ) && 'ASC' === strtoupper( $parts[1] ) ? 'ASC' : 'DESC';
$allowed_orderby = array( 'date', 'title', 'menu_order', 'rand', 'comment_count', 'meta_value', 'meta_value_num' );
$is_meta_sort = in_array( $sort_by, array( 'meta_value', 'meta_value_num' ), true );
// Sort options carry no meta key, so a meta sort needs one from the
// block; without it the option can't be honoured and is ignored
// rather than silently falling back to date.
$meta_key = isset( $args['meta_key'] ) ? $args['meta_key'] : sanitize_text_field( (string) $atts['orderByMetaKey'] );
if ( in_array( $sort_by, $allowed_orderby, true ) && ( ! $is_meta_sort || '' !== $meta_key ) ) {
$args['orderby'] = $sort_by;
$args['order'] = $sort_dir;
if ( $is_meta_sort ) {
$args['meta_key'] = $meta_key; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
} else {
// A leftover meta_key would drop every post that lacks it.
unset( $args['meta_key'] );
}
}
}
// Random order re-rolls on every request, so offset pagination would
// repeat and skip posts. Each first paint picks a fresh seed, which the
// region hands back with Load more / infinite scroll so later pages
// continue the same shuffle.
if ( 'rand' === $args['orderby'] ) {
$query_key = (string) ( $context['query_id'] ?? '' );
$requested = isset( $GLOBALS['airowp_query_requested_rand_seeds'] ) ? (array) $GLOBALS['airowp_query_requested_rand_seeds'] : array();
$seed = isset( $context['rand_seed'] ) ? absint( $context['rand_seed'] ) : absint( $requested[ $query_key ] ?? 0 );
if ( $seed < 1 ) {
$seed = wp_rand( 1, 2147483647 );
}
$seed = (int) apply_filters( 'airowp_query_rand_seed', $seed, $atts, $context );
$GLOBALS['airowp_query_rand_seeds'][ $query_key ] = $seed;
$args['airowp_rand_seed'] = $seed;
if ( ! has_filter( 'posts_orderby', 'airowp_query_seeded_rand_orderby' ) ) {
add_filter( 'posts_orderby', 'airowp_query_seeded_rand_orderby', 10, 2 );
}
}
return $args;
}
endif;
if ( ! function_exists( 'airowp_query_seeded_rand_orderby' ) ) :
/**
* Swap RAND() for RAND(seed) on Query loops that asked for a seeded shuffle.
*
* @param string $orderby ORDER BY clause.
* @param \WP_Query $query Query.
* @return string
*/
function airowp_query_seeded_rand_orderby( $orderby, $query ) {
$seed = $query instanceof \WP_Query ? $query->get( 'airowp_rand_seed' ) : '';
if ( '' === $seed || null === $seed || false === strpos( (string) $orderby, 'RAND()' ) ) {
return $orderby;
}
return str_replace( 'RAND()', 'RAND(' . (int) $seed . ')', $orderby );
}
endif;