" data-zx-post="" data-zx-doc="">… * * Off-canvas widgets inside an item get the post id appended to their runtime id (`#zx-off-canvas:-`, * `data-zx-offcanvas`, `zx-oc-` panel ids) so each item's triggers open that item's panel (T120). */ final class LoopRenderer { public const TYPES = ['loop-grid', 'loop-carousel']; public const PAGINATION = ['none', 'numbers', 'prev_next', 'numbers_prev_next', 'load_more', 'infinite']; /** @var int[] posts shown by loops on this request ("avoid duplicates") */ private static array $shown = []; /** @var array templates being rendered (a template listing itself) */ private static array $stack = []; /** @var array widget types rendering their own items (register_type) */ private static array $types = []; /** * Registers a "paginated" widget type whose items are rendered by PHP instead of a loop-item template (the Posts * widget, T180). The loop REST route, pagination, the Taxonomy filter and "avoid duplicates" then treat it like * loop-grid. `$item($renderer, $settings, $post_id, $position): string` returns one item's HTML (its root should * carry `zx-loop__item` and `data-zx-post`). Also filterable: `zadex/loop/paginated_types` (type => callable). */ public static function register_type(string $type, callable $item): void { self::$types[$type] = $item; } /** @return array paginated widget types with their own item renderer */ public static function paginated_types(): array { $types = self::$types + ['posts' => [PostCard::class, 'item']]; $filtered = function_exists('apply_filters') ? apply_filters('zadex/loop/paginated_types', $types) : $types; return is_array($filtered) ? array_filter($filtered, 'is_callable') : $types; } /** Item renderer of a paginated widget type (null for template loops and other widgets). */ public static function item_renderer(string $type): ?callable { return self::paginated_types()[$type] ?? null; } /** loop-grid, loop-carousel or a registered paginated type (posts …). */ public static function is_loop(string $type): bool { return in_array($type, self::TYPES, true) || self::item_renderer($type) !== null; } public static function reset(): void { self::$shown = []; self::$stack = []; } /** @param int[] $ids */ public static function mark_shown(array $ids): void { self::$shown = array_values(array_unique(array_merge(self::$shown, array_map('intval', $ids)))); } /** @return int[] */ public static function shown(): array { return self::$shown; } /** * Loop-item template for the n-th item (1-based position across pages). Alternate rules * (`alternates`: [{template, mode: every|positions, nth, positions}]) are checked in order; first match wins. */ public static function template_for(array $settings, int $position): int { foreach ((array) ($settings['alternates'] ?? []) as $rule) { if (!is_array($rule) || (int) ($rule['template'] ?? 0) <= 0) { continue; } if (($rule['mode'] ?? 'every') === 'positions') { if (in_array($position, Query::ids((string) ($rule['positions'] ?? '')), true)) { return (int) $rule['template']; } } else { $nth = max(0, (int) ($rule['nth'] ?? 0)); if ($nth > 0 && $position % $nth === 0) { return (int) $rule['template']; } } } return max(0, (int) ($settings['template'] ?? 0)); } /** @return int[] main + alternate template ids */ public static function template_ids(array $settings): array { $ids = [max(0, (int) ($settings['template'] ?? 0))]; foreach ((array) ($settings['alternates'] ?? []) as $rule) { if (is_array($rule)) { $ids[] = (int) ($rule['template'] ?? 0); } } return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0))); } /** * Runs the loop's query and renders its items. * * $opts: page, filters (taxonomy => terms), main (main query vars), post_id (current post), exclude (int[]), * item_class (string), track (bool, default true: record shown ids for later loops), * item (callable, optional: renders each item instead of the loop-item template, see register_type) * * @return array{html: string, ids: int[], page: int, max_pages: int, found: int} */ public static function render(Renderer $renderer, array $settings, array $opts = []): array { $page = max(1, (int) ($opts['page'] ?? 1)); $exclude = array_map('intval', (array) ($opts['exclude'] ?? [])); if (!empty($settings['query_avoid_duplicates'])) { $exclude = array_merge($exclude, self::$shown); } $context = [ 'post_id' => (int) ($opts['post_id'] ?? 0), 'page' => $page, 'filters' => (array) ($opts['filters'] ?? []), 'main' => (array) ($opts['main'] ?? []), // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Zadex query-context key, not a WP_Query argument. 'exclude' => $exclude, ]; $result = Query::run(Query::build_args($settings, $context), $settings); if ($opts['track'] ?? true) { self::mark_shown($result['ids']); } $per_page = max(1, (int) ($settings['query_per_page'] ?? 6)); $html = ''; $item = isset($opts['item']) && is_callable($opts['item']) ? $opts['item'] : null; foreach ($result['ids'] as $i => $post_id) { $position = ($page - 1) * $per_page + $i + 1; if ($item !== null) { $html .= (string) $item($renderer, $settings, (int) $post_id, $position); continue; } $html .= self::render_item($renderer, self::template_for($settings, $position), (int) $post_id, (string) ($opts['item_class'] ?? '')); } return ['html' => $html, 'ids' => $result['ids'], 'page' => $page, 'max_pages' => $result['max_pages'], 'found' => $result['found']]; } /** One item: the template document rendered in the post's context. */ public static function render_item(Renderer $renderer, int $template_id, int $post_id, string $item_class = ''): string { if ($template_id <= 0 || $post_id <= 0 || isset(self::$stack[$template_id])) { return ''; } $elements = Document::get_data($template_id)['elements'] ?? []; $previous_context = $renderer->explicit_context(); $post = function_exists('get_post') ? get_post($post_id) : null; self::$stack[$template_id] = true; $renderer->set_context(Context::for_post($post_id)); try { // Third-party code inside the item (shortcodes, template tags) sees the item's post as the current post. $body = (string) PostData::with($post, static fn(): string => $renderer->render_document(is_array($elements) ? $elements : [])); } finally { unset(self::$stack[$template_id]); $renderer->set_context($previous_context); } $body = self::scope_off_canvas($body, self::off_canvas_ids($elements), $post_id); $class = implode(' ', array_filter(['zx-loop__item', trim($item_class), 'zadex-' . $template_id])); return '
' . $body . '
'; } /** @return string[] ids of the off-canvas widgets of a tree */ public static function off_canvas_ids(array $elements): array { $ids = []; foreach ($elements as $el) { if (!is_array($el)) { continue; } if (($el['type'] ?? '') === 'off-canvas' && is_string($el['id'] ?? null)) { $ids[] = $el['id']; } if (!empty($el['children']) && is_array($el['children'])) { $ids = array_merge($ids, self::off_canvas_ids($el['children'])); } } return $ids; } /** `#zx-off-canvas:`, `data-zx-offcanvas=""`, `zx-oc-` → `-` (triggers scoped to the item). */ public static function scope_off_canvas(string $html, array $ids, int $post_id): string { foreach ($ids as $id) { $id = preg_quote((string) $id, '/'); $html = (string) preg_replace('/(zx-off-canvas:|data-zx-offcanvas="|zx-oc-)(' . $id . ')(?![A-Za-z0-9_-])/', '${1}${2}-' . $post_id, $html); } return $html; } /** Queues the CSS of the loop-item templates (\Zadex\Frontend\Styles: once per request, when known early). */ public static function template_styles(array $template_ids): void { foreach ($template_ids as $id) { \Zadex\Frontend\Styles::document((int) $id); } } /** * Pagination markup. Links carry `data-zx-page` (the runtime loads the page with AJAX) and a real * `?zx-page-=N` href (works without JavaScript). */ public static function pagination(string $type, int $page, int $max, string $element_id, array $labels = [], string $base_url = ''): string { if (!in_array($type, self::PAGINATION, true) || $type === 'none') { return ''; } $has_more = $page < $max; if ($type === 'load_more') { $text = (string) ($labels['load_more'] ?? '') !== '' ? (string) $labels['load_more'] : __('Load more', 'zadex'); return '
' . '
'; } if ($type === 'infinite') { return '
' . '' . '
'; } $key = 'zx-page-' . $element_id; $link = static function (int $n, string $text, string $class, string $rel = '') use ($key, $base_url): string { $href = function_exists('add_query_arg') ? add_query_arg([$key => $n], $base_url) : ($base_url . '?' . $key . '=' . $n); return '' . $text . ''; }; $parts = []; if ($max > 1 && ($type === 'prev_next' || $type === 'numbers_prev_next') && $page > 1) { $parts[] = $link($page - 1, esc_html((string) ($labels['prev'] ?? '') !== '' ? (string) $labels['prev'] : __('Previous', 'zadex')), 'zx-loop__page zx-loop__page--prev', 'prev'); } if ($max > 1 && ($type === 'numbers' || $type === 'numbers_prev_next')) { foreach (self::page_numbers($page, $max) as $n) { if ($n === 0) { $parts[] = ''; } elseif ($n === $page) { $parts[] = '' . $n . ''; } else { /* translators: %d: page number */ $parts[] = str_replace(' 1 && ($type === 'prev_next' || $type === 'numbers_prev_next') && $has_more) { $parts[] = $link($page + 1, esc_html((string) ($labels['next'] ?? '') !== '' ? (string) $labels['next'] : __('Next', 'zadex')), 'zx-loop__page zx-loop__page--next', 'next'); } return ''; } /** 1 … 4 5 6 … 10 (0 = ellipsis). @return int[] */ public static function page_numbers(int $page, int $max): array { if ($max <= 7) { return range(1, max(1, $max)); } $out = [1]; $from = max(2, $page - 1); $to = min($max - 1, $page + 1); if ($from > 2) { $out[] = 0; } for ($i = $from; $i <= $to; $i++) { $out[] = $i; } if ($to < $max - 1) { $out[] = 0; } $out[] = $max; return $out; } /** * Everything a loop widget's render.php prints: items, pagination, the empty message and the runtime * config (`data-zx-loop`). Reads the page (`?zx-page-=N`) and the Taxonomy filter state * (`?zx-filter-=a,b`) of the current request. * * The loop-item templates' CSS is queued (template_styles()). * * @return array{ready: bool, items: string, pagination: string, empty: string, config: array, count: int, notice: string} */ public static function for_widget(Renderer $renderer, array $element, array $settings, string $item_class = '', ?callable $item = null): array { $id = (string) ($element['id'] ?? ''); $pagination = (string) ($settings['pagination'] ?? 'none'); $pagination = in_array($pagination, LoopRenderer::PAGINATION, true) ? $pagination : 'none'; $is_editor = defined('REST_REQUEST') && REST_REQUEST; $template = (int) ($settings['template'] ?? 0); $out = ['ready' => false, 'items' => '', 'pagination' => '', 'empty' => '', 'config' => [], 'count' => 0, 'notice' => '']; if ($template <= 0 && $item === null) { if ($is_editor) { $out['notice'] = __('Choose a loop item template (or create one in the Theme Builder).', 'zadex'); } return $out; } $page = 1; if (in_array($pagination, ['numbers', 'prev_next', 'numbers_prev_next'], true) && isset($_GET['zx-page-' . $id])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended // Public pagination link (?zx-page-=N): read-only, an integer page number. $page = max(1, absint(wp_unslash($_GET['zx-page-' . $id]))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } $request = self::request_filters($id); $main = ($settings['query_source'] ?? '') === 'current' ? Query::current_main_vars() : []; $post_id = $renderer->context()->post_id; $result = self::render($renderer, $settings, [ 'page' => $page, 'filters' => $request['filters'], 'main' => $main, 'post_id' => $post_id, 'item_class' => $item_class, 'item' => $item, ]); $base = ''; if (!$is_editor && function_exists('remove_query_arg')) { $base = (string) remove_query_arg('zx-page-' . $id); } $labels = ['load_more' => (string) ($settings['load_more_text'] ?? ''), 'prev' => (string) ($settings['prev_text'] ?? ''), 'next' => (string) ($settings['next_text'] ?? '')]; $nothing = (string) ($settings['nothing_found'] ?? ''); $out['ready'] = true; if ($item === null) { self::template_styles(self::template_ids($settings)); } $out['items'] = $result['html']; $out['count'] = count($result['ids']); $out['pagination'] = self::pagination($pagination, $result['page'], $result['max_pages'], $id, $labels, $base); $out['empty'] = '
0 ? ' hidden' : '') . '>' . esc_html($nothing !== '' ? $nothing : __('Nothing found.', 'zadex')) . '
'; $out['config'] = array_filter([ 'id' => $id, 'page' => $result['page'], 'max' => $result['max_pages'], 'pagination' => $pagination, 'rest' => function_exists('rest_url') ? (string) rest_url('zadex/v1/') : '', 'post' => $post_id, 'qv' => $main ?: null, 'filters' => $request['applied'] ?: null, ], static fn($v): bool => $v !== null && $v !== ''); return $out; } /** * Taxonomy filter state from the URL: `?zx-filter-=term1,term2` for the loop-filter widgets that target * this loop in the current document(s). * * @return array{filters: array, applied: array} */ public static function request_filters(string $loop_id): array { $out = ['filters' => [], 'applied' => []]; // Public, shareable filter URL: read-only (it only narrows which published posts the loop shows), so there is // no nonce. Only the parameter names are looked at here; each value is sanitized below. $keys = array_filter(array_keys(is_array($_GET) ? $_GET : []), static fn($k): bool => is_string($k) && str_starts_with($k, 'zx-filter-')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (!$keys) { return $out; } $docs = []; foreach ([function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0, function_exists('get_the_ID') ? (int) get_the_ID() : 0] as $doc) { if ($doc > 0) { $docs[$doc] = true; } } foreach (array_keys($docs) as $doc) { foreach (self::filters_in(Document::get_data($doc)['elements'] ?? [], $loop_id) as $filter) { $raw = self::url_filter_value((string) $filter['id']); if (!is_string($raw) || $raw === '') { continue; } $terms = self::terms_param($raw); if (!$terms || $filter['taxonomy'] === '') { continue; } if (empty($filter['multiple'])) { $terms = [$terms[0]]; } $out['applied'][$filter['id']] = ['taxonomy' => $filter['taxonomy'], 'terms' => $terms]; $out['filters'][$filter['taxonomy']] = array_values(array_unique(array_merge($out['filters'][$filter['taxonomy']] ?? [], $terms))); } } return $out; } /** * Raw `?zx-filter-=slug1,slug2` value ('' when absent). Public and read-only (see request_filters()). * wp_strip_all_tags() rather than sanitize_text_field() because non-Latin term slugs are stored * percent-encoded (`%d8%a7…`) and sanitize_text_field() strips percent octets; every slug is then sanitized * with sanitize_title_for_query() by terms_param(). */ public static function url_filter_value(string $filter_id): string { $key = 'zx-filter-' . $filter_id; if (!isset($_GET[$key]) || !is_string($_GET[$key])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return ''; } return trim(wp_strip_all_tags(wp_unslash($_GET[$key]))); // phpcs:ignore WordPress.Security.NonceVerification.Recommended } /** @return string[] */ public static function terms_param(string $raw): array { $terms = []; foreach (explode(',', $raw) as $t) { $t = sanitize_title_for_query(trim($t)); if ($t !== '') { $terms[] = $t; } } return array_slice(array_values(array_unique($terms)), 0, 20); } /** @return array loop-filter widgets targeting a loop */ public static function filters_in(array $elements, string $loop_id): array { $out = []; foreach ($elements as $el) { if (!is_array($el) || !empty($el['hidden'])) { continue; } $s = is_array($el['settings'] ?? null) ? $el['settings'] : []; if (($el['type'] ?? '') === 'loop-filter' && (string) ($s['target'] ?? '') === $loop_id && is_string($el['id'] ?? null)) { $out[] = ['id' => $el['id'], 'taxonomy' => Query::taxonomy((string) ($s['taxonomy'] ?? 'category')), 'multiple' => !empty($s['multiple'])]; } if (!empty($el['children']) && is_array($el['children'])) { $out = array_merge($out, self::filters_in($el['children'], $loop_id)); } } return $out; } /** Finds a visible element by id in a tree. */ public static function find(array $elements, string $id): ?array { foreach ($elements as $el) { if (!is_array($el) || !empty($el['hidden'])) { continue; } if (($el['id'] ?? '') === $id) { return $el; } if (!empty($el['children']) && is_array($el['children'])) { $hit = self::find($el['children'], $id); if ($hit !== null) { return $hit; } } } return null; } /** Visible loop widgets of a tree in document order. @return array[] */ public static function loops_in(array $elements): array { $out = []; foreach ($elements as $el) { if (!is_array($el) || !empty($el['hidden'])) { continue; } if (self::is_loop((string) ($el['type'] ?? ''))) { $out[] = $el; } if (!empty($el['children']) && is_array($el['children'])) { $out = array_merge($out, self::loops_in($el['children'])); } } return $out; } }