&qv[cat]=3 * → { html, pagination, page, maxPages, found, hasMore, empty } * * Also serves the registered paginated widget types (LoopRenderer::register_type, e.g. the Posts widget, T180): their * items are rendered by the type's own item renderer instead of a loop-item template. * * The loop is read from the SAVED document of a published, non password-protected Zadex page (or published theme * template: loops in archive/single templates, nested loops in loop items); the request only chooses the page, the * filter terms, the current post (related / exclude current) and — for the "current query" source — whitelisted main * query vars. Results are always published posts of viewable post types (Query::guard()). No nonce: visitors and * cached pages call it, and it exposes nothing a visitor cannot already see. */ final class LoopController { private Renderer $renderer; /** @var callable(int): array|null document loader (tests) */ private $loader; public function __construct(Renderer $renderer, ?callable $loader = null) { $this->renderer = $renderer; $this->loader = $loader; } public function register(): void { add_action('rest_api_init', [$this, 'routes']); } public function routes(): void { register_rest_route('zadex/v1', '/loop/(?P\d+)/(?P[A-Za-z0-9_-]{1,64})', [ 'methods' => 'GET', 'callback' => [$this, 'handle'], // Public on purpose: loop pagination / "load more" / taxonomy filters run for visitors. Read-only: the // loop element is looked up in the saved document of a published, non-password-protected Zadex page or // theme template (is_public_document()); its saved query settings (not the request) decide what is // queried, and the request only supplies the page number, filter slugs and the public context post. 'permission_callback' => '__return_true', ]); } /** @return WP_REST_Response|WP_Error */ public function handle(WP_REST_Request $request) { $post_id = (int) $request['post']; $element_id = (string) $request['element']; if (!$this->is_public_document($post_id)) { return new WP_Error('zadex_not_found', __('Not found.', 'zadex'), ['status' => 404]); } $elements = $this->elements($post_id); $element = LoopRenderer::find($elements, $element_id); if ($element === null || !LoopRenderer::is_loop((string) ($element['type'] ?? ''))) { return new WP_Error('zadex_not_found', __('Loop not found.', 'zadex'), ['status' => 404]); } $settings = $this->renderer->settings($element); $page = max(1, min(1000, (int) ($request['page'] ?? 1))); $ctx_post = $this->context_post((int) ($request['ctx'] ?? 0), $post_id); $main = ($settings['query_source'] ?? '') === 'current' && is_array($request['qv'] ?? null) ? Query::main_vars($request['qv']) : []; $filters = $this->filters($request['tax'] ?? null); $previous = $this->renderer->explicit_context(); $this->renderer->set_context(\Zadex\Dynamic\Context::for_post($ctx_post)); try { $exclude = !empty($settings['query_avoid_duplicates']) ? $this->preceding_ids($elements, $element_id, $ctx_post, $main) : []; // The CSS of the documents rendered in the items (loop-item templates, components ...) is returned as // `css` (handle => css) and inserted by the frontend runtime; the HTML itself carries no style tags. [$result, $css] = \Zadex\Frontend\Styles::capture(fn(): array => LoopRenderer::render($this->renderer, $settings, [ 'page' => $page, 'filters' => $filters, 'main' => $main, 'post_id' => $ctx_post, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Zadex query-context key, not a WP_Query argument. 'exclude' => $exclude, 'item_class' => ($element['type'] ?? '') === 'loop-carousel' ? 'zx-loop-carousel__slide zx-slider__slide' : '', 'track' => false, 'item' => LoopRenderer::item_renderer((string) ($element['type'] ?? '')), ])); if (LoopRenderer::item_renderer((string) ($element['type'] ?? '')) === null) { [, $tpl_css] = \Zadex\Frontend\Styles::capture(static fn() => LoopRenderer::template_styles(LoopRenderer::template_ids($settings))); $css = $tpl_css + $css; } } finally { $this->renderer->set_context($previous); } $pagination = (string) ($settings['pagination'] ?? 'none'); $pagination = in_array($pagination, LoopRenderer::PAGINATION, true) ? $pagination : 'none'; $labels = ['load_more' => (string) ($settings['load_more_text'] ?? ''), 'prev' => (string) ($settings['prev_text'] ?? ''), 'next' => (string) ($settings['next_text'] ?? '')]; $base = function_exists('get_permalink') ? (string) get_permalink($post_id) : ''; return new WP_REST_Response([ 'html' => \Zadex\Frontend\Kses::restore(wp_kses($result['html'], \Zadex\Frontend\Kses::allowed_html())), 'css' => (object) $css, 'pagination' => wp_kses(LoopRenderer::pagination($pagination, $result['page'], $result['max_pages'], $element_id, $labels, $base), \Zadex\Frontend\Kses::allowed_html()), 'page' => $result['page'], 'maxPages' => $result['max_pages'], 'found' => $result['found'], 'hasMore' => $result['page'] < $result['max_pages'], 'empty' => count($result['ids']) === 0, ]); } /** Published, not password protected, and either a Zadex page or a theme template. */ public function is_public_document(int $post_id): bool { $post = $post_id > 0 ? get_post($post_id) : null; if (!$post || ($post->post_status ?? '') !== 'publish' || (string) ($post->post_password ?? '') !== '') { return false; } if (($post->post_type ?? '') === \Zadex\Theme\TemplatePost::POST_TYPE) { return true; } return Document::is_builder($post_id); } private function elements(int $post_id): array { $data = $this->loader ? ($this->loader)($post_id) : Document::get_data($post_id); return is_array($data['elements'] ?? null) ? $data['elements'] : []; } /** The current post passed by the page (related / exclude current) must be public too; else the document. */ private function context_post(int $ctx, int $doc): int { if ($ctx > 0 && $ctx !== $doc) { $post = get_post($ctx); if ($post && ($post->post_status ?? '') === 'publish' && (string) ($post->post_password ?? '') === '') { return $ctx; } } $doc_post = get_post($doc); return $doc_post && ($doc_post->post_type ?? '') === \Zadex\Theme\TemplatePost::POST_TYPE ? 0 : $doc; } /** `tax[category]=a,b` → ['category' => ['a','b']] (at most 5 taxonomies, 20 terms each). */ private function filters($raw): array { if (!is_array($raw)) { return []; } $out = []; foreach (array_slice($raw, 0, 5, true) as $tax => $terms) { $tax = Query::taxonomy((string) $tax); if ($tax === '' || (function_exists('taxonomy_exists') && !taxonomy_exists($tax))) { continue; } if (function_exists('is_taxonomy_viewable') && !is_taxonomy_viewable($tax)) { continue; } $list = LoopRenderer::terms_param(is_array($terms) ? implode(',', array_map('strval', $terms)) : (string) $terms); if ($list) { $out[$tax] = $list; } } return $out; } /** "Avoid duplicates": ids the loops above this one show on their first page. @return int[] */ private function preceding_ids(array $elements, string $element_id, int $ctx_post, array $main): array { $shown = []; foreach (LoopRenderer::loops_in($elements) as $loop) { if (($loop['id'] ?? '') === $element_id) { break; } $s = $this->renderer->settings($loop); $exclude = !empty($s['query_avoid_duplicates']) ? $shown : []; // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Zadex query-context key, not a WP_Query argument. $res = Query::run(Query::build_args($s, ['post_id' => $ctx_post, 'page' => 1, 'exclude' => $exclude, 'main' => ($s['query_source'] ?? '') === 'current' ? $main : []]), $s); $shown = array_values(array_unique(array_merge($shown, $res['ids']))); } return $shown; } }