';
echo self::reference_title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo '
' . $content . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
diff --git a/src/WidgetSystem/PostQuery.php b/src/WidgetSystem/PostQuery.php
index 32e8462b..b7fb7598 100644
--- a/src/WidgetSystem/PostQuery.php
+++ b/src/WidgetSystem/PostQuery.php
@@ -24,7 +24,7 @@ final class PostQuery {
'ignore_sticky_posts' => true,
'no_found_rows' => false,
];
- if (array_key_exists('paginate', $config) && in_array((string) $config['paginate'], ['', 'none'], true)) {
+ if (isset($config['paginate']) && $config['paginate'] === '') {
$args['no_found_rows'] = true;
$args['paged'] = 1;
}
@@ -33,13 +33,22 @@ final class PostQuery {
// category_ids remains available only to the unrelated generic posts
// widgets; a tab segment must never fall back to it.
$is_reference_segment = array_key_exists('cat', $segment);
- $cat_raw = array_key_exists('cat', $config) ? (string) $config['cat'] : ($is_reference_segment ? '' : (string) ($config['category_ids'] ?? ''));
- $categories = self::signed_ids($cat_raw);
- if (!empty($config['category_id'])) $categories = [(int) $config['category_id']];
- $cat_in = array_values(array_filter($categories, static fn(int $id): bool => $id > 0));
- $cat_not = array_values(array_map('abs', array_filter($categories, static fn(int $id): bool => $id < 0)));
- if ($cat_in) $args['category__in'] = $cat_in;
- if ($cat_not) $args['category__not_in'] = $cat_not;
+ if (array_key_exists('cat', $config)) {
+ // WP_Query's `cat` expands included parents to descendants and
+ // applies negative IDs with the same reference-theme semantics.
+ $categories = self::signed_ids((string) $config['cat']);
+ if ($categories) {
+ $args['cat'] = implode(',', $categories);
+ }
+ } else {
+ // Generic widgets retain their explicit category ID/list behavior.
+ $categories = self::signed_ids((string) ($config['category_ids'] ?? ''));
+ if (!empty($config['category_id'])) $categories = [(int) $config['category_id']];
+ $cat_in = array_values(array_filter($categories, static fn(int $id): bool => $id > 0));
+ $cat_not = array_values(array_map('abs', array_filter($categories, static fn(int $id): bool => $id < 0)));
+ if ($cat_in) $args['category__in'] = $cat_in;
+ if ($cat_not) $args['category__not_in'] = $cat_not;
+ }
$tags = $is_reference_segment ? [] : RendererHelpers::ids((string) ($config['tag_ids'] ?? ''));
if ($tags) {
$args['tag__in'] = $tags;
@@ -47,14 +56,15 @@ final class PostQuery {
$days = max(0, (int) ($config['limit_day'] ?? 0));
if ($days > 0) {
- $now = function_exists('current_time') ? current_time('timestamp') : time();
- $format = static function (int $timestamp): string {
- return function_exists('wp_date') ? wp_date('Y-m-d H:i:s', $timestamp) : date('Y-m-d H:i:s', $timestamp);
- };
- $args['date_query'] = [['after' => $format($now - ($days * DAY_IN_SECONDS)), 'before' => $format($now), 'inclusive' => true]];
+ $now = current_datetime();
+ $args['date_query'] = [[
+ 'after' => $now->modify('-' . $days . ' days')->format('Y-m-d H:i:s'),
+ 'before' => $now->format('Y-m-d H:i:s'),
+ 'inclusive' => true,
+ ]];
}
self::apply_order($args, (string) ($config['orderby'] ?? 'date'), (string) ($config['order'] ?? 'DESC'));
- $pay = $is_reference_segment ? ($config['zibpay_type'] ?? []) : ($config['pay_type'] ?? '');
+ $pay = array_key_exists('zibpay_type', $config) ? $config['zibpay_type'] : ($config['pay_type'] ?? '');
self::apply_pay_type($args, $pay);
return $args;
}
diff --git a/src/WidgetSystem/PostRenderer.php b/src/WidgetSystem/PostRenderer.php
index d0a92551..5af82f31 100644
--- a/src/WidgetSystem/PostRenderer.php
+++ b/src/WidgetSystem/PostRenderer.php
@@ -16,6 +16,10 @@ final class PostRenderer {
}
public static function payload(array $instance, string $slug, int $page = 1, string $segment = ''): array {
+ $page = max(1, $page);
+ if (($instance['paginate'] ?? '') === '') {
+ $page = 1;
+ }
$segment_config = $slug === 'tab_posts' ? PostQuery::segment($instance, $segment) : [];
$query = PostQuery::build($instance, $page, $segment_config);
$html = self::items($query, $instance, $slug, $page);
@@ -47,9 +51,7 @@ final class PostRenderer {
'is_tag' => false,
'is_author' => false,
'is_search' => false,
- 'is_mult_thumb' => 'disable',
- 'is_no_thumb' => 'disable',
- 'show_number' => in_array('show_number', $mini_opt, true),
+ 'show_number' => $page === 1 && in_array('show_number', $mini_opt, true),
'show_thumb' => in_array('show_thumb', $mini_opt, true),
'show_meta' => in_array('show_meta', $mini_opt, true),
'number_offset' => ($page - 1) * max(1, (int) ($instance['count'] ?? 8)),
@@ -60,6 +62,11 @@ final class PostRenderer {
if ($mode === 'mini') {
return aksrt_render_mini_list($query, array_merge($config, ['class' => 'ajax-item']), false);
}
+ if ($mode === 'list') {
+ // Zibll suppresses archive-only thumb variants in widget list mode.
+ $config['is_mult_thumb'] = 'disable';
+ $config['is_no_thumb'] = 'disable';
+ }
// The .widget-ajaxpager container rendered by the caller is the ajax
// target, so item markup goes in without the page-level list wrapper.
return aksrt_render_post_list(array_merge($config, ['skip_wrapper' => true]), $query, false);
@@ -78,11 +85,12 @@ final class PostRenderer {
'show_number' => false,
])) : '';
} else {
- $items .= aksrt_render_post(array_merge($config, [
- 'type' => $style === 'card' ? 'card' : 'list',
- 'is_mult_thumb' => 'disable',
- 'is_no_thumb' => 'disable',
- ]));
+ $item_config = array_merge($config, ['type' => $style === 'card' ? 'card' : 'list']);
+ if ($style === 'list') {
+ $item_config['is_mult_thumb'] = 'disable';
+ $item_config['is_no_thumb'] = 'disable';
+ }
+ $items .= aksrt_render_post($item_config);
}
}
wp_reset_postdata();
@@ -98,13 +106,13 @@ final class PostRenderer {
*/
private static function pagination(array $instance, int $page, int $max_pages): string {
$mode = (string) ($instance['paginate'] ?? '');
- if ($mode === '' || $mode === 'none') {
+ if ($mode === '') {
return '
';
}
if ($max_pages <= 1) {
return '';
}
- if ($mode === 'number' || $mode === 'numbered') {
+ if ($mode === 'number') {
$links = '';
$mobile = wp_is_mobile();
$mid_size = $mobile ? 0 : 2;
diff --git a/src/WidgetSystem/Renderers/ContentRenderer.php b/src/WidgetSystem/Renderers/ContentRenderer.php
index f4a3049b..2d69294b 100644
--- a/src/WidgetSystem/Renderers/ContentRenderer.php
+++ b/src/WidgetSystem/Renderers/ContentRenderer.php
@@ -47,13 +47,11 @@ final class ContentRenderer {
if ($segment !== '') {
$attrs .= ' data-segment="' . esc_attr($segment) . '"';
}
- $loader = '
' . self::placeholder($instance) . '
';
- if (($instance['load_mode'] ?? '') === 'ajax') {
- // The runtime (lazysizes + lazyload-action) reveals this container
- // and clicks the single .ias-btn trigger; frontend.js then loads
- // the first page through the REST client.
- $trigger = '
';
- return '';
+ $lazy = ($instance['load_mode'] ?? '') === 'ajax';
+ $loader = '
' . self::placeholder($instance) . '
';
+ if ($lazy) {
+ // lazySizes reveals the pager; the widget client owns its request.
+ return '';
}
$payload = PostRenderer::payload($instance, $slug, $page, $segment);
// The loader must live inside the pager: the REST client in frontend.js
diff --git a/src/WidgetSystem/RestController.php b/src/WidgetSystem/RestController.php
index a0d5de07..ef297655 100644
--- a/src/WidgetSystem/RestController.php
+++ b/src/WidgetSystem/RestController.php
@@ -34,7 +34,7 @@ final class RestController extends \WP_REST_Controller {
* @return \WP_REST_Response|\WP_Error
*/
public function get_items($request) {
- $unknown = array_diff(array_keys($request->get_params()), ['widget', 'instance', 'page', 'segment']);
+ $unknown = array_diff(array_keys($request->get_params()), ['widget', 'instance', 'page', 'segment', 'rest_route']);
if ($unknown) {
return new \WP_Error('aksrt_widget_unknown_parameter', __('请求包含不支持的参数。', 'aksrt'), ['status' => 400, 'parameters' => array_values($unknown)]);
}
diff --git a/src/WidgetSystem/WidgetCatalog.php b/src/WidgetSystem/WidgetCatalog.php
index dee93d2c..9d37b858 100644
--- a/src/WidgetSystem/WidgetCatalog.php
+++ b/src/WidgetSystem/WidgetCatalog.php
@@ -71,7 +71,7 @@ final class WidgetCatalog {
'size' => $size,
'common' => [
'title' => !in_array($slug, ['post_pay', 'layout_gap', 'slider', 'slider_media_combo', 'buttons', 'posts_toc'], true),
- 'affix' => ($size === 'sidebar' && $slug !== 'posts_toc') || in_array($slug, ['buttons', 'notice'], true),
+ 'affix' => ($size === 'sidebar' && $slug !== 'posts_toc') || in_array($slug, ['buttons', 'notice', 'posts'], true),
'animation' => !in_array($slug, ['buttons', 'term_cards', 'term_aggregation', 'media_cards', 'combo_info', 'slider_media_combo'], true),
],
'fields' => WidgetSchemas::for($slug),
diff --git a/tests/widget-system/discovery-reference-contract.test.cjs b/tests/widget-system/discovery-reference-contract.test.cjs
index 6f556e25..5eaeea86 100644
--- a/tests/widget-system/discovery-reference-contract.test.cjs
+++ b/tests/widget-system/discovery-reference-contract.test.cjs
@@ -32,8 +32,8 @@ test('term aggregation renders the real SVG use element, total count, and config
const firstQuery = fixture.aggregation_queries[0];
assert.equal(firstQuery.posts_per_page, 2);
assert.equal(firstQuery.no_found_rows, false);
- assert.deepEqual(firstQuery.category__in, [7]);
- assert.equal(fixture.aggregation_queries[1].category__in[0], 8);
+ assert.equal(firstQuery.cat, '7');
+ assert.equal(fixture.aggregation_queries[1].cat, '8');
});
test('category filters keep dropdown and inline terms in separate rendered groups on the ajax-option pipeline', () => {
diff --git a/tests/widget-system/discovery-reference-harness.php b/tests/widget-system/discovery-reference-harness.php
index 87ab398e..0d5e2892 100644
--- a/tests/widget-system/discovery-reference-harness.php
+++ b/tests/widget-system/discovery-reference-harness.php
@@ -19,7 +19,12 @@ class WP_Query {
public function __construct(public array $query_args = []) {
$GLOBALS['discovery_queries'][] = $query_args;
$cats = array_map('intval', (array) ($query_args['category__in'] ?? []));
- $rows = array_values(array_filter($GLOBALS['discovery_posts'], static fn(array $post): bool => !$cats || in_array($post['cat'], $cats, true)));
+ $excluded = [];
+ foreach (array_filter(explode(',', (string) ($query_args['cat'] ?? ''))) as $cat) {
+ if ((int) $cat > 0) $cats[] = (int) $cat;
+ if ((int) $cat < 0) $excluded[] = abs((int) $cat);
+ }
+ $rows = array_values(array_filter($GLOBALS['discovery_posts'], static fn(array $post): bool => (!$cats || in_array($post['cat'], $cats, true)) && !in_array($post['cat'], $excluded, true)));
$this->found_posts = count($rows);
$per_page = max(1, (int) ($query_args['posts_per_page'] ?? 10));
$this->max_num_pages = (int) ceil($this->found_posts / $per_page);
diff --git a/tests/widget-system/posts-render-behavior.test.cjs b/tests/widget-system/posts-render-behavior.test.cjs
new file mode 100644
index 00000000..e2f43438
--- /dev/null
+++ b/tests/widget-system/posts-render-behavior.test.cjs
@@ -0,0 +1,35 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { execFileSync } = require('node:child_process');
+const path = require('node:path');
+const test = require('node:test');
+
+const cases = JSON.parse(execFileSync(process.env.PHP_BINARY || 'php', [
+ path.join(__dirname, 'tab-posts-render-harness.php'),
+], { encoding: 'utf8' })).cases;
+
+for (const style of ['list', 'card', 'mini']) {
+ test(`article ${style} widget renders its saved layout directly`, () => {
+ const html = cases[`posts_${style}_detail`];
+ assert.match(html, new RegExp(`class="widget-main-post mb20 style-${style}"`));
+ assert.match(html, /data-widget="posts" data-instance="aksrt_widget_posts-3"/);
+ assert.match(html, /data-loaded="1" aria-live="polite" aria-busy="false"/);
+ assert.equal((html.match(/
{
+ const html = cases[`posts_${style}_ajax`];
+ assert.match(html, new RegExp(`class="widget-main-post mb20 style-${style}"`));
+ assert.match(html, /class="widget-ajaxpager lazyload" data-widget-lazy/);
+ assert.match(html, /data-widget="posts" data-instance="aksrt_widget_posts-3"/);
+ assert.match(html, /aria-live="polite" aria-busy="true"/);
+ assert.match(html, /class="post_ajax_loader is-loading" role="status"/);
+ assert.match(html, new RegExp(`data-placeholder="${style}:4"`));
+ assert.doesNotMatch(html, /class="item-heading|ias-btn|data-widget-ias|post_ajax_trigger|lazyload-action=/);
+ });
+}
diff --git a/tests/widget-system/posts-wrapper-contract.test.cjs b/tests/widget-system/posts-wrapper-contract.test.cjs
new file mode 100644
index 00000000..5a3f248c
--- /dev/null
+++ b/tests/widget-system/posts-wrapper-contract.test.cjs
@@ -0,0 +1,37 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+
+const root = path.resolve(__dirname, '..', '..');
+const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
+
+test('article list uses the reference widget container and keeps widget behavior classes', () => {
+ const renderer = read('src/WidgetSystem/FrontendRenderer.php');
+ const catalog = read('src/WidgetSystem/WidgetCatalog.php');
+ assert.match(renderer, /\['user_card', 'author_card', 'tab_posts', 'posts'\]/);
+ assert.match(renderer, /\$classes = \['zib-widget-wrap', 'aksrt-' \. str_replace/);
+ assert.match(renderer, /echo ''/);
+ assert.match(renderer, /WidgetVisibility::device_class\(\$instance\)/);
+ assert.match(renderer, /!empty\(\$instance\['sidebar_affix'\]\)/);
+ assert.match(renderer, /data-affix="true"/);
+ assert.match(catalog, /in_array\(\$slug, \['buttons', 'notice', 'posts'\], true\)/);
+});
+
+test('article list pagination shares the reference widget rules and obsolete trigger CSS is removed', () => {
+ const css = read('assets/widgets/frontend.css');
+ assert.match(css, /\.widget-tab-post \.pagenav, \.widget-main-post \.pagenav/);
+ assert.match(css, /\.sidebar :is\(\.widget-tab-post, \.widget-main-post\) \.pagenav/);
+ assert.match(css, /:is\(\.widget-tab-post, \.widget-main-post\) \.pagenav a\.page-numbers/);
+ assert.doesNotMatch(css, /\.post_ajax_trigger/);
+ for (const token of [
+ '.widget-tab-post .item-heading, .widget-main-post .item-heading',
+ '.widget-tab-post .item-heading.font-bold, .widget-main-post .item-heading.font-bold',
+ '.widget-tab-post .posts-mini, .widget-main-post .posts-mini',
+ '.widget-tab-post .posts-mini-con, .widget-main-post .posts-mini-con',
+ '.widget-tab-post .item-meta .meta-pay, .widget-main-post .item-meta .meta-pay',
+ '.aksrt-posts-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible',
+ ]) assert.ok(css.includes(token), `missing shared widget rule: ${token}`);
+});
diff --git a/tests/widget-system/run.php b/tests/widget-system/run.php
index 1005325b..b0177278 100644
--- a/tests/widget-system/run.php
+++ b/tests/widget-system/run.php
@@ -18,6 +18,12 @@ use AKSRT\WidgetSystem\Renderers\ContentRenderer;
use AKSRT\WidgetSystem\Renderers\GeneralRenderer;
use AKSRT\WidgetSystem\Renderers\SliderRenderer;
+if (!function_exists('current_datetime')) {
+ function current_datetime(): DateTimeImmutable {
+ return new DateTimeImmutable('2026-06-15 12:30:00', new DateTimeZone('Asia/Shanghai'));
+ }
+}
+
final class WidgetSystemTest {
private int $assertions = 0;
private array $failures = [];
@@ -105,15 +111,24 @@ final class WidgetSystemTest {
'count' => 6, 'cat' => '1, -2,3 bad',
'orderby' => 'views', 'order' => 'DESC', 'limit_day' => 7, 'paginate' => '',
], 3);
- $this->same([1, 3], $reference['category__in'], 'reference cat includes positive IDs');
- $this->same([2], $reference['category__not_in'], 'reference cat excludes negative IDs');
+ $this->same('1,-2,3', $reference['cat'], 'reference cat preserves cleaned signed IDs for WP_Query descendant semantics');
+ $this->false(isset($reference['category__in']) || isset($reference['category__not_in']), 'reference cat does not narrow matches to direct categories');
$this->false(isset($reference['tax_query']), 'topic filters are removed with the topics feature');
$this->same('aksrt_post_views', $reference['meta_key'], 'reference view ordering uses numeric view meta');
$this->false(isset($reference['meta_query']), 'modern reference queries do not apply commerce filters by default');
$this->same(1, $reference['paged'], 'no-pagination queries stay on first page');
+ $this->same('2026-06-08 12:30:00', $reference['date_query'][0]['after'], 'limit_day subtracts days in the WordPress site timezone');
+ $this->same('2026-06-15 12:30:00', $reference['date_query'][0]['before'], 'limit_day uses local WordPress time without a second offset conversion');
+ $generic_categories = PostQuery::arguments(['category_ids' => '2,5', 'category_id' => 9], 1);
+ $this->same([9], $generic_categories['category__in'], 'generic category_id continues to override category_ids');
+ $this->false(isset($generic_categories['cat']), 'generic categories do not switch to modern cat syntax');
$paywalled = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['1', '6'], 'paginate' => ''], 1, ['cat' => '1']);
$this->contains('reading', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters map to pay meta');
$this->contains('video', json_encode($paywalled['meta_query'] ?? []), 'tab commerce filters cover every selected type');
+ $top_level_paywall = PostQuery::arguments(['cat' => '1', 'zibpay_type' => ['2'], 'paginate' => 'ajax'], 1);
+ $this->contains('download', json_encode($top_level_paywall['meta_query'] ?? []), 'top-level reference commerce filters map to pay meta');
+ $generic_paywall = PostQuery::arguments(['category_ids' => '1', 'pay_type' => 'gallery'], 1);
+ $this->contains('gallery', json_encode($generic_paywall['meta_query'] ?? []), 'generic widget pay_type behavior remains available');
$invalid = PostQuery::arguments(['cat' => '', 'orderby' => 'drop table'], 1, ['cat' => '']);
$this->false(isset($invalid['meta_query']), 'modern reference fields never add commerce filters');
$this->same('date', $invalid['orderby'], 'unknown orderby falls back');
@@ -298,7 +313,7 @@ final class WidgetSystemTest {
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1]));
$this->error('aksrt_widget_instance_not_registered', $error, 'REST requires an assigned instance');
$GLOBALS['aksrt_test_state']['sidebars'] = ['home_top_content' => ['aksrt_widget_posts-1']];
- update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list'], '_multiwidget' => 1]);
+ update_option('widget_aksrt_widget_posts', [1 => ['title' => 'Saved only', 'count' => 2, 'style' => 'list', 'paginate' => 'ajax'], '_multiwidget' => 1]);
$response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 2, 'segment' => '']));
$this->true($response instanceof WP_REST_Response, 'registered instance returns REST response');
$data = $response->get_data();
@@ -306,6 +321,8 @@ final class WidgetSystemTest {
$this->same(2, $data['page'], 'REST page reaches saved-query pipeline');
$this->contains('暂无内容', $data['html'], 'REST returns deterministic empty state');
$this->same(4, end($GLOBALS['aksrt_test_state']['query_args'])['posts_per_page'], 'REST reloads saved normalized configuration');
+ $response = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_posts-1', 'page' => 1, 'rest_route' => '/aksrt/v1/widgets/posts/items']));
+ $this->true($response instanceof WP_REST_Response, 'plain permalink transport parameter is accepted');
$error = $controller->get_items(new WP_REST_Request(['widget' => 'posts', 'instance' => 'aksrt_widget_hot_posts-1', 'page' => 1]));
$this->error('aksrt_widget_invalid_instance', $error, 'REST binds instance ID to requested widget');
}
diff --git a/tests/widget-system/tab-posts-render-behavior.test.cjs b/tests/widget-system/tab-posts-render-behavior.test.cjs
index d6352174..9075daf1 100644
--- a/tests/widget-system/tab-posts-render-behavior.test.cjs
+++ b/tests/widget-system/tab-posts-render-behavior.test.cjs
@@ -33,15 +33,15 @@ test('un-normalized missing and invalid style use one shared mini resolution in
}
});
-test('mini options render thumbnail, rank, author and meta on both pages with continuous numbering', () => {
+test('mini options render thumbnail, rank, author and meta; rank is limited to the first page', () => {
const current = render().mini_all_options;
assert.equal((current.first.match(/class="posts-mini ajax-item"/g) || []).length, 4);
assert.match(current.first, /class="mr10">
1<\/span>/);
assert.match(current.first, /class="meta-author flex ac"/);
assert.match(current.first, /class="item-meta muted-2-color flex jsb ac/);
- assert.match(current.second, /badg badg-sm mr3 c-green">5<\/span>/);
- assert.match(current.second, /真实文章 36/);
+ assert.doesNotMatch(current.second, /class="badg badg-sm/);
+ assert.match(current.second, /真实文章 32/);
assert.doesNotMatch(current.first + current.second, /class="posts-row|class="posts-item list|class="posts-item card/);
});
@@ -152,12 +152,14 @@ test('multi-column frontend output uses the reference widget wrapper and carries
assert.doesNotMatch(wrapper, /aksrt-widget__inner container|
|<\/aside>/);
const frontend = read('src/WidgetSystem/FrontendRenderer.php');
- const routeStart = frontend.indexOf("if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts']");
+ const routeStart = frontend.indexOf("if (in_array($definition->slug(), ['user_card', 'author_card', 'tab_posts', 'posts']");
const contentRouteStart = frontend.indexOf('if (in_array($definition->slug()', routeStart + 1);
const referenceRoute = frontend.slice(routeStart, contentRouteStart);
const contentRoute = frontend.slice(contentRouteStart, frontend.indexOf('private static function render_reference_widget'));
- assert.match(referenceRoute, /'user_card', 'author_card', 'tab_posts'/);
- assert.doesNotMatch(contentRoute, /'tab_posts'/);
+ assert.match(referenceRoute, /'user_card', 'author_card', 'tab_posts', 'posts'/);
+ assert.doesNotMatch(contentRoute, /'tab_posts'|'posts'/);
+ assert.match(frontend, /\$affix_attr = !empty\(\$instance\['sidebar_affix'\]\) \? ' data-affix="true"'/);
+ assert.match(frontend, /\. \$affix_attr \. \(\$style/);
});
test('widget list and card CSS is included in the public page bundle at desktop and mobile breakpoints', () => {
diff --git a/tests/widget-system/tab-posts-render-harness.php b/tests/widget-system/tab-posts-render-harness.php
index f2b24b1c..9236c352 100644
--- a/tests/widget-system/tab-posts-render-harness.php
+++ b/tests/widget-system/tab-posts-render-harness.php
@@ -31,6 +31,10 @@ class WP_Query {
$rows = array_values(array_filter($GLOBALS['tab_fixture_posts'], static function (array $post) use ($args): bool {
$include = array_map('intval', (array) ($args['category__in'] ?? []));
$exclude = array_map('intval', (array) ($args['category__not_in'] ?? []));
+ foreach (array_filter(explode(',', (string) ($args['cat'] ?? ''))) as $cat) {
+ if ((int) $cat > 0) $include[] = (int) $cat;
+ if ((int) $cat < 0) $exclude[] = abs((int) $cat);
+ }
return (!$include || in_array($post['cat'], $include, true)) && !in_array($post['cat'], $exclude, true);
}));
$orderby = (string) ($args['orderby'] ?? 'date');
@@ -239,7 +243,7 @@ namespace {
'avatar_calls' => array_slice($GLOBALS['tab_fixture_avatar_calls'], $avatar_start),
];
}
- $mini_instance = ['style' => 'mini', 'mini_opt' => ['show_thumb', 'show_number', 'show_meta'], 'count' => 4, 'paginate' => '', 'tabs' => $tabs];
+ $mini_instance = ['style' => 'mini', 'mini_opt' => ['show_thumb', 'show_number', 'show_meta'], 'count' => 4, 'paginate' => 'number', 'tabs' => $tabs];
$mini_instance = \AKSRT\WidgetSystem\FieldSchema::normalize(\AKSRT\WidgetSystem\WidgetSchemas::for('tab_posts'), $mini_instance);
$avatar_start = count($GLOBALS['tab_fixture_avatar_calls']);
$mini_first = \AKSRT\WidgetSystem\PostRenderer::payload($mini_instance, 'tab_posts', 1, '0')['html'];
@@ -249,6 +253,14 @@ namespace {
'second' => $mini_second,
'avatar_calls' => array_slice($GLOBALS['tab_fixture_avatar_calls'], $avatar_start),
];
+ $cases['no_pagination_page'] = \AKSRT\WidgetSystem\PostRenderer::payload(array_merge($mini_instance, ['paginate' => '']), 'tab_posts', 2, '0');
+ foreach (['list', 'card', 'mini'] as $posts_style) {
+ $posts_definition = \AKSRT\WidgetSystem\WidgetRegistry::get('posts');
+ foreach (['detail', 'ajax'] as $load_mode) {
+ $posts_instance = $posts_definition->normalize(['style' => $posts_style, 'load_mode' => $load_mode, 'count' => 4, 'paginate' => 'number', 'title' => '文章列表']);
+ $cases['posts_' . $posts_style . '_' . $load_mode] = \AKSRT\WidgetSystem\Renderers\ContentRenderer::render('posts', $posts_instance, ['widget_id' => 'aksrt_widget_posts-3']);
+ }
+ }
$GLOBALS['tab_fixture_global_style']['post_list_author'] = false;
$mini_time_instance = ['style' => 'mini', 'mini_opt' => ['show_meta'], 'count' => 4, 'paginate' => '', 'tabs' => $tabs];
$mini_time_instance = \AKSRT\WidgetSystem\FieldSchema::normalize(\AKSRT\WidgetSystem\WidgetSchemas::for('tab_posts'), $mini_time_instance);
diff --git a/tests/widget-system/tab-posts-runtime.test.cjs b/tests/widget-system/tab-posts-runtime.test.cjs
index 6d2107bc..0f46dd97 100644
--- a/tests/widget-system/tab-posts-runtime.test.cjs
+++ b/tests/widget-system/tab-posts-runtime.test.cjs
@@ -332,9 +332,25 @@ function addJumpControl(document, pager, { max = 20, value = '4' } = {}) {
return { input, button };
}
-function createRuntime({ fetch, pages } = {}) {
+function postsMarkup(document, unveiled = false) {
+ const root = document.createElement('div');
+ root.className = 'widget-main-post style-list';
+ const pager = document.createElement('div');
+ pager.className = 'widget-ajaxpager ' + (unveiled ? 'lazyloaded' : 'lazyload');
+ pager.setAttribute('data-widget-items', '');
+ pager.setAttribute('data-widget-lazy', '');
+ pager.setAttribute('data-widget', 'posts');
+ pager.setAttribute('data-instance', 'aksrt_widget_posts-2');
+ pager.setAttribute('data-page', '1');
+ pager.setAttribute('aria-busy', 'true');
+ pager.innerHTML = '';
+ root.appendChild(pager);
+ return { root };
+}
+
+function createRuntime({ fetch, pages, posts = false, unveiled = false, restUrl = '/wp-json/aksrt/v1/widgets/', observe = false } = {}) {
const document = new MiniDocument();
- const { root: widget, nav, tabOne, tabTwo } = tabMarkup(document);
+ const { root: widget, nav, tabOne, tabTwo } = posts ? postsMarkup(document, unveiled) : tabMarkup(document);
document.body.appendChild(widget);
const autoFun = [];
function jQuery() {
@@ -344,7 +360,7 @@ function createRuntime({ fetch, pages } = {}) {
};
}
const requests = [];
- const config = { restUrl: '/wp-json/aksrt/v1', i18n: { error: 'error', retry: 'retry' } };
+ const config = { restUrl, i18n: { error: 'error', retry: 'retry' } };
const fetchStub = (url) => {
requests.push(url);
if (fetch) return fetch(url);
@@ -357,11 +373,18 @@ function createRuntime({ fetch, pages } = {}) {
};
return Promise.resolve({ ok: true, json: () => Promise.resolve(response) });
};
+ let notifyMutation;
+ const Observer = observe ? class {
+ constructor(callback) { notifyMutation = callback; }
+ observe() {}
+ } : undefined;
const window = {
aksrtWidgets: config,
+ location: { href: 'https://example.test/' },
matchMedia: () => ({ matches: false }),
jQuery,
};
+ if (observe) window.MutationObserver = Observer;
vm.runInNewContext(fs.readFileSync(path.join(root, 'assets/widgets/frontend.js'), 'utf8'), {
window,
document,
@@ -373,9 +396,10 @@ function createRuntime({ fetch, pages } = {}) {
Object,
Math,
encodeURIComponent,
- MutationObserver: undefined,
+ URL,
+ MutationObserver: Observer,
});
- return { document, widget, nav, tabOne, tabTwo, requests, autoFun };
+ return { document, widget, nav, tabOne, tabTwo, requests, autoFun, notifyMutation };
}
const tick = () => new Promise((resolve) => setImmediate(resolve));
@@ -677,3 +701,76 @@ test('jump input clamps pages to max and rejects invalid or current pages withou
assert.equal(runtime.requests.length, 0, 'submitting the already selected page does not request again');
assert.equal(runtime.document.activeElement, input, 'invalid and current page values return focus to the input');
});
+
+test('article widget waits for its own lazy reveal and fetches the first page once', async () => {
+ const transport = pendingFetch();
+ const runtime = createRuntime({ posts: true, fetch: transport.fetch });
+ const pager = runtime.widget.querySelector('[data-widget-items]');
+ assert.equal(runtime.requests.length, 0, 'offscreen widgets keep their placeholder without fetching');
+ assert.ok(pager.querySelector('.post_ajax_loader.is-loading'));
+ pager.querySelector('.placeholder').dispatch('lazybeforeunveil');
+ assert.equal(runtime.requests.length, 0, 'a descendant image reveal does not load the pager');
+ pager.dispatch('lazybeforeunveil');
+ pager.dispatch('lazybeforeunveil');
+ assert.deepEqual(pageRequests(runtime.requests), [1]);
+ const url = new URL(runtime.requests[0]);
+ assert.equal(url.pathname, '/wp-json/aksrt/v1/widgets/posts/items');
+ assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
+ transport.pending[0].resolve({ html: 'First article ', page: 1, paginate_html: '' });
+ await tick();
+ assert.ok(pager.querySelector('[data-post-id="first"]'));
+ assert.equal(pager.dataset.loaded, '1');
+ assert.equal(pager.getAttribute('aria-busy'), 'false');
+ assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false);
+ pager.dispatch('lazybeforeunveil');
+ assert.equal(runtime.requests.length, 1, 'a completed first page is never fetched again by lazySizes');
+});
+
+test('article widget loads when lazySizes unveiled it before the widget client initialized', async () => {
+ const runtime = createRuntime({ posts: true, unveiled: true });
+ assert.deepEqual(pageRequests(runtime.requests), [1]);
+ await tick();
+ const pager = runtime.widget.querySelector('[data-widget-items]');
+ assert.equal(pager.dataset.loaded, '1');
+ assert.ok(pager.querySelector('[data-post-id]'));
+});
+
+test('article widget failure exposes a retry that replaces the initial skeleton', async () => {
+ const transport = pendingFetch();
+ const runtime = createRuntime({ posts: true, fetch: transport.fetch });
+ const pager = runtime.widget.querySelector('[data-widget-items]');
+ pager.dispatch('lazybeforeunveil');
+ transport.pending[0].reject(new Error('offline'));
+ await tick();
+ assert.equal(pager.getAttribute('aria-busy'), 'false');
+ assert.equal(pager.querySelector('.post_ajax_loader').classList.contains('is-loading'), false);
+ pager.querySelector('[data-widget-retry]').click();
+ assert.deepEqual(pageRequests(runtime.requests), [1, 1]);
+ transport.pending[1].resolve({ html: 'Retried ', page: 1, paginate_html: '' });
+ await tick();
+ assert.ok(pager.querySelector('[data-post-id="retried"]'));
+ assert.equal(pager.querySelector('[data-widget-retry]'), null);
+});
+
+test('article REST addresses preserve the route for plain WordPress permalinks', async () => {
+ const runtime = createRuntime({ posts: true, unveiled: true, restUrl: 'https://example.test/blog/?rest_route=/aksrt/v1/widgets/' });
+ const url = new URL(runtime.requests[0]);
+ assert.equal(url.pathname, '/blog/');
+ assert.equal(url.searchParams.get('rest_route'), '/aksrt/v1/widgets/posts/items');
+ assert.equal(url.searchParams.get('instance'), 'aksrt_widget_posts-2');
+ assert.equal(url.searchParams.get('page'), '1');
+ await tick();
+ assert.equal(runtime.widget.querySelector('[data-widget-items]').dataset.loaded, '1');
+});
+
+test('a pager inserted as the mutation root still receives its lazy listener', async () => {
+ const runtime = createRuntime({ observe: true });
+ const pager = postsMarkup(runtime.document).root.querySelector('[data-widget-items]');
+ pager.nodeType = 1;
+ runtime.document.body.appendChild(pager);
+ runtime.notifyMutation([{ addedNodes: [pager] }]);
+ pager.dispatch('lazybeforeunveil');
+ await tick();
+ assert.deepEqual(pageRequests(runtime.requests), [1]);
+ assert.equal(pager.dataset.loaded, '1');
+});
diff --git a/tests/widget-system/tab-posts-style-contract.test.cjs b/tests/widget-system/tab-posts-style-contract.test.cjs
index 79598223..c7b2c716 100644
--- a/tests/widget-system/tab-posts-style-contract.test.cjs
+++ b/tests/widget-system/tab-posts-style-contract.test.cjs
@@ -20,7 +20,7 @@ function declarations(selector) {
return result;
}
-test('tab post titles restore reference weight after public typography while preserving the bold option', () => {
+test('tab and single post widget titles restore reference weight while preserving the bold option', () => {
// public.css sets h2 to 700. The widget must override that selector;
// the explicit global heading-bold option must still win inside it.
assert.match(read('assets/css/core/typography.css'), /font-weight:\s*700/);
@@ -28,12 +28,19 @@ test('tab post titles restore reference weight after public typography while pre
assert.equal(heading['font-weight'], '500');
assert.equal(heading.color, 'inherit');
assert.equal(declarations('.widget-tab-post .item-heading.font-bold')['font-weight'], '700');
+ assert.equal(declarations('.widget-main-post .item-heading')['font-weight'], '500');
+ assert.equal(declarations('.widget-main-post .item-heading.font-bold')['font-weight'], '700');
const shell = declarations('.aksrt-tab-posts-wrap');
assert.equal(shell['font-size'], '14px');
assert.equal(shell['line-height'], '1.42857143');
assert.equal(shell.color, 'var(--main-color,#4e5358)');
assert.equal(declarations('.widget-tab-post .posts-mini')['vertical-align'], 'baseline');
assert.equal(declarations('.widget-tab-post .posts-mini-con').flex, 'auto');
+ assert.equal(declarations('.widget-main-post .posts-mini')['vertical-align'], 'baseline');
+ assert.equal(declarations('.widget-main-post .posts-mini-con').flex, 'auto');
+ assert.equal(declarations('.widget-tab-post .item-meta .meta-pay').cursor, 'auto');
+ assert.equal(declarations('.widget-main-post .item-meta .meta-pay').cursor, 'auto');
+ assert.match(css, /\.aksrt-posts-wrap :where\(a, button, input, select, textarea, \[tabindex\]\):focus-visible/);
});
test('widget pagination restores compact reference metrics over public pagination overrides', () => {
@@ -52,6 +59,10 @@ test('widget pagination restores compact reference metrics over public paginatio
assert.equal(nestedLabel['min-width'], '0');
assert.equal(nestedLabel.padding, '0');
const hover = declarations('.widget-tab-post .pagenav a.page-numbers:hover');
+ assert.equal(declarations('.widget-main-post .pagenav').display, 'block');
+ assert.equal(declarations('.widget-main-post .pagenav .page-numbers').padding, '6px 9px 5px');
+ assert.equal(declarations('.widget-main-post .pagenav a.page-numbers:hover').opacity, '.7');
+ assert.match(read('assets/css/main.css'), /\.pagenav \.current\s*\{[^}]*background-color:\s*var\(--focus-color\)\s*!important;[^}]*color:\s*#fff\s*!important;/s);
assert.equal(hover.background, 'var(--main-border-color)');
assert.equal(hover.opacity, '.7');
});
@@ -61,13 +72,15 @@ test('jump control keeps the reference inline layout and responsive numeric link
assert.equal(jump.width, 'auto', 'public mobile CSS must not stretch the jump control to a full row');
assert.equal(jump.display, 'inline-flex');
const input = declarations('.widget-tab-post .pagenav .pag-jump .jump-input');
+ assert.equal(declarations('.widget-main-post .pagenav .pag-jump').display, 'inline-flex');
+ assert.equal(declarations('.widget-main-post .pagenav .pag-jump .jump-input').position, 'absolute');
assert.equal(input.position, 'absolute');
assert.equal(input.width, '100%');
assert.equal(input.opacity, '0');
const mobile = css.slice(css.indexOf('@media (max-width:640px)'));
- assert.match(mobile, /\.widget-tab-post \.pagenav a\.page-numbers\s*\{\s*display:none/);
- assert.match(mobile, /\.widget-tab-post \.pagenav a\.prev\s*\{\s*display:inline-block/);
- assert.match(mobile, /\.widget-tab-post \.pagenav \.page-numbers\s*\{\s*min-width:28px;\s*margin:2px;\s*font-size:12px/);
+ assert.match(mobile, /:is\(\.widget-tab-post, \.widget-main-post\) \.pagenav a\.page-numbers\s*\{\s*display:none/);
+ assert.match(mobile, /:is\(\.widget-tab-post, \.widget-main-post\) \.pagenav a\.prev\s*\{\s*display:inline-block/);
+ assert.match(mobile, /:is\(\.widget-tab-post, \.widget-main-post\) \.pagenav \.page-numbers\s*\{\s*min-width:28px;\s*margin:2px;\s*font-size:12px/);
});
test('modern tabs have one interaction owner and no retired tab selector or unused widget-style field', () => {
diff --git a/tests/widget-system/zibll-modern-widgets-contract.test.cjs b/tests/widget-system/zibll-modern-widgets-contract.test.cjs
index 3422a09c..6e0056f2 100644
--- a/tests/widget-system/zibll-modern-widgets-contract.test.cjs
+++ b/tests/widget-system/zibll-modern-widgets-contract.test.cjs
@@ -20,8 +20,8 @@ test('modern widget schemas expose Zibll field names and defaults', () => {
test('tab and slider renderers retain reference DOM contracts', () => {
const content = read('src/WidgetSystem/Renderers/ContentRenderer.php');
const slider = read('src/WidgetSystem/Renderers/SliderRenderer.php');
- for (const token of ['widget-tab-post', 'posts-mini-lists', 'index-tab', 'tab-nav-theme', 'tab-pane fade', 'ias-btn', 'post_ajax_loader', 'widget-ajaxpager']) assert.match(content, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
- assert.doesNotMatch(content, /post_ajax_trigger/);
+ for (const token of ['widget-tab-post', 'posts-mini-lists', 'index-tab', 'tab-nav-theme', 'tab-pane fade', 'data-widget-lazy', 'post_ajax_loader', 'widget-ajaxpager']) assert.match(content, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
+ assert.doesNotMatch(content, /post_ajax_trigger|ias-btn|data-widget-ias/);
for (const token of ['relative zib-slider', 'new-swiper slide-widget', 'swiper-wrapper', 'swiper-slide', 'slide-layer', 'data-swiper-parallax', 'swiper-button-prev', 'swiper-pagination', 'widget-slider-graphic', 'slider-col', 'cover-col', 'widget-graphic-cover']) assert.match(slider, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});