context['airo-wp/queryId'] )
? sanitize_key( (string) $block->context['airo-wp/queryId'] )
: '';
if ( '' === $query_id ) {
return;
}
// Helpers must be loaded; they live in build/blocks/query/render-helpers.php.
$helpers = AIRO_WP_PLUGIN_DIR . 'build/blocks/query/render-helpers.php';
if ( ! file_exists( $helpers ) ) {
return;
}
require_once $helpers; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable -- build artifact; path resolved from plugin directory
$state = airowp_query_get_last_state( $query_id );
// Determine the effective pagination kind.
// paginationKind takes precedence (supports the 'infinite' variation);
// falls back to legacy 'mode' attribute for backwards compatibility.
$pagination_kind = isset( $attributes['paginationKind'] ) && 'numbered' !== $attributes['paginationKind']
? $attributes['paginationKind']
: ( isset( $attributes['mode'] ) ? $attributes['mode'] : 'numbered' );
// Carousel presentation wins over infinite scroll. Infinite scroll needs
// a sentinel that the reader reaches by scrolling past the items; inside
// a slider or scroll-slides host the items sit in a fixed-height
// viewport, so the sentinel either intersects on first paint (loading
// every page at once) or is never reached. Degrade to the Load more
// button instead of dropping the control — the reader keeps access to
// the rest of the query, and the editor says so up front so the author
// is not surprised by the swap. See airowp_query_host_supports_infinite_scroll().
$degraded_from_infinite = false;
if ( 'infinite' === $pagination_kind && ! airowp_query_host_supports_infinite_scroll( airowp_query_get_item_host( $query_id ) ) ) {
$pagination_kind = 'loadmore';
$degraded_from_infinite = true;
}
// Horizontal alignment of the pagination control. Drives the SCSS
// `is-align-{left|center|right}` modifier so the numbered flex list / load-more
// button / infinite sentinel all honour the setting with a single class.
$alignment = isset( $attributes['alignment'] ) && in_array( $attributes['alignment'], array( 'center', 'right' ), true )
? $attributes['alignment']
: 'left';
$align_class = 'is-align-' . $alignment;
// Single-page guard applies to ALL pagination kinds, including infinite.
// Emit nothing for single-page results so no sentinel or button is injected.
if ( ! $state || (int) $state['totalPages'] < 2 ) {
return;
}
// The page this Query actually rendered, so a first paint on a later
// page makes Load more / infinite scroll request the NEXT page, not
// page 2 again.
$rendered_page = isset( $state['page'] ) ? max( 1, (int) $state['page'] ) : airowp_query_current_page( $query_id );
// Infinite scroll: emit the sentinel + hidden fallback button.
if ( 'infinite' === $pagination_kind ) {
$auto_pause = isset( $attributes['autoPauseAfter'] ) ? (int) $attributes['autoPauseAfter'] : 3;
$sentinel_offset = isset( $attributes['sentinelOffsetPx'] ) ? (int) $attributes['sentinelOffsetPx'] : 200;
$btn_label = ! empty( $attributes['buttonLabelWhenPaused'] )
? (string) $attributes['buttonLabelWhenPaused']
: __( 'Load more', 'airo-wp' );
$inf_context = wp_json_encode(
array(
'queryId' => $query_id,
'autoLoadCount' => 0,
'page' => $rendered_page,
'busy' => false,
'restUrl' => esc_url_raw( rest_url( 'airo-wp/v1/query/render' ) ),
'nonce' => function_exists( 'airowp_query_rest_nonce' ) ? airowp_query_rest_nonce() : ( is_user_logged_in() ? wp_create_nonce( 'wp_rest' ) : '' ), // Visitors get none: a stale one on a cached page is rejected by core.
),
JSON_HEX_APOS
);
$wrapper = get_block_wrapper_attributes(
array(
'class' => 'airo-wp-query-pagination airo-wp-query-pagination--infinite ' . $align_class,
'data-wp-interactive' => 'airo-wp/query',
'data-airo-wp-query-id' => $query_id,
'data-airo-wp-pagination' => 'infinite',
'data-airo-wp-auto-pause-after' => (string) $auto_pause,
'data-airo-wp-sentinel-offset' => (string) $sentinel_offset,
)
);
printf(
'
' .
'' .
'' .
'
',
$wrapper, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() output.
$inf_context, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output inside single-quoted attr.
esc_attr( $btn_label ),
esc_attr__( 'Loading\u2026', 'airo-wp' ),
esc_html( $btn_label )
);
return;
}
$pagination_mode = $pagination_kind; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$show_prev_next = ! isset( $attributes['showPrevNext'] ) || (bool) $attributes['showPrevNext'];
// A block degraded from infinite scroll never had a labelLoadMore to
// set — its author-facing label is buttonLabelWhenPaused, so fall back
// to that and the button reads the way the author configured it.
$fallback_label = $degraded_from_infinite && ! empty( $attributes['buttonLabelWhenPaused'] )
? (string) $attributes['buttonLabelWhenPaused']
: __( 'Load more', 'airo-wp' );
$label_load_more = ! empty( $attributes['labelLoadMore'] )
? (string) $attributes['labelLoadMore']
: $fallback_label;
$label_loading = ! empty( $attributes['labelLoading'] )
? (string) $attributes['labelLoading']
: __( 'Loading…', 'airo-wp' );
if ( 'loadmore' === $pagination_mode ) {
// Seed the IAPI context on the pagination wrapper so `getContext()` inside
// actions.loadMore resolves ctx.queryId / ctx.page / ctx.busy. Without this
// the click handler would see an empty context and silently no-op.
// JSON_HEX_APOS defends the single-quoted attr boundary against a stray
// apostrophe in any future context value.
$lm_context = wp_json_encode(
array(
'queryId' => $query_id,
'page' => $rendered_page,
'busy' => false,
'restUrl' => esc_url_raw( rest_url( 'airo-wp/v1/query/render' ) ),
'nonce' => function_exists( 'airowp_query_rest_nonce' ) ? airowp_query_rest_nonce() : ( is_user_logged_in() ? wp_create_nonce( 'wp_rest' ) : '' ), // Visitors get none: a stale one on a cached page is rejected by core.
),
JSON_HEX_APOS
);
$wrapper = get_block_wrapper_attributes(
array(
'class' => 'airo-wp-query-pagination airo-wp-query-pagination--loadmore ' . $align_class,
'data-wp-interactive' => 'airo-wp/query',
'data-airo-wp-query-id' => $query_id,
'data-airo-wp-pagination' => 'loadmore',
)
);
printf(
'',
$wrapper, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() output.
esc_html( $label_load_more ),
esc_attr( $label_load_more ),
esc_attr( $label_loading ),
$lm_context // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_json_encode output inside single-quoted attr.
);
return;
}
// Numbered pagination. The page this Query actually rendered, so a
// REST refresh (always page 1) and first paint agree.
$current = isset( $state['page'] ) ? max( 1, (int) $state['page'] ) : airowp_query_current_page( $query_id );
$paginate_args = array(
'total' => (int) $state['totalPages'],
'current' => $current,
'type' => 'array',
'prev_next' => $show_prev_next,
);
// With several Queries on the page, WordPress's shared `paged` would
// page all of them; each pages by its own `qpage__{queryId}` instead.
$page_param = airowp_query_page_param_name( $query_id );
if ( 'paged' !== $page_param ) {
// paginate_links() escapes each link it builds from this base.
$paginate_args['base'] = add_query_arg( $page_param, '%#%' );
$paginate_args['format'] = '';
}
$links = paginate_links( $paginate_args );
if ( empty( $links ) || ! is_array( $links ) ) {
return;
}
$wrapper = get_block_wrapper_attributes(
array(
'class' => 'airo-wp-query-pagination airo-wp-query-pagination--numbered ' . $align_class,
'role' => 'navigation',
'aria-label' => __( 'Query pagination', 'airo-wp' ),
'data-airo-wp-query-id' => $query_id,
)
);
echo '';
}
}
airowp_render_query_pagination( $attributes, $content, $block );