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 );