From d38b6152373b4b74b1ea1b21225abfaddb27f15d Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:23:46 +0200 Subject: [PATCH 01/17] feat(canonical-list): add build() with attrs bag API (ignore sticky only) --- includes/CanonicalList.php | 83 +++++++++++++++++++++++++ tests/integration/CanonicalListTest.php | 15 +++++ 2 files changed, 98 insertions(+) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index bce0917..65b1139 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -17,6 +17,89 @@ final class CanonicalList private const ALLOWED_ORDERBY = ['date', 'title']; private const ALLOWED_ORDER = ['ASC', 'DESC']; + private const ALLOWED_STICKY = ['', 'ignore', 'exclude', 'only']; + + /** + * @param array $query_attrs Block `query` attributes bag. + * @return int[] Ordered list of published post IDs. + */ + public static function build(array $query_attrs): array + { + $normalized = self::normalize($query_attrs); + if ($normalized === null) { + return []; + } + + $last_changed = wp_cache_get_last_changed('posts'); + $cache_key = 'canonical:' . md5(serialize($normalized)) . ':' . $last_changed; + + $cached = wp_cache_get($cache_key, self::CACHE_GROUP); + if ($cached !== false) { + return $cached; + } + + $args = self::build_query_args($normalized); + $query = new \WP_Query($args); + $ids = array_map('intval', $query->posts); + + $ids = self::apply_sticky_mode($ids, $normalized['sticky'], $args); + + wp_cache_set($cache_key, $ids, self::CACHE_GROUP, self::CACHE_TTL); + return $ids; + } + + /** + * @param array $query_attrs + * @return array|null Null if postType invalid. + */ + private static function normalize(array $query_attrs): ?array + { + $post_type = (string) ($query_attrs['postType'] ?? 'post'); + if (!post_type_exists($post_type)) { + return null; + } + + $orderby_raw = (string) ($query_attrs['orderBy'] ?? 'date'); + $order_raw = strtoupper((string) ($query_attrs['order'] ?? 'ASC')); + $sticky_raw = (string) ($query_attrs['sticky'] ?? ''); + + return [ + 'postType' => $post_type, + 'orderBy' => in_array($orderby_raw, self::ALLOWED_ORDERBY, true) ? $orderby_raw : 'date', + 'order' => in_array($order_raw, self::ALLOWED_ORDER, true) ? $order_raw : 'ASC', + 'sticky' => in_array($sticky_raw, self::ALLOWED_STICKY, true) ? $sticky_raw : '', + ]; + } + + /** + * @param array $n Normalized attrs. + * @return array WP_Query args. + */ + private static function build_query_args(array $n): array + { + return [ + 'post_type' => $n['postType'], + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'orderby' => $n['orderBy'], + 'order' => $n['order'], + 'no_found_rows' => true, + 'suppress_filters' => true, + 'ignore_sticky_posts' => 1, + ]; + } + + /** + * @param int[] $ids + * @param string $mode + * @param array $args + * @return int[] + */ + private static function apply_sticky_mode(array $ids, string $mode, array $args): array + { + return $ids; + } /** * @param string $post_type WordPress post type slug. diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index c8b98fe..2ae7977 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -74,6 +74,21 @@ public function test_returns_published_ids_ordered_by_date_asc_by_default(): voi $this->assertSame($expected, CanonicalList::get('post')); } + public function test_build_with_empty_attrs_returns_all_published_post_ids_in_natural_order(): void + { + $result = CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); + $expected = [ + $this->post_ids[0], // Alpha + $this->post_ids[1], // Bravo (sticky) + $this->post_ids[2], // Charlie + $this->post_ids[3], // Delta + $this->post_ids[5], // Foxtrot (Echo @ draft excluded) + $this->post_ids[6], // Golf (sticky) + $this->post_ids[7], // Hotel + ]; + $this->assertSame($expected, $result); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From 3c7836b195e36002bb0e76486947ce1644add0ed Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:24:40 +0200 Subject: [PATCH 02/17] feat(canonical-list): sticky mode 'include' with filter-scope intersection --- includes/CanonicalList.php | 15 ++++++++++++++ tests/integration/CanonicalListTest.php | 27 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index 65b1139..43ac71e 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -98,6 +98,21 @@ private static function build_query_args(array $n): array */ private static function apply_sticky_mode(array $ids, string $mode, array $args): array { + if ($mode === 'ignore' || empty($ids)) { + return $ids; + } + + $sticky_option = array_map('intval', (array) get_option('sticky_posts', [])); + + if ($mode === '') { + if (empty($sticky_option)) { + return $ids; + } + $matching_stickies = array_values(array_intersect($ids, $sticky_option)); + $non_stickies = array_values(array_diff($ids, $matching_stickies)); + return array_merge($matching_stickies, $non_stickies); + } + return $ids; } diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index 2ae7977..f83b0a6 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -89,6 +89,33 @@ public function test_build_with_empty_attrs_returns_all_published_post_ids_in_na $this->assertSame($expected, $result); } + public function test_build_with_sticky_include_prepends_stickies_in_orderby(): void + { + // orderBy date ASC → base order Alpha, Bravo*, Charlie, Delta, Foxtrot, Golf*, Hotel + // Include mode → stickies first (Bravo, Golf), then non-stickies in natural order. + $result = CanonicalList::build(['postType' => 'post', 'sticky' => '']); + $expected = [ + $this->post_ids[1], // Bravo (sticky) + $this->post_ids[6], // Golf (sticky) + $this->post_ids[0], // Alpha + $this->post_ids[2], // Charlie + $this->post_ids[3], // Delta + $this->post_ids[5], // Foxtrot + $this->post_ids[7], // Hotel + ]; + $this->assertSame($expected, $result); + } + + public function test_build_with_sticky_include_and_no_stickies_is_identical_to_ignore(): void + { + delete_option('sticky_posts'); + wp_cache_flush(); + + $include = CanonicalList::build(['postType' => 'post', 'sticky' => '']); + $ignore = CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); + $this->assertSame($ignore, $include); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From eebad354ba27dd755fc9771e77d9b3a9d56df6f4 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:27:50 +0200 Subject: [PATCH 03/17] feat(canonical-list): sticky mode 'exclude' --- includes/CanonicalList.php | 7 +++++++ tests/integration/CanonicalListTest.php | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index 43ac71e..88f4b18 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -113,6 +113,13 @@ private static function apply_sticky_mode(array $ids, string $mode, array $args) return array_merge($matching_stickies, $non_stickies); } + if ($mode === 'exclude') { + if (empty($sticky_option)) { + return $ids; + } + return array_values(array_diff($ids, $sticky_option)); + } + return $ids; } diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index f83b0a6..9d7068f 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -116,6 +116,19 @@ public function test_build_with_sticky_include_and_no_stickies_is_identical_to_i $this->assertSame($ignore, $include); } + public function test_build_with_sticky_exclude_removes_sticky_ids(): void + { + $result = CanonicalList::build(['postType' => 'post', 'sticky' => 'exclude']); + $expected = [ + $this->post_ids[0], // Alpha + $this->post_ids[2], // Charlie + $this->post_ids[3], // Delta + $this->post_ids[5], // Foxtrot + $this->post_ids[7], // Hotel + ]; + $this->assertSame($expected, $result); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From 68b9c11a98d1d9feccc9fff07f3481140473fff7 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:28:21 +0200 Subject: [PATCH 04/17] feat(canonical-list): sticky mode 'only' --- includes/CanonicalList.php | 7 +++++++ tests/integration/CanonicalListTest.php | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index 88f4b18..a98a941 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -120,6 +120,13 @@ private static function apply_sticky_mode(array $ids, string $mode, array $args) return array_values(array_diff($ids, $sticky_option)); } + if ($mode === 'only') { + if (empty($sticky_option)) { + return []; + } + return array_values(array_intersect($ids, $sticky_option)); + } + return $ids; } diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index 9d7068f..8ea8646 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -129,6 +129,24 @@ public function test_build_with_sticky_exclude_removes_sticky_ids(): void $this->assertSame($expected, $result); } + public function test_build_with_sticky_only_returns_sticky_ids_in_orderby(): void + { + $result = CanonicalList::build(['postType' => 'post', 'sticky' => 'only']); + $expected = [ + $this->post_ids[1], // Bravo (sticky, 2024-01-02) + $this->post_ids[6], // Golf (sticky, 2024-01-07) + ]; + $this->assertSame($expected, $result); + } + + public function test_build_with_sticky_only_and_no_stickies_returns_empty(): void + { + delete_option('sticky_posts'); + wp_cache_flush(); + + $this->assertSame([], CanonicalList::build(['postType' => 'post', 'sticky' => 'only'])); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From 0fa5d5c47437ad83e5e133b74abbc3d8e0743cca Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:29:10 +0200 Subject: [PATCH 05/17] feat(canonical-list): honor native taxQuery filter --- includes/CanonicalList.php | 38 +++++++++++++++++++++- tests/integration/CanonicalListTest.php | 43 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index a98a941..ca3b042 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -63,11 +63,29 @@ private static function normalize(array $query_attrs): ?array $order_raw = strtoupper((string) ($query_attrs['order'] ?? 'ASC')); $sticky_raw = (string) ($query_attrs['sticky'] ?? ''); + $tax_query_raw = $query_attrs['taxQuery'] ?? []; + $tax_query = []; + if (is_array($tax_query_raw)) { + foreach ($tax_query_raw as $taxonomy => $term_ids) { + if (!taxonomy_exists((string) $taxonomy)) { + continue; + } + $ids = array_values(array_filter( + array_map('intval', (array) $term_ids), + static fn($id) => $id > 0 + )); + if (!empty($ids)) { + $tax_query[(string) $taxonomy] = $ids; + } + } + } + return [ 'postType' => $post_type, 'orderBy' => in_array($orderby_raw, self::ALLOWED_ORDERBY, true) ? $orderby_raw : 'date', 'order' => in_array($order_raw, self::ALLOWED_ORDER, true) ? $order_raw : 'ASC', 'sticky' => in_array($sticky_raw, self::ALLOWED_STICKY, true) ? $sticky_raw : '', + 'taxQuery' => $tax_query, ]; } @@ -77,7 +95,7 @@ private static function normalize(array $query_attrs): ?array */ private static function build_query_args(array $n): array { - return [ + $args = [ 'post_type' => $n['postType'], 'post_status' => 'publish', 'posts_per_page' => -1, @@ -88,6 +106,24 @@ private static function build_query_args(array $n): array 'suppress_filters' => true, 'ignore_sticky_posts' => 1, ]; + + if (!empty($n['taxQuery'])) { + $clauses = []; + foreach ($n['taxQuery'] as $taxonomy => $term_ids) { + $clauses[] = [ + 'taxonomy' => $taxonomy, + 'field' => 'term_id', + 'terms' => $term_ids, + 'operator' => 'IN', + ]; + } + if (count($clauses) > 1) { + $clauses['relation'] = 'AND'; + } + $args['tax_query'] = $clauses; + } + + return $args; } /** diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index 8ea8646..c8f506d 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -147,6 +147,49 @@ public function test_build_with_sticky_only_and_no_stickies_returns_empty(): voi $this->assertSame([], CanonicalList::build(['postType' => 'post', 'sticky' => 'only'])); } + /** @return array{news:int, opinion:int} */ + private function seed_categories(): array + { + $news_id = self::factory()->category->create(['name' => 'news']); + $opinion_id = self::factory()->category->create(['name' => 'opinion']); + + wp_set_post_categories($this->post_ids[0], [$news_id]); // Alpha — news + wp_set_post_categories($this->post_ids[1], [$news_id]); // Bravo — news (sticky) + wp_set_post_categories($this->post_ids[2], [$opinion_id]); // Charlie — opinion + wp_set_post_categories($this->post_ids[5], [$news_id]); // Foxtrot — news + + wp_cache_flush(); + return ['news' => $news_id, 'opinion' => $opinion_id]; + } + + public function test_build_with_tax_query_filters_by_category(): void + { + $cats = $this->seed_categories(); + $result = CanonicalList::build([ + 'postType' => 'post', + 'sticky' => 'ignore', + 'taxQuery' => ['category' => [$cats['news']]], + ]); + $expected = [ + $this->post_ids[0], // Alpha + $this->post_ids[1], // Bravo (sticky, still in 'news') + $this->post_ids[5], // Foxtrot + ]; + $this->assertSame($expected, $result); + } + + public function test_build_with_tax_query_include_prepends_only_matching_stickies(): void + { + $cats = $this->seed_categories(); + // Filter to 'opinion' — only Charlie matches. Golf (sticky) is NOT in 'opinion' → not prepended. + $result = CanonicalList::build([ + 'postType' => 'post', + 'sticky' => '', // Include + 'taxQuery' => ['category' => [$cats['opinion']]], + ]); + $this->assertSame([$this->post_ids[2]], $result); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From b87ee65e9d2f63fc91f8a3e33c60f3777ebb2043 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:29:57 +0200 Subject: [PATCH 06/17] feat(canonical-list): honor native author filter (CSV user IDs) --- includes/CanonicalList.php | 11 +++++++++++ tests/integration/CanonicalListTest.php | 15 +++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index ca3b042..0aa64f1 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -63,6 +63,12 @@ private static function normalize(array $query_attrs): ?array $order_raw = strtoupper((string) ($query_attrs['order'] ?? 'ASC')); $sticky_raw = (string) ($query_attrs['sticky'] ?? ''); + $author_raw = (string) ($query_attrs['author'] ?? ''); + $author = array_values(array_filter( + array_map('intval', $author_raw === '' ? [] : explode(',', $author_raw)), + static fn($id) => $id > 0 + )); + $tax_query_raw = $query_attrs['taxQuery'] ?? []; $tax_query = []; if (is_array($tax_query_raw)) { @@ -86,6 +92,7 @@ private static function normalize(array $query_attrs): ?array 'order' => in_array($order_raw, self::ALLOWED_ORDER, true) ? $order_raw : 'ASC', 'sticky' => in_array($sticky_raw, self::ALLOWED_STICKY, true) ? $sticky_raw : '', 'taxQuery' => $tax_query, + 'author' => $author, ]; } @@ -107,6 +114,10 @@ private static function build_query_args(array $n): array 'ignore_sticky_posts' => 1, ]; + if (!empty($n['author'])) { + $args['author__in'] = $n['author']; + } + if (!empty($n['taxQuery'])) { $clauses = []; foreach ($n['taxQuery'] as $taxonomy => $term_ids) { diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index c8f506d..cc20500 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -190,6 +190,21 @@ public function test_build_with_tax_query_include_prepends_only_matching_stickie $this->assertSame([$this->post_ids[2]], $result); } + public function test_build_with_author_filter_restricts_to_csv_of_user_ids(): void + { + $editor_id = self::factory()->user->create(['role' => 'editor']); + wp_update_post(['ID' => $this->post_ids[3], 'post_author' => $editor_id]); + wp_update_post(['ID' => $this->post_ids[5], 'post_author' => $editor_id]); + wp_cache_flush(); + + $result = CanonicalList::build([ + 'postType' => 'post', + 'sticky' => 'ignore', + 'author' => (string) $editor_id, + ]); + $this->assertSame([$this->post_ids[3], $this->post_ids[5]], $result); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From 2d4c24fcd46de956ac14b94fd542c7c1b57f3080 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:30:35 +0200 Subject: [PATCH 07/17] feat(canonical-list): honor native search filter --- includes/CanonicalList.php | 7 +++++++ tests/integration/CanonicalListTest.php | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index 0aa64f1..26ef637 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -63,6 +63,8 @@ private static function normalize(array $query_attrs): ?array $order_raw = strtoupper((string) ($query_attrs['order'] ?? 'ASC')); $sticky_raw = (string) ($query_attrs['sticky'] ?? ''); + $search = trim((string) ($query_attrs['search'] ?? '')); + $author_raw = (string) ($query_attrs['author'] ?? ''); $author = array_values(array_filter( array_map('intval', $author_raw === '' ? [] : explode(',', $author_raw)), @@ -93,6 +95,7 @@ private static function normalize(array $query_attrs): ?array 'sticky' => in_array($sticky_raw, self::ALLOWED_STICKY, true) ? $sticky_raw : '', 'taxQuery' => $tax_query, 'author' => $author, + 'search' => $search, ]; } @@ -118,6 +121,10 @@ private static function build_query_args(array $n): array $args['author__in'] = $n['author']; } + if ($n['search'] !== '') { + $args['s'] = $n['search']; + } + if (!empty($n['taxQuery'])) { $clauses = []; foreach ($n['taxQuery'] as $taxonomy => $term_ids) { diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index cc20500..dacc7dd 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -205,6 +205,16 @@ public function test_build_with_author_filter_restricts_to_csv_of_user_ids(): vo $this->assertSame([$this->post_ids[3], $this->post_ids[5]], $result); } + public function test_build_with_search_filters_by_keyword(): void + { + $result = CanonicalList::build([ + 'postType' => 'post', + 'sticky' => 'ignore', + 'search' => 'Alpha', + ]); + $this->assertSame([$this->post_ids[0]], $result); + } + public function test_excludes_draft_posts(): void { $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); From df150ec2e16095683bc3c227115cdb5c60984d93 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:32:34 +0200 Subject: [PATCH 08/17] refactor(canonical-list): remove legacy ::get(), migrate tests to ::build() --- includes/CanonicalList.php | 54 -------------------- includes/QueryFilter.php | 14 +++++- tests/integration/CanonicalListTest.php | 65 +++++++++---------------- 3 files changed, 35 insertions(+), 98 deletions(-) diff --git a/includes/CanonicalList.php b/includes/CanonicalList.php index 26ef637..2da591e 100644 --- a/includes/CanonicalList.php +++ b/includes/CanonicalList.php @@ -184,58 +184,4 @@ private static function apply_sticky_mode(array $ids, string $mode, array $args) return $ids; } - /** - * @param string $post_type WordPress post type slug. - * @param string $orderby 'date' or 'title'. - * @param string $order 'ASC' or 'DESC'. - * @param bool $exclude_sticky When true, sticky posts are removed from the list. - * @return int[] Ordered list of published post IDs. - */ - public static function get( - string $post_type, - string $orderby = 'date', - string $order = 'ASC', - bool $exclude_sticky = false - ): array { - if (!post_type_exists($post_type)) { - return []; - } - - $orderby = in_array($orderby, self::ALLOWED_ORDERBY, true) ? $orderby : 'date'; - $order = in_array(strtoupper($order), self::ALLOWED_ORDER, true) ? strtoupper($order) : 'ASC'; - - $last_changed = wp_cache_get_last_changed('posts'); - $cache_flag = $exclude_sticky ? 'no_sticky' : 'with_sticky'; - $cache_key = "canonical:{$post_type}:{$orderby}:{$order}:{$cache_flag}:{$last_changed}"; - - $cached = wp_cache_get($cache_key, self::CACHE_GROUP); - if ($cached !== false) { - return $cached; - } - - $args = [ - 'post_type' => $post_type, - 'post_status' => 'publish', - 'posts_per_page' => -1, - 'fields' => 'ids', - 'orderby' => $orderby, - 'order' => $order, - 'no_found_rows' => true, - 'suppress_filters' => true, - 'ignore_sticky_posts' => 1, - ]; - - if ($exclude_sticky) { - $sticky_ids = get_option('sticky_posts', []); - if (!empty($sticky_ids)) { - $args['post__not_in'] = array_map('intval', (array) $sticky_ids); - } - } - - $query = new \WP_Query($args); - - $ids = array_map('intval', $query->posts); - wp_cache_set($cache_key, $ids, self::CACHE_GROUP, self::CACHE_TTL); - return $ids; - } } diff --git a/includes/QueryFilter.php b/includes/QueryFilter.php index 8f2ab0f..6fd1425 100644 --- a/includes/QueryFilter.php +++ b/includes/QueryFilter.php @@ -104,7 +104,12 @@ public function filter_query_vars(array $query, WP_Block $block, int $page): arr // Build canonical list with the user's chosen sort order. // Resolver always goes forward — the list order defines "next". - $all_ids = CanonicalList::get($post_type, self::$orderby, self::$order, $exclude_sticky); + $all_ids = CanonicalList::build([ + 'postType' => $post_type, + 'orderBy' => self::$orderby, + 'order' => self::$order, + 'sticky' => $exclude_sticky ? 'exclude' : 'ignore', + ]); $current_id = (new ContextDetector())->current_post_id(); $resolved = $current_id === null @@ -152,7 +157,12 @@ public function filter_rest_query(string $post_type, array $args, WP_REST_Reques $raw_count = (int) ($args['posts_per_page'] ?? 3); $count = max(self::MIN_COUNT, min(self::MAX_COUNT, $raw_count)); - $all_ids = CanonicalList::get($post_type, $orderby, $order, $exclude_sticky); + $all_ids = CanonicalList::build([ + 'postType' => $post_type, + 'orderBy' => $orderby, + 'order' => $order, + 'sticky' => $exclude_sticky ? 'exclude' : 'ignore', + ]); $resolved = $context_post ? (new SequentialResolver())->resolve($all_ids, $context_post, 'asc', $count) : array_slice($all_ids, 0, $count); diff --git a/tests/integration/CanonicalListTest.php b/tests/integration/CanonicalListTest.php index dacc7dd..443af6d 100644 --- a/tests/integration/CanonicalListTest.php +++ b/tests/integration/CanonicalListTest.php @@ -60,20 +60,6 @@ protected function tearDown(): void parent::tearDown(); } - public function test_returns_published_ids_ordered_by_date_asc_by_default(): void - { - $expected = [ - $this->post_ids[0], // Alpha 2024-01-01 - $this->post_ids[1], // Bravo 2024-01-02 - $this->post_ids[2], // Charlie 2024-01-03 - $this->post_ids[3], // Delta 2024-01-04 - $this->post_ids[5], // Foxtrot 2024-01-06 (Echo @ 2024-01-05 is draft) - $this->post_ids[6], // Golf 2024-01-07 - $this->post_ids[7], // Hotel 2024-01-08 - ]; - $this->assertSame($expected, CanonicalList::get('post')); - } - public function test_build_with_empty_attrs_returns_all_published_post_ids_in_natural_order(): void { $result = CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); @@ -217,28 +203,30 @@ public function test_build_with_search_filters_by_keyword(): void public function test_excludes_draft_posts(): void { - $this->assertNotContains($this->post_ids[4], CanonicalList::get('post')); + $result = CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); + $this->assertNotContains($this->post_ids[4], $result); } public function test_returns_empty_for_nonexistent_post_type(): void { - $this->assertSame([], CanonicalList::get('no_such_type')); + $this->assertSame([], CanonicalList::build(['postType' => 'no_such_type'])); } public function test_second_call_uses_cache_no_extra_query(): void { global $wpdb; - CanonicalList::get('post'); + CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); $queries_before = $wpdb->num_queries; - CanonicalList::get('post'); + CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); $this->assertSame($queries_before, $wpdb->num_queries); } public function test_cache_invalidates_when_post_is_created(): void { - $before = CanonicalList::get('post'); + $attrs = ['postType' => 'post', 'sticky' => 'ignore']; + $before = CanonicalList::build($attrs); $new_id = self::factory()->post->create([ 'post_title' => 'Zulu', @@ -247,7 +235,7 @@ public function test_cache_invalidates_when_post_is_created(): void 'post_status' => 'publish', ]); - $after = CanonicalList::get('post'); + $after = CanonicalList::build($attrs); $this->assertNotSame($before, $after); $this->assertContains($new_id, $after); @@ -266,33 +254,26 @@ public function test_respects_orderby_title_and_order_desc(): void $this->post_ids[1], // Bravo $this->post_ids[0], // Alpha ]; - $this->assertSame($expected, CanonicalList::get('post', 'title', 'DESC')); + $this->assertSame($expected, CanonicalList::build([ + 'postType' => 'post', + 'orderBy' => 'title', + 'order' => 'DESC', + 'sticky' => 'ignore', + ])); } - public function test_exclude_sticky_removes_sticky_ids_from_list(): void + public function test_build_cache_yields_same_list_across_calls_with_identical_attrs(): void { - $result = CanonicalList::get('post', 'date', 'ASC', true); - - foreach ($this->sticky_ids as $sticky_id) { - $this->assertNotContains($sticky_id, $result); - } - $this->assertContains($this->post_ids[0], $result, 'Non-sticky posts should remain.'); + $attrs = ['postType' => 'post', 'sticky' => 'ignore']; + $first = CanonicalList::build($attrs); + $second = CanonicalList::build($attrs); + $this->assertSame($first, $second); } - public function test_cache_keys_distinct_between_with_and_without_sticky(): void + public function test_build_cache_distinguishes_different_attrs(): void { - global $wpdb; - - $with_sticky = CanonicalList::get('post', 'date', 'ASC', false); - $queries_between = $wpdb->num_queries; - - $without_sticky = CanonicalList::get('post', 'date', 'ASC', true); - $this->assertGreaterThan( - $queries_between, - $wpdb->num_queries, - 'exclude_sticky=true must bypass the exclude_sticky=false cache entry.' - ); - - $this->assertNotSame($with_sticky, $without_sticky); + $include = CanonicalList::build(['postType' => 'post', 'sticky' => '']); + $ignore = CanonicalList::build(['postType' => 'post', 'sticky' => 'ignore']); + $this->assertNotSame($include, $ignore); // prepend differs } } From 09cb01a2647de4ddc26c2696d4ca105d27d4d859 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:35:20 +0200 Subject: [PATCH 09/17] refactor(query-filter): pass full attrs bag to CanonicalList::build --- includes/QueryFilter.php | 32 ++++++++------------------- tests/integration/QueryFilterTest.php | 28 +++++++++++------------ 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/includes/QueryFilter.php b/includes/QueryFilter.php index 6fd1425..1b146a3 100644 --- a/includes/QueryFilter.php +++ b/includes/QueryFilter.php @@ -38,14 +38,8 @@ final class QueryFilter /** @var bool Whether the currently-rendering core/query is our variation. */ private static bool $is_sequential = false; - /** @var string Native orderBy from the block ('date' or 'title'). */ - private static string $orderby = 'date'; - - /** @var string Native order from the block ('asc' or 'desc'). */ - private static string $order = 'asc'; - - /** @var bool Custom excludeSticky attribute from the block. */ - private static bool $exclude_sticky = false; + /** @var array Captured native query attrs from pre_render. */ + private static array $query_attrs = []; /** * Hook: pre_render_block (priority 10, 2 args) @@ -68,16 +62,14 @@ public function pre_render($pre_render, array $parsed_block) // not wipe the state we just armed, as they fire pre_render_block between // our core/query's pre_render and query_loop_block_query_vars consumption. self::$is_sequential = false; - self::$exclude_sticky = false; + self::$query_attrs = []; if (($parsed_block['attrs']['namespace'] ?? '') !== self::NAMESPACE) { return $pre_render; } self::$is_sequential = true; - self::$orderby = (string) ($parsed_block['attrs']['query']['orderBy'] ?? 'date'); - self::$order = (string) ($parsed_block['attrs']['query']['order'] ?? 'asc'); - self::$exclude_sticky = (bool) ($parsed_block['attrs']['query']['excludeSticky'] ?? false); + self::$query_attrs = (array) ($parsed_block['attrs']['query'] ?? []); return $pre_render; } @@ -95,21 +87,15 @@ public function filter_query_vars(array $query, WP_Block $block, int $page): arr } self::$is_sequential = false; - $exclude_sticky = self::$exclude_sticky; - self::$exclude_sticky = false; + $attrs = self::$query_attrs; + self::$query_attrs = []; - $post_type = (string) ($block->context['query']['postType'] ?? 'post'); $raw_count = (int) ($block->context['query']['perPage'] ?? 3); $count = max(self::MIN_COUNT, min(self::MAX_COUNT, $raw_count)); - // Build canonical list with the user's chosen sort order. - // Resolver always goes forward — the list order defines "next". - $all_ids = CanonicalList::build([ - 'postType' => $post_type, - 'orderBy' => self::$orderby, - 'order' => self::$order, - 'sticky' => $exclude_sticky ? 'exclude' : 'ignore', - ]); + // Build canonical list from the full native attrs bag; resolver + // always walks forward, so the list order defines "next". + $all_ids = CanonicalList::build($attrs); $current_id = (new ContextDetector())->current_post_id(); $resolved = $current_id === null diff --git a/tests/integration/QueryFilterTest.php b/tests/integration/QueryFilterTest.php index 698d220..f73ee99 100644 --- a/tests/integration/QueryFilterTest.php +++ b/tests/integration/QueryFilterTest.php @@ -93,14 +93,15 @@ public function test_pre_render_arms_flag_and_captures_attrs(): void $parsed = $this->make_parsed_block(); $parsed['attrs']['query']['orderBy'] = 'title'; $parsed['attrs']['query']['order'] = 'desc'; - $parsed['attrs']['query']['excludeSticky'] = true; + $parsed['attrs']['query']['sticky'] = 'exclude'; $this->filter->pre_render(null, $parsed); $this->assertTrue($this->get_static('is_sequential')); - $this->assertSame('title', $this->get_static('orderby')); - $this->assertSame('desc', $this->get_static('order')); - $this->assertTrue($this->get_static('exclude_sticky')); + $attrs = $this->get_static('query_attrs'); + $this->assertSame('title', $attrs['orderBy']); + $this->assertSame('desc', $attrs['order']); + $this->assertSame('exclude', $attrs['sticky']); } public function test_pre_render_arms_flag_on_non_singular_context(): void @@ -129,7 +130,7 @@ public function test_pre_render_resets_armed_state_when_followed_by_non_matching $this->filter->pre_render(null, $foreign); $this->assertFalse($this->get_static('is_sequential')); - $this->assertFalse($this->get_static('exclude_sticky')); + $this->assertSame([], $this->get_static('query_attrs')); } public function test_pre_render_preserves_armed_state_across_inner_non_query_blocks(): void @@ -140,17 +141,17 @@ public function test_pre_render_preserves_armed_state_across_inner_non_query_blo // query_loop_block_query_vars (consume) must NOT wipe the armed state. // Regression guard against the v1.2.1 bug fixed by v1.2.2. $parsed = $this->make_parsed_block(); - $parsed['attrs']['query']['excludeSticky'] = true; + $parsed['attrs']['query']['sticky'] = 'exclude'; $this->filter->pre_render(null, $parsed); $this->assertTrue($this->get_static('is_sequential')); - $this->assertTrue($this->get_static('exclude_sticky')); + $this->assertSame('exclude', $this->get_static('query_attrs')['sticky']); foreach (['core/post-template', 'core/post-title', 'core/paragraph'] as $inner) { $this->filter->pre_render(null, ['blockName' => $inner, 'attrs' => []]); } $this->assertTrue($this->get_static('is_sequential')); - $this->assertTrue($this->get_static('exclude_sticky')); + $this->assertSame('exclude', $this->get_static('query_attrs')['sticky']); } // ------------------------------------------------------------------ @@ -247,11 +248,11 @@ public function test_filter_query_vars_non_singular_empty_canonical_forces_post_ public function test_filter_query_vars_exclude_sticky_filters_sticky_from_result(): void { - // v1.2.0: query.excludeSticky=true must propagate to CanonicalList::get. + // v1.3.0: query.sticky='exclude' must propagate to CanonicalList::build. $this->go_to(home_url('/')); $parsed = $this->make_parsed_block(); - $parsed['attrs']['query']['excludeSticky'] = true; + $parsed['attrs']['query']['sticky'] = 'exclude'; $parsed['attrs']['query']['perPage'] = 10; $this->filter->pre_render(null, $parsed); @@ -289,7 +290,7 @@ public function test_full_render_pipeline_preserves_armed_state_across_inner_blo add_filter('query_loop_block_query_vars', [$this->filter, 'filter_query_vars'], 10, 3); try { - $markup = '' + $markup = '' . '' . '' . '' @@ -426,6 +427,7 @@ private function make_parsed_block(array $overrides = []): array 'perPage' => 3, 'orderBy' => 'date', 'order' => 'asc', + 'sticky' => 'ignore', ], ], 'innerBlocks' => [], @@ -485,9 +487,7 @@ private function reset_static_state(): void { $defaults = [ 'is_sequential' => false, - 'orderby' => 'date', - 'order' => 'asc', - 'exclude_sticky' => false, + 'query_attrs' => [], ]; foreach ($defaults as $name => $value) { $this->static_prop($name)->setValue(null, $value); From b11163ef2a2090807f23e3c9b1faa076e3b1d6fb Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:36:14 +0200 Subject: [PATCH 10/17] refactor(query-filter): REST endpoint accepts single JSON attrs param --- includes/QueryFilter.php | 24 +++++++++++++-------- tests/integration/QueryFilterTest.php | 31 ++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/includes/QueryFilter.php b/includes/QueryFilter.php index 1b146a3..b943681 100644 --- a/includes/QueryFilter.php +++ b/includes/QueryFilter.php @@ -137,18 +137,24 @@ public function filter_rest_query(string $post_type, array $args, WP_REST_Reques return $args; } - $orderby = (string) ($request->get_param('sequential_orderby') ?? 'date'); - $order = (string) ($request->get_param('sequential_order') ?? 'asc'); - $exclude_sticky = (bool) $request->get_param('sequential_exclude_sticky'); + $raw_attrs = $request->get_param('sequential_query_attrs'); + $attrs = []; + if (is_string($raw_attrs) && $raw_attrs !== '') { + $decoded = json_decode($raw_attrs, true); + if (is_array($decoded)) { + $attrs = $decoded; + } + } + + // REST route is post-type scoped; respect that if attrs omit postType. + if (empty($attrs['postType'])) { + $attrs['postType'] = $post_type; + } + $raw_count = (int) ($args['posts_per_page'] ?? 3); $count = max(self::MIN_COUNT, min(self::MAX_COUNT, $raw_count)); - $all_ids = CanonicalList::build([ - 'postType' => $post_type, - 'orderBy' => $orderby, - 'order' => $order, - 'sticky' => $exclude_sticky ? 'exclude' : 'ignore', - ]); + $all_ids = CanonicalList::build($attrs); $resolved = $context_post ? (new SequentialResolver())->resolve($all_ids, $context_post, 'asc', $count) : array_slice($all_ids, 0, $count); diff --git a/tests/integration/QueryFilterTest.php b/tests/integration/QueryFilterTest.php index f73ee99..8bdd34c 100644 --- a/tests/integration/QueryFilterTest.php +++ b/tests/integration/QueryFilterTest.php @@ -362,8 +362,12 @@ public function test_rest_filter_sequential_resolution_with_context_post(): void $request = new WP_REST_Request('GET', '/wp/v2/posts'); $request->set_param('sequential_block', '1'); $request->set_param('sequential_context_post', (string) $this->post_ids[2]); - $request->set_param('sequential_orderby', 'date'); - $request->set_param('sequential_order', 'asc'); + $request->set_param('sequential_query_attrs', json_encode([ + 'postType' => 'post', + 'orderBy' => 'date', + 'order' => 'asc', + 'sticky' => 'ignore', + ])); $result = $this->filter->filter_rest_query( 'post', @@ -386,6 +390,10 @@ public function test_rest_filter_canonical_fallback_with_marker_only(): void // Editor-template preview case: marker present, no context post. $request = new WP_REST_Request('GET', '/wp/v2/posts'); $request->set_param('sequential_block', '1'); + $request->set_param('sequential_query_attrs', json_encode([ + 'postType' => 'post', + 'sticky' => 'ignore', + ])); $result = $this->filter->filter_rest_query('post', ['posts_per_page' => 3], $request); @@ -400,7 +408,10 @@ public function test_rest_filter_exclude_sticky_propagates(): void { $request = new WP_REST_Request('GET', '/wp/v2/posts'); $request->set_param('sequential_block', '1'); - $request->set_param('sequential_exclude_sticky', '1'); + $request->set_param('sequential_query_attrs', json_encode([ + 'postType' => 'post', + 'sticky' => 'exclude', + ])); $result = $this->filter->filter_rest_query('post', ['posts_per_page' => 10], $request); @@ -410,6 +421,20 @@ public function test_rest_filter_exclude_sticky_propagates(): void $this->assertCount(4, $result['post__in']); } + public function test_rest_filter_decodes_sequential_query_attrs_json(): void + { + $request = new WP_REST_Request('GET', '/wp/v2/posts'); + $request->set_param('sequential_block', '1'); + $request->set_param('sequential_query_attrs', json_encode(['sticky' => 'only'])); + $request->set_param('sequential_context_post', (string) $this->post_ids[1]); // a sticky + + // sticky='only' canonical = [post_ids[1], post_ids[4]]. Anchor=post_ids[1] + // → next sticky = post_ids[4]. + $result = $this->filter->filter_rest_query('post', ['posts_per_page' => 3], $request); + + $this->assertSame($this->post_ids[4], $result['post__in'][0]); + } + // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ From 918f504a39599e9483ff3e4f124d13148c355768 Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:36:42 +0200 Subject: [PATCH 11/17] refactor(editor): delete inspector-controls.js, drop excludeSticky default --- build/index.asset.php | 2 +- build/index.js | 2 +- src/index.js | 1 - src/inspector-controls.js | 79 --------------------------------------- src/variation.js | 1 - 5 files changed, 2 insertions(+), 83 deletions(-) delete mode 100644 src/inspector-controls.js diff --git a/build/index.asset.php b/build/index.asset.php index 77c6b8e..e55eb34 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '9416f37d7ab747ea1de8'); + array('react', 'wp-api-fetch', 'wp-blocks', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => 'd21505b3ec1d74beffd6'); diff --git a/build/index.js b/build/index.js index 9f02dd9..798623e 100644 --- a/build/index.js +++ b/build/index.js @@ -1 +1 @@ -(()=>{"use strict";var e={n:t=>{var o=t&&t.__esModule?()=>t.default:()=>t;return e.d(o,{a:o}),o},d:(t,o)=>{for(var n in o)e.o(o,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:o[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.blocks,o=window.wp.i18n,n="next-posts-block/query";(0,t.registerBlockVariation)("core/query",{name:"next-posts",title:(0,o.__)("Next Posts","next-posts-block"),description:(0,o.__)("Displays the N posts that sequentially follow (or precede) the current post, with wrap-around.","next-posts-block"),icon:"list-view",category:"theme",keywords:[(0,o.__)("next","next-posts-block"),(0,o.__)("previous","next-posts-block"),(0,o.__)("sequence","next-posts-block"),(0,o.__)("related","next-posts-block")],scope:["inserter"],attributes:{namespace:n,query:{postType:"post",perPage:3,inherit:!1,offset:0,orderBy:"date",order:"asc",excludeSticky:!1}},innerBlocks:[["core/post-template",{},[["core/post-featured-image"],["core/post-title",{level:3,isLink:!0}],["core/post-excerpt"],["core/read-more"]]]],isActive:({namespace:e})=>e===n});const r=window.React,s=window.wp.hooks,c=window.wp.compose,i=window.wp.blockEditor,l=window.wp.components,u=window.wp.element,a=(0,c.createHigherOrderComponent)(e=>t=>{if("core/query"!==t.name||"next-posts-block/query"!==t.attributes?.namespace)return(0,r.createElement)(e,{...t});const n=Boolean(t.attributes?.query?.excludeSticky);return(0,r.createElement)(u.Fragment,null,(0,r.createElement)(e,{...t}),(0,r.createElement)(i.InspectorControls,null,(0,r.createElement)(l.PanelBody,{title:(0,o.__)("Sequential settings","next-posts-block"),initialOpen:!0},(0,r.createElement)(l.ToggleControl,{__nextHasNoMarginBottom:!0,label:(0,o.__)("Exclude sticky posts from the sequence","next-posts-block"),help:(0,o.__)("Sticky posts are removed from the canonical list used to build the sequence.","next-posts-block"),checked:n,onChange:e=>{t.setAttributes({query:{...t.attributes.query,excludeSticky:e}})}}))))},"withSequentialSettings");(0,s.addFilter)("editor.BlockEdit","next-posts-block/sequential-settings",a);const d=window.wp.apiFetch;var p=e.n(d);const m=window.wp.data,y=/\/wp\/v2\/([a-z0-9_-]+)(\?|$)/i;p().use((e,t)=>{const o=e.url||e.path||"";if(!o.match(y))return t(e);const n=function e(t){for(const r of t){var o,n;if("core/query"===r.name&&"next-posts-block/query"===r.attributes?.namespace)return{orderby:null!==(o=r.attributes.query?.orderBy)&&void 0!==o?o:"date",order:null!==(n=r.attributes.query?.order)&&void 0!==n?n:"asc",excludeSticky:Boolean(r.attributes.query?.excludeSticky)};if(r.innerBlocks?.length){const t=e(r.innerBlocks);if(t)return t}}return null}(null!==(r=(0,m.select)("core/block-editor")?.getBlocks())&&void 0!==r?r:[]);var r;if(!n)return t(e);if(o.includes("sequential_block="))return t(e);const s=(0,m.select)("core/editor")?.getCurrentPostId(),c=["sequential_block=1",`sequential_orderby=${encodeURIComponent(n.orderby)}`,`sequential_order=${encodeURIComponent(n.order)}`];n.excludeSticky&&c.push("sequential_exclude_sticky=1"),s&&c.push(`sequential_context_post=${s}`);const i=o.includes("?")?"&":"?",l=`${o}${i}${c.join("&")}`;return t({...e,url:e.url?l:void 0,path:e.path?l:void 0})});const b="next-posts-block-hide-query-type",k=[/sticky\s+posts?/i,/publications?\s+épinglées?/i,/publicaciones?\s+fijadas?/i],f="data-sequential-hidden";function w(e){const t=e.querySelector("label, legend, .components-base-control__label");if(!t)return!1;const o=t.textContent.trim();return k.some(e=>e.test(o))}function q(){const e=document.querySelectorAll(".editor-block-inspector .components-tools-panel-item, .block-editor-block-inspector .components-tools-panel-item");for(const t of e)t.hasAttribute(f)||w(t)&&(t.setAttribute(f,"1"),t.style.display="none")}const h=(0,c.createHigherOrderComponent)(e=>t=>{const o="core/query"===t.name&&"next-posts-block/query"===t.attributes?.namespace,n=!0===t.attributes?.query?.inherit;return(0,u.useEffect)(()=>{o&&n&&t.setAttributes({query:{...t.attributes.query,inherit:!1}})},[o,n]),(0,u.useEffect)(()=>{if(!o||!t.isSelected)return;!function(){if(document.getElementById(b))return;const e=document.createElement("style");e.id=b,e.textContent=".components-tools-panel-item:has(.components-toggle-group-control){display:none!important}",document.head.appendChild(e)}();const e=function(){q();const e=document.querySelector(".interface-interface-skeleton__sidebar")||document.body,t=new MutationObserver(e=>{e.some(e=>e.addedNodes.length>0||e.removedNodes.length>0)&&q()});return t.observe(e,{childList:!0,subtree:!0}),()=>{t.disconnect(),function(){const e=document.querySelectorAll(`[${f}]`);for(const t of e)t.style.display="",t.removeAttribute(f)}()}}();return()=>{!function(){const e=document.getElementById(b);e&&e.remove()}(),e()}},[o,t.isSelected]),(0,r.createElement)(e,{...t})},"withLockedCustomQuery");(0,s.addFilter)("editor.BlockEdit","next-posts-block/lock-custom-query",h)})(); \ No newline at end of file +(()=>{"use strict";var e={n:t=>{var o=t&&t.__esModule?()=>t.default:()=>t;return e.d(o,{a:o}),o},d:(t,o)=>{for(var n in o)e.o(o,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:o[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.blocks,o=window.wp.i18n,n="next-posts-block/query";(0,t.registerBlockVariation)("core/query",{name:"next-posts",title:(0,o.__)("Next Posts","next-posts-block"),description:(0,o.__)("Displays the N posts that sequentially follow (or precede) the current post, with wrap-around.","next-posts-block"),icon:"list-view",category:"theme",keywords:[(0,o.__)("next","next-posts-block"),(0,o.__)("previous","next-posts-block"),(0,o.__)("sequence","next-posts-block"),(0,o.__)("related","next-posts-block")],scope:["inserter"],attributes:{namespace:n,query:{postType:"post",perPage:3,inherit:!1,offset:0,orderBy:"date",order:"asc"}},innerBlocks:[["core/post-template",{},[["core/post-featured-image"],["core/post-title",{level:3,isLink:!0}],["core/post-excerpt"],["core/read-more"]]]],isActive:({namespace:e})=>e===n});const r=window.wp.apiFetch;var s=e.n(r);const c=window.wp.data,i=/\/wp\/v2\/([a-z0-9_-]+)(\?|$)/i;s().use((e,t)=>{const o=e.url||e.path||"";if(!o.match(i))return t(e);const n=function e(t){for(const r of t){var o,n;if("core/query"===r.name&&"next-posts-block/query"===r.attributes?.namespace)return{orderby:null!==(o=r.attributes.query?.orderBy)&&void 0!==o?o:"date",order:null!==(n=r.attributes.query?.order)&&void 0!==n?n:"asc",excludeSticky:Boolean(r.attributes.query?.excludeSticky)};if(r.innerBlocks?.length){const t=e(r.innerBlocks);if(t)return t}}return null}(null!==(r=(0,c.select)("core/block-editor")?.getBlocks())&&void 0!==r?r:[]);var r;if(!n)return t(e);if(o.includes("sequential_block="))return t(e);const s=(0,c.select)("core/editor")?.getCurrentPostId(),l=["sequential_block=1",`sequential_orderby=${encodeURIComponent(n.orderby)}`,`sequential_order=${encodeURIComponent(n.order)}`];n.excludeSticky&&l.push("sequential_exclude_sticky=1"),s&&l.push(`sequential_context_post=${s}`);const u=o.includes("?")?"&":"?",a=`${o}${u}${l.join("&")}`;return t({...e,url:e.url?a:void 0,path:e.path?a:void 0})});const l=window.React,u=window.wp.hooks,a=window.wp.compose,d=window.wp.element,p="next-posts-block-hide-query-type",y=[/sticky\s+posts?/i,/publications?\s+épinglées?/i,/publicaciones?\s+fijadas?/i],m="data-sequential-hidden";function b(e){const t=e.querySelector("label, legend, .components-base-control__label");if(!t)return!1;const o=t.textContent.trim();return y.some(e=>e.test(o))}function f(){const e=document.querySelectorAll(".editor-block-inspector .components-tools-panel-item, .block-editor-block-inspector .components-tools-panel-item");for(const t of e)t.hasAttribute(m)||b(t)&&(t.setAttribute(m,"1"),t.style.display="none")}const k=(0,a.createHigherOrderComponent)(e=>t=>{const o="core/query"===t.name&&"next-posts-block/query"===t.attributes?.namespace,n=!0===t.attributes?.query?.inherit;return(0,d.useEffect)(()=>{o&&n&&t.setAttributes({query:{...t.attributes.query,inherit:!1}})},[o,n]),(0,d.useEffect)(()=>{if(!o||!t.isSelected)return;!function(){if(document.getElementById(p))return;const e=document.createElement("style");e.id=p,e.textContent=".components-tools-panel-item:has(.components-toggle-group-control){display:none!important}",document.head.appendChild(e)}();const e=function(){f();const e=document.querySelector(".interface-interface-skeleton__sidebar")||document.body,t=new MutationObserver(e=>{e.some(e=>e.addedNodes.length>0||e.removedNodes.length>0)&&f()});return t.observe(e,{childList:!0,subtree:!0}),()=>{t.disconnect(),function(){const e=document.querySelectorAll(`[${m}]`);for(const t of e)t.style.display="",t.removeAttribute(m)}()}}();return()=>{!function(){const e=document.getElementById(p);e&&e.remove()}(),e()}},[o,t.isSelected]),(0,l.createElement)(e,{...t})},"withLockedCustomQuery");(0,u.addFilter)("editor.BlockEdit","next-posts-block/lock-custom-query",k)})(); \ No newline at end of file diff --git a/src/index.js b/src/index.js index 2c8a1cb..1328ddc 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,5 @@ */ import './variation'; -import './inspector-controls'; import './rest-middleware'; import './lock-custom-query'; diff --git a/src/inspector-controls.js b/src/inspector-controls.js deleted file mode 100644 index 9a5d355..0000000 --- a/src/inspector-controls.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Custom InspectorControls panel for the Next Posts variation. - * - * Adds a "Sequential settings" panel with a single ToggleControl: - * "Exclude sticky posts from the sequence" → writes to query.excludeSticky. - * - * Scoped to our variation via a BlockEdit filter. Other core/query blocks - * are left untouched. - * - * Pairs with the server-side CanonicalList::get($post_type, …, $exclude_sticky) - * which rebuilds the list without sticky IDs when the flag is true. - */ - -import { addFilter } from '@wordpress/hooks'; -import { createHigherOrderComponent } from '@wordpress/compose'; -import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody, ToggleControl } from '@wordpress/components'; -import { Fragment } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -const NAMESPACE = 'next-posts-block/query'; - -const withSequentialSettings = createHigherOrderComponent( - ( BlockEdit ) => ( props ) => { - const isOurVariation = - props.name === 'core/query' && - props.attributes?.namespace === NAMESPACE; - - if ( ! isOurVariation ) { - return ; - } - - const excludeSticky = Boolean( - props.attributes?.query?.excludeSticky - ); - - const setExcludeSticky = ( value ) => { - props.setAttributes( { - query: { ...props.attributes.query, excludeSticky: value }, - } ); - }; - - return ( - - - - - - - - - ); - }, - 'withSequentialSettings' -); - -addFilter( - 'editor.BlockEdit', - 'next-posts-block/sequential-settings', - withSequentialSettings -); diff --git a/src/variation.js b/src/variation.js index edad79a..3155550 100644 --- a/src/variation.js +++ b/src/variation.js @@ -36,7 +36,6 @@ registerBlockVariation( 'core/query', { offset: 0, orderBy: 'date', order: 'asc', - excludeSticky: false, }, }, innerBlocks: [ From 5c023a93bc76dfe23e7ec8493974778f165cd7fc Mon Sep 17 00:00:00 2001 From: Jensen SIU Date: Mon, 20 Apr 2026 11:37:31 +0200 Subject: [PATCH 12/17] refactor(editor): remove sticky-hide section from lock-custom-query.js --- build/index.asset.php | 2 +- build/index.js | 2 +- src/lock-custom-query.js | 104 ++------------------------------------- 3 files changed, 7 insertions(+), 101 deletions(-) diff --git a/build/index.asset.php b/build/index.asset.php index e55eb34..7242e2d 100644 --- a/build/index.asset.php +++ b/build/index.asset.php @@ -1 +1 @@ - array('react', 'wp-api-fetch', 'wp-blocks', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => 'd21505b3ec1d74beffd6'); + array('react', 'wp-api-fetch', 'wp-blocks', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '5d027e352e6313c5786b'); diff --git a/build/index.js b/build/index.js index 798623e..763f00f 100644 --- a/build/index.js +++ b/build/index.js @@ -1 +1 @@ -(()=>{"use strict";var e={n:t=>{var o=t&&t.__esModule?()=>t.default:()=>t;return e.d(o,{a:o}),o},d:(t,o)=>{for(var n in o)e.o(o,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:o[n]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.blocks,o=window.wp.i18n,n="next-posts-block/query";(0,t.registerBlockVariation)("core/query",{name:"next-posts",title:(0,o.__)("Next Posts","next-posts-block"),description:(0,o.__)("Displays the N posts that sequentially follow (or precede) the current post, with wrap-around.","next-posts-block"),icon:"list-view",category:"theme",keywords:[(0,o.__)("next","next-posts-block"),(0,o.__)("previous","next-posts-block"),(0,o.__)("sequence","next-posts-block"),(0,o.__)("related","next-posts-block")],scope:["inserter"],attributes:{namespace:n,query:{postType:"post",perPage:3,inherit:!1,offset:0,orderBy:"date",order:"asc"}},innerBlocks:[["core/post-template",{},[["core/post-featured-image"],["core/post-title",{level:3,isLink:!0}],["core/post-excerpt"],["core/read-more"]]]],isActive:({namespace:e})=>e===n});const r=window.wp.apiFetch;var s=e.n(r);const c=window.wp.data,i=/\/wp\/v2\/([a-z0-9_-]+)(\?|$)/i;s().use((e,t)=>{const o=e.url||e.path||"";if(!o.match(i))return t(e);const n=function e(t){for(const r of t){var o,n;if("core/query"===r.name&&"next-posts-block/query"===r.attributes?.namespace)return{orderby:null!==(o=r.attributes.query?.orderBy)&&void 0!==o?o:"date",order:null!==(n=r.attributes.query?.order)&&void 0!==n?n:"asc",excludeSticky:Boolean(r.attributes.query?.excludeSticky)};if(r.innerBlocks?.length){const t=e(r.innerBlocks);if(t)return t}}return null}(null!==(r=(0,c.select)("core/block-editor")?.getBlocks())&&void 0!==r?r:[]);var r;if(!n)return t(e);if(o.includes("sequential_block="))return t(e);const s=(0,c.select)("core/editor")?.getCurrentPostId(),l=["sequential_block=1",`sequential_orderby=${encodeURIComponent(n.orderby)}`,`sequential_order=${encodeURIComponent(n.order)}`];n.excludeSticky&&l.push("sequential_exclude_sticky=1"),s&&l.push(`sequential_context_post=${s}`);const u=o.includes("?")?"&":"?",a=`${o}${u}${l.join("&")}`;return t({...e,url:e.url?a:void 0,path:e.path?a:void 0})});const l=window.React,u=window.wp.hooks,a=window.wp.compose,d=window.wp.element,p="next-posts-block-hide-query-type",y=[/sticky\s+posts?/i,/publications?\s+épinglées?/i,/publicaciones?\s+fijadas?/i],m="data-sequential-hidden";function b(e){const t=e.querySelector("label, legend, .components-base-control__label");if(!t)return!1;const o=t.textContent.trim();return y.some(e=>e.test(o))}function f(){const e=document.querySelectorAll(".editor-block-inspector .components-tools-panel-item, .block-editor-block-inspector .components-tools-panel-item");for(const t of e)t.hasAttribute(m)||b(t)&&(t.setAttribute(m,"1"),t.style.display="none")}const k=(0,a.createHigherOrderComponent)(e=>t=>{const o="core/query"===t.name&&"next-posts-block/query"===t.attributes?.namespace,n=!0===t.attributes?.query?.inherit;return(0,d.useEffect)(()=>{o&&n&&t.setAttributes({query:{...t.attributes.query,inherit:!1}})},[o,n]),(0,d.useEffect)(()=>{if(!o||!t.isSelected)return;!function(){if(document.getElementById(p))return;const e=document.createElement("style");e.id=p,e.textContent=".components-tools-panel-item:has(.components-toggle-group-control){display:none!important}",document.head.appendChild(e)}();const e=function(){f();const e=document.querySelector(".interface-interface-skeleton__sidebar")||document.body,t=new MutationObserver(e=>{e.some(e=>e.addedNodes.length>0||e.removedNodes.length>0)&&f()});return t.observe(e,{childList:!0,subtree:!0}),()=>{t.disconnect(),function(){const e=document.querySelectorAll(`[${m}]`);for(const t of e)t.style.display="",t.removeAttribute(m)}()}}();return()=>{!function(){const e=document.getElementById(p);e&&e.remove()}(),e()}},[o,t.isSelected]),(0,l.createElement)(e,{...t})},"withLockedCustomQuery");(0,u.addFilter)("editor.BlockEdit","next-posts-block/lock-custom-query",k)})(); \ No newline at end of file +(()=>{"use strict";var e={n:t=>{var o=t&&t.__esModule?()=>t.default:()=>t;return e.d(o,{a:o}),o},d:(t,o)=>{for(var r in o)e.o(o,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:o[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.blocks,o=window.wp.i18n,r="next-posts-block/query";(0,t.registerBlockVariation)("core/query",{name:"next-posts",title:(0,o.__)("Next Posts","next-posts-block"),description:(0,o.__)("Displays the N posts that sequentially follow (or precede) the current post, with wrap-around.","next-posts-block"),icon:"list-view",category:"theme",keywords:[(0,o.__)("next","next-posts-block"),(0,o.__)("previous","next-posts-block"),(0,o.__)("sequence","next-posts-block"),(0,o.__)("related","next-posts-block")],scope:["inserter"],attributes:{namespace:r,query:{postType:"post",perPage:3,inherit:!1,offset:0,orderBy:"date",order:"asc"}},innerBlocks:[["core/post-template",{},[["core/post-featured-image"],["core/post-title",{level:3,isLink:!0}],["core/post-excerpt"],["core/read-more"]]]],isActive:({namespace:e})=>e===r});const n=window.wp.apiFetch;var s=e.n(n);const c=window.wp.data,i=/\/wp\/v2\/([a-z0-9_-]+)(\?|$)/i;s().use((e,t)=>{const o=e.url||e.path||"";if(!o.match(i))return t(e);const r=function e(t){for(const n of t){var o,r;if("core/query"===n.name&&"next-posts-block/query"===n.attributes?.namespace)return{orderby:null!==(o=n.attributes.query?.orderBy)&&void 0!==o?o:"date",order:null!==(r=n.attributes.query?.order)&&void 0!==r?r:"asc",excludeSticky:Boolean(n.attributes.query?.excludeSticky)};if(n.innerBlocks?.length){const t=e(n.innerBlocks);if(t)return t}}return null}(null!==(n=(0,c.select)("core/block-editor")?.getBlocks())&&void 0!==n?n:[]);var n;if(!r)return t(e);if(o.includes("sequential_block="))return t(e);const s=(0,c.select)("core/editor")?.getCurrentPostId(),l=["sequential_block=1",`sequential_orderby=${encodeURIComponent(r.orderby)}`,`sequential_order=${encodeURIComponent(r.order)}`];r.excludeSticky&&l.push("sequential_exclude_sticky=1"),s&&l.push(`sequential_context_post=${s}`);const u=o.includes("?")?"&":"?",a=`${o}${u}${l.join("&")}`;return t({...e,url:e.url?a:void 0,path:e.path?a:void 0})});const l=window.React,u=window.wp.hooks,a=window.wp.compose,d=window.wp.element,p="next-posts-block-hide-query-type",y=(0,a.createHigherOrderComponent)(e=>t=>{const o="core/query"===t.name&&"next-posts-block/query"===t.attributes?.namespace,r=!0===t.attributes?.query?.inherit;return(0,d.useEffect)(()=>{o&&r&&t.setAttributes({query:{...t.attributes.query,inherit:!1}})},[o,r]),(0,d.useEffect)(()=>{if(o&&t.isSelected)return function(){if(document.getElementById(p))return;const e=document.createElement("style");e.id=p,e.textContent=".components-tools-panel-item:has(.components-toggle-group-control){display:none!important}",document.head.appendChild(e)}(),()=>{!function(){const e=document.getElementById(p);e&&e.remove()}()}},[o,t.isSelected]),(0,l.createElement)(e,{...t})},"withLockedCustomQuery");(0,u.addFilter)("editor.BlockEdit","next-posts-block/lock-custom-query",y)})(); \ No newline at end of file diff --git a/src/lock-custom-query.js b/src/lock-custom-query.js index da61499..aae3fd2 100644 --- a/src/lock-custom-query.js +++ b/src/lock-custom-query.js @@ -1,22 +1,10 @@ /** - * Locks native core/query controls that conflict with Next Posts semantics. + * Locks the native core/query Query Type toggle so our variation stays on + * inherit=false. Native sticky and filter controls are intentionally left + * visible — the server now honors them verbatim (see CanonicalList::build). * - * 1. Query Type toggle (v1.1.0): locked to "Custom" (inherit: false) so the - * plugin's server-side filter cannot be bypassed. - * - Data integrity: effect coerces inherit=true back to false. - * - UI: CSS :has() rule hides the lone ToggleGroupControl ToolsPanelItem. - * - * 2. Sticky posts SelectControl (v1.2.0): hidden and neutralized. Users - * configure sticky exclusion via our own ToggleControl in - * inspector-controls.js (query.excludeSticky). Server always forces - * ignore_sticky_posts=1 and clears post__not_in so the native 4-option - * control has no effect even if it leaks through. - * - UI: MutationObserver + label-based match. Robust across WP versions - * and locales (matches "sticky posts" / "publications épinglées" / - * explicit label fallback). On deselect, hidden items are restored. - * - * Both hides are best-effort on the UI layer; the data layer enforcement - * (inherit=false + ignore_sticky_posts=1) remains authoritative regardless. + * - Data integrity: effect coerces inherit=true back to false. + * - UI: CSS :has() rule hides the lone ToggleGroupControl ToolsPanelItem. */ import { addFilter } from '@wordpress/hooks'; @@ -25,10 +13,6 @@ import { useEffect } from '@wordpress/element'; const NAMESPACE = 'next-posts-block/query'; -/* ------------------------------------------------------------------ */ -/* (1) Query Type toggle lock */ -/* ------------------------------------------------------------------ */ - const QUERY_TYPE_STYLE_ID = 'next-posts-block-hide-query-type'; const QUERY_TYPE_HIDE_RULE = `.components-tools-panel-item:has(.components-toggle-group-control){display:none!important}`; @@ -45,80 +29,6 @@ function removeQueryTypeStyle() { if ( el ) el.remove(); } -/* ------------------------------------------------------------------ */ -/* (2) Sticky posts SelectControl hide */ -/* ------------------------------------------------------------------ */ - -// Label patterns matched against the ToolsPanelItem's own