Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d38b615
feat(canonical-list): add build() with attrs bag API (ignore sticky o…
seangoogoo Apr 20, 2026
3c7836b
feat(canonical-list): sticky mode 'include' with filter-scope interse…
seangoogoo Apr 20, 2026
eebad35
feat(canonical-list): sticky mode 'exclude'
seangoogoo Apr 20, 2026
68b9c11
feat(canonical-list): sticky mode 'only'
seangoogoo Apr 20, 2026
0fa5d5c
feat(canonical-list): honor native taxQuery filter
seangoogoo Apr 20, 2026
b87ee65
feat(canonical-list): honor native author filter (CSV user IDs)
seangoogoo Apr 20, 2026
2d4c24f
feat(canonical-list): honor native search filter
seangoogoo Apr 20, 2026
df150ec
refactor(canonical-list): remove legacy ::get(), migrate tests to ::b…
seangoogoo Apr 20, 2026
09cb01a
refactor(query-filter): pass full attrs bag to CanonicalList::build
seangoogoo Apr 20, 2026
b11163e
refactor(query-filter): REST endpoint accepts single JSON attrs param
seangoogoo Apr 20, 2026
918f504
refactor(editor): delete inspector-controls.js, drop excludeSticky de…
seangoogoo Apr 20, 2026
5c023a9
refactor(editor): remove sticky-hide section from lock-custom-query.js
seangoogoo Apr 20, 2026
19681c1
refactor(editor): REST middleware sends full attrs as single JSON param
seangoogoo Apr 20, 2026
afff206
i18n: remove obsolete sticky-exclude strings, regenerate .mo and .json
seangoogoo Apr 20, 2026
bb114bd
test: cover filter × sticky combinations and anchor-outside-list fall…
seangoogoo Apr 20, 2026
c70f051
docs: changelog 1.3.0 + agent-handoff state update
seangoogoo Apr 20, 2026
c78dab4
chore: bump version to 1.3.0
seangoogoo Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '9416f37d7ab747ea1de8');
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-blocks', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '142c185eb9e8b0c1f123');
2 changes: 1 addition & 1 deletion build/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

174 changes: 143 additions & 31 deletions includes/CanonicalList.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,59 +17,171 @@ final class CanonicalList

private const ALLOWED_ORDERBY = ['date', 'title'];
private const ALLOWED_ORDER = ['ASC', 'DESC'];
private const ALLOWED_STICKY = ['', 'ignore', 'exclude', 'only'];

/**
* @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.
* @param array<string, mixed> $query_attrs Block `query` attributes bag.
* @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)) {
public static function build(array $query_attrs): array
{
$normalized = self::normalize($query_attrs);
if ($normalized === null) {
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}";
$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<string, mixed> $query_attrs
* @return array<string, mixed>|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'] ?? '');

$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)),
static fn($id) => $id > 0
));

$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,
'author' => $author,
'search' => $search,
];
}

/**
* @param array<string, mixed> $n Normalized attrs.
* @return array<string, mixed> WP_Query args.
*/
private static function build_query_args(array $n): array
{
$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,
'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,
];

if ($exclude_sticky) {
$sticky_ids = get_option('sticky_posts', []);
if (!empty($sticky_ids)) {
$args['post__not_in'] = array_map('intval', (array) $sticky_ids);
if (!empty($n['author'])) {
$args['author__in'] = $n['author'];
}

if ($n['search'] !== '') {
$args['s'] = $n['search'];
}

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

$query = new \WP_Query($args);
return $args;
}

/**
* @param int[] $ids
* @param string $mode
* @param array<string, mixed> $args
* @return int[]
*/
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);
}

if ($mode === 'exclude') {
if (empty($sticky_option)) {
return $ids;
}
return array_values(array_diff($ids, $sticky_option));
}

if ($mode === 'only') {
if (empty($sticky_option)) {
return [];
}
return array_values(array_intersect($ids, $sticky_option));
}

$ids = array_map('intval', $query->posts);
wp_cache_set($cache_key, $ids, self::CACHE_GROUP, self::CACHE_TTL);
return $ids;
}

}
46 changes: 24 additions & 22 deletions includes/QueryFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> Captured native query attrs from pre_render. */
private static array $query_attrs = [];

/**
* Hook: pre_render_block (priority 10, 2 args)
Expand All @@ -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;
}
Expand All @@ -95,16 +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::get($post_type, self::$orderby, self::$order, $exclude_sticky);
// 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
Expand Down Expand Up @@ -146,13 +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::get($post_type, $orderby, $order, $exclude_sticky);
$all_ids = CanonicalList::build($attrs);
$resolved = $context_post
? (new SequentialResolver())->resolve($all_ids, $context_post, 'asc', $count)
: array_slice($all_ids, 0, $count);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"translation-revision-date":"2026-04-17 14:30+0200","generator":"WP-CLI\/2.13.0-alpha-275d793","source":"src\/variation.js","domain":"messages","locale_data":{"messages":{"":{"domain":"messages","lang":"fr_FR","plural-forms":"nplurals=2; plural=(n > 1);"},"Next Posts":["Publications suivantes"],"Displays the N posts that sequentially follow (or precede) the current post, with wrap-around.":["Affiche les N publications qui suivent (ou pr\u00e9c\u00e8dent) s\u00e9quentiellement la publication actuelle, avec retour au d\u00e9but."],"next":["suivant"],"previous":["pr\u00e9c\u00e9dent"],"sequence":["s\u00e9quence"],"related":["connexe"]}}}
Loading