Files
laoyang 492d8e11a6 3.2.48: 归档倒序/字号/底边对齐/共N篇 + 评论标题线等长 + 工具条分隔线统一 #18
工单 #18 八条诉求逐条落地:

1. 月份、日期、年份全部倒序(最近的在最上面)
   - PHP 侧 `krsort($years[$y]['months'])`(原来是 ksort)
   - JS 侧 `Object.keys(...).sort((a,b)=>b-a)` —— 对象键序对纯数字整数键
     会强制升序,服务端顺序传不过来,必须前端自己再排一次
   - 年份本来就是 krsort,日期本来就是倒序,本次只补月份

2. 年份字体再大一点
   - 原来 CSS 写的 22px **从来没有生效过**:`.yang-archives .ar-year` 是
     (0,2,0),被 `.k-main .details .article .content h3` 的 (0,4,1) 压死,
     线上实际是 18px
   - 改为带 `.k-main` 前缀(提到 (0,5,0))+ 26px + 700
   - `.ar-month-title` 同一个坑,一并修(写 14px/600,线上实际 16px/500)

3. 「共 N 篇」太高、比年份还高 → 下移与年份底边对齐
   - `.ar-head` 的 `align-items: center` → `flex-end`
   - `.ar-year` 的 margin 显式清零(kratos 的 `h1..h6 { margin: 0 0 30px 0 }`)
   - 窄屏媒体查询同步改,否则手机上又回到居中
   - 注意 `flex-end` 对的是 margin box,所以 margin 不清零等于没改

4. 每月后面的裸数字 → 「共 N 篇」
   - PHP:`sprintf(esc_html__('共 %s 篇'), number_format_i18n(...))`
   - JS:同一个文案键 `i18n.monthCount`,服务端输出到 `yangArchivesData`
   - 新增 `monthCount` 键,**没有复用** `totalFmt`(两处语义不同,
     以后要分别改文案时不至于互相牵连)

5. 上一年/下一年按钮看不清 → 与「写下你的评论」同款观感
   - 平时:`1px solid #cfe6fb` 浅蓝框 + 透明底 + `--yq-primary-dark` 蓝字
   - 悬停:主色实底 + 白字(`.ar-nav-year` / `.ar-nav-arrow` 也要跟白,
     否则图标还是蓝的在蓝底上看不见)
   - 禁用态保持灰(`is-disabled` + opacity .42),不能跟可用态一个样

6. 评论「评论者」下面的灰线太短 → 延长到与评论分隔线等长、右侧到头
   - 🔴 这里改过一次方案:一开始以为把 h3 `flex: 1 1 auto` 撑宽就行,
     实测发现 `.comments-head` 是 flex 行,右边还有「写下你的评论」按钮
     (flex: 0 0 auto),h3 撑满也只能长到按钮左边缘 —— 原理上到不了右端
     (实测 h3 110.5px / li 561.98px,差的 451px 就是按钮 + gap)
   - 最终方案:**线从 h3 搬到 `.comments-head` 容器上**,
     h3 显式 `border-bottom: none` 干掉 kratos 的 #f0f0f0,
     否则会出现一短一长两条线,比原来更难看
   - 容器那条规则**还必须带 `.k-main` 前缀**((0,5,0)),否则压不过
     kratos 的 `.comments .title`。发布判据当场抓到这一条:注释里写了
     「带 .k-main 前缀」,代码里漏了 —— 注释不能当证据

7. 工具条分隔细线不统一 → 5 个按钮 4 条线
   - 原来 B/I 之间没有线,其余有 ⇒ 现在统一
   - 写成「每条后面跟一条」会在末尾多出贴容器边缘的孤线,所以是 5 按钮 4 线

8. 清理无用 JS/CSS
   - 四轮查证结论:**没有可安全删除的**,代码一行未动
   - 排查过程已作为评论发到工单

测试:归档单测 113/0(反向 13 真红)、归档 e2e 112/0(反向 24 真红)、
评论几何 157/0(反向含新增 5 条,覆盖"线搬回标题""两处都画"两种错误形态)。
线上真实页面几何探针发布前 17 通过/16 失败(每条都对应上面某一条诉求),
发布后要求全绿。
2026-10-26 18:42:40 +08:00

764 lines
31 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 归档式文章列表([Archives] 短代码)
*
* ── 用法 ──────────────────────────────────────────────────────
* 在文章或页面正文里任意位置写一行:
*
* [Archives]
*
* 这个位置就会渲染出全站文章的**年 / 月归档列表**:
*
* · 默认显示**今年**,按月份归类(只列有文章的月份)
* · 每篇后面带 **浏览数 / 评论数 / 点赞数**
* · 列表上左/上右、下左/下右四角各有「上一年 / 下一年」按钮
* · 切年**纯 JS 重建 DOM,不刷新页面**
*
* 可调参数(都可省略):
*
* [Archives year="2025" month="1" nav="1"]
*
* · year 首次显示哪一年,默认今年;也可用 ?arc_year=2025 覆盖
* · month 1 = 显示月份分组标题,0 = 只平铺列表,默认 1
* · nav 1 = 显示四角年份导航,0 = 不显示,默认 1
*
* ── 为什么不用 wp_get_archives() ───────────────────────────────
* 三个原因,每个都足以否掉它:
* ① 它拿不到计数。要显示浏览数/评论数/点赞数,只能每篇再查一次 ——
* 71 篇就是 71 次查询(外加 2×71 次 meta 查询)。
* ② 它的输出格式固定(`<li><a>…</a></li>` 或下拉框),塞不进三项计数。
* ③ 它按「按月/按年」二选一输出**整条时间线**,没有"给我某一年"的
* 接口,JS 切年时无法复用它。
*
* 所以这里自己一次 SQL 取全量 + 两次批量 SQL 取计数,服务端组织成
* `年 → 月 → 条目` 的三层数组,**首屏直出今年**,其余年份以 JSON
* 随页面下发。JS 切年只是从这份 JSON 重建 DOM —— 不发起任何请求。
*
* ── 为什么计数要"批量"而不是逐篇 ───────────────────────────────
* 浏览数 `views` 与点赞数 `love` 都存在 postmeta 里(见 theme-article.php)。
* 逐篇 `get_post_meta()` 会先被 WP 的对象缓存接住一部分,但**没命中时
* 每次一条 SQL**。71 篇最坏 142 条。改成一条
* `WHERE meta_key IN ('views','love')` 就够,代价是自己在 PHP 里
* 按 post_id 归位 —— 这点代码远比 142 次往返便宜。
*
* ── 为什么评论数也走 SQL 而不是 get_comments_number() ──────────
* `get_comments_number()` 在没预热评论缓存的页面上同样是一条一条查
* (它内部走 `get_comments()` 的 count 分支,不带 `post_id` 时按文章查)。
* 归档页不是文章页,评论缓存根本没预热,所以这里必须自己 GROUP BY 一次。
*
* ── 排序 ──────────────────────────────────────────────────────
* 年份**倒序**(新的在前,符合翻归档的直觉),月份**升序**(1 月在
* 前,符合"这一年从头看"的直觉),月内日期**倒序**(新的在前)。
* 这三条方向不一致是**刻意**的:年份是"选哪一年",月份是"这一年
* 的进度条",月内是"最近写了什么"。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 一次取全站归档数据
*
* 结果结构:
*
* array(
* 'years' => array(
* 2026 => array( // 年份,倒序
* 'count' => 12, // 该年文章总数
* 'months' => array(
* 10 => array( // 月份,升序
* 'count' => 3,
* 'items' => array( // 月内,日期倒序
* array('id'=>1,'title'=>'…','url'=>'…',
* 'day'=>'15','date'=>'2026-10-15',
* 'views'=>12,'comments'=>3,'love'=>5),
* ),
* ),
* ),
* ),
* ),
* 'order' => array(2026, 2025, 2024), // 有文章的年份,倒序
* 'latest' => 2026, // 最新一篇所在年
* 'total' => 71,
* )
*
* ⚠️ 全部文章**一次性**取回。71 篇的量级完全撑得住;如果哪天到了
* 几万篇,需要改成分年懒加载 —— 但那时前端也得改成 AJAX,
* 属于重新设计,不是在这里加个 limit 就能解决的(加了会算错每月
* 与每年的条目数)。
*
* @return array
*/
function yang_archives_data()
{
/* 同一次请求内只算一次:一个页面可能放了两个 [Archives]。
* 不用 transient —— 归档数据要随发文/评论即时变化,
* 加缓存就得处理 save_post / wp_insert_comment 失效,
* 那套失效逻辑一旦漏一条,用户看到的就是"我刚发的文章不在归档里"。
* 静态变量只活一次请求,不存在这个问题。 */
static $cache = null;
if ($cache !== null) {
return $cache;
}
$empty = array('years' => array(), 'order' => array(), 'latest' => 0, 'total' => 0);
global $wpdb;
/* ① 文章本体。
*
* 只取 post 类型、已发布、非密码保护 —— 密码保护的正文别人看不到,
* 列进归档等于泄露标题。`post_password = ''` 就是排掉它们。
*
* 🔴 不要在这里 `ORDER BY post_date DESC` 之后就当排好了:
* 下面会重组成「年 → 月」,组内顺序必须由重组逻辑自己保证,
* 否则将来有人改这里的排序,页面显示顺序会莫名其妙跟着变。
* 这里的 ORDER BY 只是让同一月内的记录天然有序,省一次 usort。 */
$rows = $wpdb->get_results(
"SELECT ID, post_title, post_date
FROM {$wpdb->posts}
WHERE post_type = 'post'
AND post_status = 'publish'
AND post_password = ''
ORDER BY post_date DESC",
ARRAY_A
);
if (empty($rows)) {
$cache = $empty;
return $cache;
}
/* ② 浏览数 / 点赞数:一次批量取。
*
* `meta_value` 是 longtext,这里不强转 —— 转成 int 放到 PHP 侧做,
* 保持 SQL 可走 meta_key 索引。 */
$ids = array();
foreach ($rows as $r) {
$ids[] = (int) $r['ID'];
}
$ids_sql = implode(',', $ids);
$meta = array();
if ($ids_sql !== '') {
$mrows = $wpdb->get_results(
"SELECT post_id, meta_key, meta_value
FROM {$wpdb->postmeta}
WHERE post_id IN ({$ids_sql})
AND meta_key IN ('views', 'love')",
ARRAY_A
);
foreach ($mrows as $m) {
$pid = (int) $m['post_id'];
$key = $m['meta_key'];
if (!isset($meta[$pid])) {
$meta[$pid] = array('views' => 0, 'love' => 0);
}
$meta[$pid][$key] = (int) $m['meta_value'];
}
}
/* ③ 评论数:一次 GROUP BY。
*
* 只数已批准的(`comment_approved = '1'`)—— 待审核的评论访客看不到,
* 数进去会出现"显示 5 条、点进去只有 3 条"。 */
$ccount = array();
if ($ids_sql !== '') {
$crows = $wpdb->get_results(
"SELECT comment_post_ID AS pid, COUNT(*) AS n
FROM {$wpdb->comments}
WHERE comment_approved = '1'
AND comment_post_ID IN ({$ids_sql})
GROUP BY comment_post_ID",
ARRAY_A
);
foreach ($crows as $c) {
$ccount[(int) $c['pid']] = (int) $c['n'];
}
}
/* ④ 组织成 年 → 月 → 条目 */
$years = array();
$total = 0;
foreach ($rows as $r) {
$pid = (int) $r['ID'];
$date = $r['post_date']; // 'YYYY-MM-DD HH:MM:SS',WP 保证格式
$y = (int) substr($date, 0, 4);
$m = (int) substr($date, 5, 2);
$d = substr($date, 8, 2);
/* 年份/月份为 0 说明 post_date 是脏数据('0000-00-00')。
* 这类记录直接跳过而不是塞进 0 年 0 月 —— 后者会在导航里
* 制造一个永远点不到内容的"第 0 年"。 */
if ($y < 1 || $m < 1 || $m > 12) {
continue;
}
$title = (string) $r['post_title'];
if ($title === '') {
$title = __('(无标题)', 'kratos');
}
$views = isset($meta[$pid]['views']) ? $meta[$pid]['views'] : 0;
$love = isset($meta[$pid]['love']) ? $meta[$pid]['love'] : 0;
$cmts = isset($ccount[$pid]) ? $ccount[$pid] : 0;
if (!isset($years[$y])) {
$years[$y] = array('count' => 0, 'months' => array());
}
if (!isset($years[$y]['months'][$m])) {
$years[$y]['months'][$m] = array('count' => 0, 'items' => array());
}
$years[$y]['months'][$m]['items'][] = array(
'id' => $pid,
'title' => $title,
'url' => get_permalink($pid),
'day' => $d,
'date' => substr($date, 0, 10),
'views' => $views,
'comments' => $cmts,
'love' => $love,
);
$years[$y]['months'][$m]['count']++;
$years[$y]['count']++;
$total++;
}
if (empty($years)) {
$cache = $empty;
return $cache;
}
/* 年份、月份**都是倒序** —— 最近的在最上面。
*
* 3.2.48 改:月份从 ksort(1 月在前)改成 krsort(12 月在前)。
* 用户的原话是「月份和日期也倒序排列吧,就是说,最近的在最上面,
* 年份是最近的最靠前,月份和日期也应该保持一致」。
*
* 三者必须同向,否则页面会自相矛盾:线上真实数据出现过
* 「1 月 → 2 月 → 5 月 → 10 月」的月份顺序,而月内文章是
* 「01-21 → 01-20 → 01-19」,年份是「2026 → 2025」。
* 于是读者看到的整体序列是「2026 年的 1 月、2 月、5 月、10 月,
* 到 2025 年又从 1 月开始」—— 年份在往下走,月份却每年重置方向。
*
* 🔴 改这里必须同步改 assets/js/archives.js 的 buildYear:
* 切年时那段 JS 会用同一套类名重建 DOM,它的月份排序写死在
* `Object.keys(...).sort((a,b) => a-b)`。只改 PHP 会出现
* 「首屏 12→1,点一下下一年变回 1→12」这种只在交互后才暴露的错。
* 判据在 _archives_e2e.js:首屏与切年后的月份序列必须同向。 */
krsort($years);
foreach ($years as $y => $yv) {
krsort($years[$y]['months']);
}
/* 月内按日期倒序 —— 由产品自己排,不依赖 SQL 的 ORDER BY。
*
* 🔴 为什么上面已经有 `ORDER BY post_date DESC` 还要排一次:
* 那个 ORDER BY 保证的是"三张表里这一条 SQL 的返回顺序",
* 它是真实存在的契约,正常情况下够用。但归档的"日期倒序"
* 是**这个功能的对外承诺**,不该只由上游偶然保证 ——
* 一旦有人调 SQL(换成 post_date_gmt、加上 UNION、被
* object-cache 插件换成别的查询),页面顺序会静默变乱,
* 而且乱得很有迷惑性:同一天的多篇顺序不对,很难注意到。
*
* 实测证据:本地单测把夹具**乱序**喂进来(不模拟 SQL 排序),
* 产品就把 `20, 05, 01, 02` 原样输出了 —— 说明它此刻确实
* 完全依赖上游顺序。一次 usort 的代价可以忽略(一年几百条),
* 换来的是这个承诺由产品自己兜住。
*
* 用 `<=>` 比较 `YYYY-MM-DD HH:MM:SS` 字符串:定长格式下字典序
* 与时间序一致,比 strtotime 更快也不受时区影响。 */
foreach ($years as $y => $yv) {
foreach ($years[$y]['months'] as $m => $mv) {
usort($years[$y]['months'][$m]['items'], function ($a, $b) {
if ($a['date'] === $b['date']) {
// 同一时刻(极少,但导入数据里会有)→ 按 id 倒序,保证稳定
return $b['id'] <=> $a['id'];
}
return strcmp($b['date'], $a['date']);
});
}
}
$order = array_keys($years);
$cache = array(
'years' => $years,
'order' => $order,
'latest' => (int) $order[0],
'total' => $total,
);
return $cache;
}
/**
* 短代码参数
*
* @param array $atts
* @param string $tag 实际命中的标签名(`archives` 或 `Archives`)
* @return array
*/
function yang_archives_atts($atts, $tag = 'archives')
{
/* 🔴 第 3 个参数必须是**实际命中的那个 tag**,不能写死 'archives'。
* 它决定 `shortcode_atts_{$tag}` 过滤器往外传的名字。
* 我们同时注册了 `archives` 与 `Archives` 两种写法,
* 写死小写的话,用 `[Archives]` 的站点上 `shortcode_atts_Archives`
* 这个钩子永远收不到 —— 属于"功能能用但扩展点悄悄失效"的隐性 bug。 */
$a = shortcode_atts(array(
'year' => '',
'month' => '1',
'nav' => '1',
), $atts, $tag);
return array(
'year' => $a['year'] === '' ? '' : (int) $a['year'],
'month' => (int) $a['month'] === 1 ? 1 : 0,
'nav' => (int) $a['nav'] === 1 ? 1 : 0,
);
}
/**
* 决定首屏显示哪一年
*
* 优先级:URL 参数 `?arc_year=YYYY` > 短代码 `year="YYYY"` > 今年
*
* 为什么 URL 参数优先:切年时 JS 会 `replaceState` 把年份写进地址,
* 用户复制链接给别人,对方打开必须看到**同一年的归档**。
* 如果短代码的 year 优先,这个链接就永远只能打开作者设定的那一年,
* 分享出去等于没用。
*
* ⚠️ 年份必须是数据里**真实存在**的年份,否则落到最新一年。
* 不这么做的话,`?arc_year=1900` 会渲染出一个空列表,
* 而导航按钮的禁用态又是按"有没有数据"算的 —— 页面会卡在
* 一个既不能前进也不能后退的死年。
*
* @param array $data yang_archives_data() 的结果
* @param int $attr_year 短代码上写的年份,0 表示没写
* @return int
*/
function yang_archives_pick_year($data, $attr_year)
{
$order = $data['order'];
if (empty($order)) {
return 0;
}
$candidates = array();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if (isset($_GET['arc_year'])) {
$candidates[] = (int) $_GET['arc_year'];
}
if ($attr_year > 0) {
$candidates[] = (int) $attr_year;
}
foreach ($candidates as $y) {
if (isset($data['years'][$y])) {
return $y;
}
}
// 都没命中 → 今年若在数据里就用今年,否则用最新一年
$this_year = (int) current_time('Y');
if (isset($data['years'][$this_year])) {
return $this_year;
}
return (int) $order[0];
}
/**
* 渲染某一年的列表(服务端首屏用)
*
* ⚠️ JS 切年时会用**同一套类名**重建 DOM(见 assets/js/archives.js)。
* 改这里的结构必须同步改那边,否则首屏与切年后的样式会不一致。
* 判据在 `_archives_e2e.js` 里:两边建出来的节点类名必须逐个相同。
*
* @param array $ydata 某一年('count' + 'months')
* @param int $year 年份
* @param int $show_month 是否输出月份分组标题
* @return string
*/
function yang_archives_render_year($ydata, $year, $show_month)
{
if (empty($ydata['months'])) {
return '';
}
$out = '';
foreach ($ydata['months'] as $m => $mdata) {
if ($show_month) {
$out .= '<div class="ar-month">';
$out .= '<h4 class="ar-month-title">'
. sprintf(
/* translators: 1: 月份数字 2: 该月文章数 */
esc_html__('%1$s 月', 'kratos'),
/* 🔴 月份同样不过 number_format_i18n —— 它会把 10 月
* 变成「10」没问题,但一旦有人把月份格式改成两位数
* 或站点语言用非逗号分隔,就会与 JS 侧(String(m))
* 不一致。月份是编号。 */
esc_html((string) $m)
)
. '<span class="ar-month-count">'
/* 3.2.48:原来是**裸数字**,用户反馈「也不知道什么意思」。
* 改成「共 N 篇」,与年度那一行的文案一致。
*
* 🔴 容器类名 ar-month-count 保留不动 —— CSS 的胶囊底、
* 等宽数字都挂在它上面;JS 侧 buildYear 也用同一个类名。
* 改文案不改结构,两侧判据都不用迁。 */
. sprintf(
/* translators: %s: 该月文章数 */
esc_html__('共 %s 篇', 'kratos'),
esc_html(number_format_i18n($mdata['count']))
)
. '</span>'
. '</h4>';
} else {
$out .= '<div class="ar-month">';
}
$out .= '<ul class="ar-list">';
foreach ($mdata['items'] as $it) {
$out .= yang_archives_render_item($it, $m);
}
$out .= '</ul></div>';
}
return $out;
}
/**
* 渲染单条
*
* @param array $it 条目
* @param int $m 月份(用于拼「月-日」)
* @return string
*/
function yang_archives_render_item($it, $m)
{
$md = sprintf('%02d-%s', (int) $m, $it['day']);
$out = '<li class="ar-item">';
$out .= '<time class="ar-date" datetime="' . esc_attr($it['date']) . '">'
. esc_html($md) . '</time>';
$out .= '<a class="ar-title" href="' . esc_url($it['url']) . '">'
. esc_html($it['title']) . '</a>';
/* 三项计数。用的 i-hot / i-comments / i-good 是主题自带图标集里
* 已经在用的三个(page-content.php 同款),不引入新图标,
* 免得字体包里缺字形变成豆腐块。
*
* `data-zero="1"` 给零值打标记,CSS 据此弱化 —— 站内大部分文章的
* 评论数和点赞数就是 0,三项同等强调会让计数列看起来全是「0 0 0」,
* 反而看不出哪篇有热度。 */
$out .= '<span class="ar-stats">';
$out .= yang_archives_render_stat('views', 'i-hot', $it['views'],
__('浏览数', 'kratos'));
$out .= yang_archives_render_stat('comments', 'i-comments', $it['comments'],
__('评论数', 'kratos'));
$out .= yang_archives_render_stat('love', 'i-good', $it['love'],
__('点赞数', 'kratos'));
$out .= '</span>';
$out .= '</li>';
return $out;
}
/**
* 单个计数(图标 + 数字 + 无障碍文字)
*
* @param string $kind views|comments|love,只用于类名
* @param string $icon 图标类名
* @param int $num 数值
* @param string $label 无障碍标签
* @return string
*/
function yang_archives_render_stat($kind, $icon, $num, $label)
{
$num = (int) $num;
return '<span class="ar-stat ar-stat-' . esc_attr($kind) . '"'
. ' title="' . esc_attr($label) . '"'
. ' data-zero="' . ($num === 0 ? '1' : '0') . '">'
. '<i class="kicon ' . esc_attr($icon) . '" aria-hidden="true"></i>'
. '<span class="ar-num">' . esc_html(number_format_i18n($num)) . '</span>'
. '<span class="sr-only">' . esc_html($label) . '</span>'
. '</span>';
}
/**
* 四角导航
*
* 语义按用户要求:**左侧「上一年」= 回到更近的年份**,
* **右侧「下一年」= 去更早的年份**。
*
* 🔴 这个方向初看是反的("下一年"却往过去走),但用户明确写了两遍
* 示例(2026 → 2025 → 2024),所以行为按示例来。
* 为了不让人误点,按钮上除了文案还直接显示**目标年份**,
* 这样"点下去会去哪"一眼可见,不必靠文案猜。
*
* 用的是 `<button>` 而不是 `<a href>`:
* · 切年不刷新页面,`<a>` 的默认行为要 `preventDefault` 才有意义,
* 而无 JS 时 `<a>` 带 `?arc_year=` 还能用 —— 这点是有价值的。
* · 所以这里**两者结合**:`<a href="?arc_year=YYYY">` 作为真实
* 回退(新标签/无 JS 都能用),JS 接管后 `preventDefault` 改走
* DOM 重建。这样"不刷新"是增强,不是唯一路径。
*
* 🔴 `$variant` 必须由调用方给(top / bottom):
* 第一版把 `ar-nav ar-nav-top` 写死在这里,而下排也是调这个函数生成的
* ⇒ 下排同时带上了 `ar-nav-top` 类名。虽然 `.ar-foot .ar-nav`
* (0,3,0)特异性高于 `.ar-nav-top`(0,2,0)把它压住了、视觉上没坏,
* 但类名本身就是错的("下排"不是"top"),CSS/JS 以后想按位置区分
* 就无从下手,测试也没法分辨上下排。**位置由调用方决定,不由渲染函数猜。**
*
* @param int $year 当前年份
* @param array $order 有文章的年份(倒序)
* @param string $variant top|bottom,只用于类名
* @return string
*/
function yang_archives_render_nav($year, $order, $variant = 'top')
{
$pos = array_search($year, $order, true);
if ($pos === false) {
return '';
}
/* order 是倒序:[2026, 2025, 2024]
* · 更近的年份(上一年)在 pos-1
* · 更早的年份(下一年)在 pos+1 */
$prev = isset($order[$pos - 1]) ? (int) $order[$pos - 1] : 0;
$next = isset($order[$pos + 1]) ? (int) $order[$pos + 1] : 0;
$variant = ($variant === 'bottom') ? 'bottom' : 'top';
$out = '<div class="ar-nav ar-nav-' . esc_attr($variant) . '">';
$out .= yang_archives_render_nav_btn($prev, 'prev', $year);
$out .= yang_archives_render_nav_btn($next, 'next', $year);
$out .= '</div>';
return $out;
}
/**
* 单个导航按钮
*
* @param int $target 目标年份,0 表示没有
* @param string $dir prev|next
* @param int $year 当前年份(拼 URL 用)
* @return string
*/
function yang_archives_render_nav_btn($target, $dir, $year)
{
$is_prev = ($dir === 'prev');
$label = $is_prev ? __('上一年', 'kratos') : __('下一年', 'kratos');
$arrow = $is_prev ? '&uarr;' : '&darr;';
if ($target === 0) {
// 没有目标年 → 禁用态。用 <span> 而不是 disabled 的 <a>:
// <a> 没有 disabled 语义,加了也不生效,还会被键盘 Tab 到。
return '<span class="ar-nav-btn is-disabled" aria-disabled="true">'
. '<span class="ar-nav-arrow" aria-hidden="true">' . $arrow . '</span>'
. '<span class="ar-nav-label">' . esc_html($label) . '</span>'
. '</span>';
}
return '<a class="ar-nav-btn ar-nav-' . esc_attr($dir) . '"'
. ' href="' . esc_url(add_query_arg('arc_year', $target)) . '"'
. ' data-year="' . esc_attr($target) . '"'
. ' rel="nofollow">'
. '<span class="ar-nav-arrow" aria-hidden="true">' . $arrow . '</span>'
. '<span class="ar-nav-label">' . esc_html($label) . '</span>'
/* 🔴 年份**绝不能**过 number_format_i18n:它会输出「2,025」。
* 实测证据(e2e):首屏 PHP 出的是「2,025」,
* 而 JS 切年后写的是 String(year) = 「2025」——
* 同一个按钮点一下就变样,像 bug 一样。
* 年份是编号不是数量,任何时候都不该有千分位。 */
. '<span class="ar-nav-year">' . esc_html((string) $target) . '</span>'
. '</a>';
}
/**
* 短代码主体
*
* 🔴 签名必须是 `($atts, $content, $tag)` 三个参数 —— 这是 WordPress
* `do_shortcode_tag()` 的调用约定:
* call_user_func( $shortcode_tags[$tag], $attr, $content, $tag )
* 中间的 `$content` 是**闭合式短代码**(`[x]...[/x]`)的中间内容,
* 自闭合短代码传空串。本短代码用不到它,但**位置不能省**:
* 省掉之后 `$tag` 会落到第 2 个参数上,实际收到的 tag 变成空串,
* `shortcode_atts_{$tag}` 就只剩 `shortcode_atts_` 这个无主钩子。
* (3.2.47 开发中实测踩到:直接调函数传 ('Archives') 是对的,
* 过 do_shortcode 才变成空 —— 差别就在中间这个 $content。)
*
* @param array $atts
* @param string $content 闭合式短代码的中间内容(本短代码不用)
* @param string $tag 实际命中的标签名(archives / Archives / ARCHIVES)
* @return string
*/
function yang_archives_shortcode($atts, $content = '', $tag = 'archives')
{
$opt = yang_archives_atts($atts, $tag);
$data = yang_archives_data();
if (empty($data['order'])) {
return '<div class="yang-archives"><p class="ar-empty">'
. esc_html__('还没有任何文章。', 'kratos') . '</p></div>';
}
$year = yang_archives_pick_year($data, $opt['year']);
if ($year === 0) {
return '<div class="yang-archives"><p class="ar-empty">'
. esc_html__('还没有任何文章。', 'kratos') . '</p></div>';
}
/* 资源按需加载:只在真的用了短代码的页面加载。
* 与 theme-toc.php 同一套做法(那边也是 wp_enqueue_scripts 里
* 判 has_marker 再 enqueue)。这里更直接 —— 短代码已经开始渲染了,
* 说明页面确实要用,直接 enqueue 即可。
*
* 🔴 为什么必须走 wp_enqueue_* 而不是手写 <link>:
* 主题的 ASSET_PATH 会随「CDN 加速」选项改变(inc/theme-cdn.php),
* 手写相对路径会绕过 CDN 改写,也拿不到 THEME_VERSION 做缓存失效。 */
wp_enqueue_style(
'yang-archives',
ASSET_PATH . '/assets/css/archives.css',
array('kratos'),
THEME_VERSION
);
wp_enqueue_script(
'yang-archives',
ASSET_PATH . '/assets/js/archives.js',
array(),
THEME_VERSION,
true
);
/* 把全部年份的数据交给 JS。
*
* 🔴 用 `<script type="application/json">` 而不是内联 `var data = {...}`:
* ① 不污染全局作用域;
* ② 内容不需要转义 `</script>`(下面还有一道保险);
* ③ 浏览器不执行它,CSP 里 `unsafe-inline` 也管不到。
*
* 🔴 JSON_HEX_TAG 等四个 flag 是**必须**的:文章标题里出现
* `</script>` 时,若不把这四个字符转成 \\u003C 之类,
* 浏览器的 HTML 解析器会在标题中间就把脚本块截断 ——
* 页面后面全部错乱,而且看起来像"某个标题把页面搞坏了"。 */
$json = wp_json_encode(
array(
'years' => $data['years'],
'order' => $data['order'],
'latest' => $data['latest'],
'total' => $data['total'],
'current' => $year,
'showMonth' => $opt['month'],
'showNav' => $opt['nav'],
'baseUrl' => get_permalink(),
'i18n' => array(
'month' => __('%s 月', 'kratos'),
'prev' => __('上一年', 'kratos'),
'next' => __('下一年', 'kratos'),
'views' => __('浏览数', 'kratos'),
'comments' => __('评论数', 'kratos'),
'love' => __('点赞数', 'kratos'),
'untitled' => __('(无标题)', 'kratos'),
'empty' => __('这一年还没有文章。', 'kratos'),
'totalFmt' => __('共 %s 篇', 'kratos'),
/* 3.2.48:月份计数文案。与 totalFmt 同款,但**单独一个键**——
* 将来若想给月份换个说法(比如「本月 N 篇」),不必牵动年度那行。 */
'monthCount' => __('共 %s 篇', 'kratos'),
),
),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
);
$uid = 'yang-archives-' . wp_unique_id();
$out = '<div class="yang-archives" id="' . esc_attr($uid) . '"'
. ' data-year="' . esc_attr($year) . '">';
// 顶部:年份标题 + 四角导航上排
$out .= '<div class="ar-head">';
/* 🔴 年份标题同样**不能**过 number_format_i18n(会变「2,025」)。
* 而且要输出纯数字,与 JS 切年后的 `String(year)` 完全一致 ——
* 两边不一致就是「首屏 2,025、点一下 2025」。 */
$out .= '<h3 class="ar-year" data-role="year-title">'
. esc_html((string) $year) . '</h3>';
$out .= '<span class="ar-total" data-role="year-total">'
. sprintf(
/* translators: %s: 文章数 */
esc_html__('共 %s 篇', 'kratos'),
esc_html(number_format_i18n($data['years'][$year]['count']))
)
. '</span>';
if ($opt['nav']) {
$out .= yang_archives_render_nav($year, $data['order'], 'top');
}
$out .= '</div>';
// 列表主体
$out .= '<div class="ar-body" data-role="body">';
$out .= yang_archives_render_year($data['years'][$year], $year, $opt['month']);
$out .= '</div>';
// 底部导航(四角的下排)
if ($opt['nav']) {
$out .= '<div class="ar-foot">';
$out .= yang_archives_render_nav($year, $data['order'], 'bottom');
$out .= '</div>';
}
$out .= '<script type="application/json" data-role="data">'
. $json . '</script>';
$out .= '</div>';
return $out;
}
/* 🔴🔴 WP 的 shortcode 标签**区分大小写** —— 这一点 3.2.46 写错过,实测过。
*
* 内核证据(wp-includes/shortcodes.php):
* ① `get_shortcode_regex()` 直接 `implode('|', array_map('preg_quote', $tagnames))`,
* 拿注册名拼正则,**没有做任何大小写归一化**;
* ② `do_shortcode()` 里 `preg_match_all('/' . get_shortcode_regex() . '/', ...)`
* —— 修饰符只有默认,**没有 `i`**;
* ③ `do_shortcode_tag()` 拿到 `$tag = $m[2]` 后直接 `$shortcode_tags[$tag]` 精确查表。
*
* ⇒ `add_shortcode('archives', ...)` **只匹配全小写 `[archives]`**。
* 用户按文档写 `[Archives]`,正则压根不认,WordPress 就把原文原样吐出来
* —— 页面显示 `<p>[Archives]</p>`,看起来极像"短代码没装/被转义",
* 实际是**标签名大小写对不上**。
*
* ⇒ 只能把要支持的写法**逐个注册**。这里注册三种常见形态:
* 全小写(WP 惯例)、首字母大写(文档与用户习惯)、全大写。
* 注册同一个回调是安全的:`$shortcode_tags` 里就是几条 key 指向同一个函数,
* 任何时刻只会命中其中一条(正则里几个分支互斥),不会渲染两次。
*
* ⚠️ 不追求覆盖任意混合大小写(`[aRcHiVeS]`):那需要每隔位置都注册一条,
* 正则会膨胀到不可维护。三种形态已经覆盖真实使用中的全部习惯写法。 */
add_shortcode('archives', 'yang_archives_shortcode');
add_shortcode('Archives', 'yang_archives_shortcode');
add_shortcode('ARCHIVES', 'yang_archives_shortcode');