Files
wordpress-kratos-yang/inc/theme-archives.php
T
laoyang 7047ab3ece 3.2.46: 新增 [Archives] 归档短代码 + 评论关闭时整块零输出 #15
需求一:[Archives] 短代码 —— 归档式全站文章列表
  · 在文章/页面正文写一行 [Archives],该位置渲染全站文章的年月归档
  · 默认显示今年,按月份归类;每篇带浏览/评论/点赞三个数
  · 上下两排各有一组「上一年 / 下一年」按钮(四角),JS 切换不刷新页面
  · 地址栏同步 ?arc_year=,刷新与分享链接都能保持同一年
  · 只在用到短代码的页面才加载 assets/js|css/archives.js|css
  · 参数:year="2025" 指定首年;month="0" 不按月分组

需求二:评论关闭时评论区整块消失
  · comments.php 在**任何输出之前** `if (!comments_open()) return;`
    —— 零字节,不是空容器、不是 display:none、不留提示文字
  · 已有评论的老文章关评论后也不再显示(需求原话:正文之外什么都没有)
  · inc/theme-comments.php 的资源守卫与之同口径:关评论时不加载
    约 10KB 的 comments.css / comments.js
  · 删掉 .comments-closed 样式与两处「评论已关闭」提示分支

开发中实测抓到并修掉的三个真实 bug(都是"测试把期望值写对之后"才显形):
  · 年份标题 / 导航年份过了 number_format_i18n() ⇒ 输出「2,025」,
    而 JS 侧 String(year) 输出「2025」⇒ 首屏与切年后不一致
  · 月份标题同样过 number_format_i18n(),与 JS 侧 String(m) 口径不一致
  · yang_archives_render_nav() 把 ar-nav-top 写死,而下排也调它生成
    ⇒ 下排被叫成 top(视觉上被更高特异性规则压住,看不出问题)

验证:
  归档  _archives_unit.php        94 通过 / 0 失败
        _archives_e2e.js        109 通过 / 0 失败
        _archives_e2e_falsify.js 反证 25 真红 / 0 无效
        _archives_unit_falsify.py 反证 6 真红 / 0 无效
  评论  _comments_closed_test.php 54 通过 / 0 失败
        _comments_closed_falsify.py 反证 13 真红 / 0 无效
  回归  _test_comments.php        64 通过 / 0 失败
        _hl_unit_run.py          24 通过 / 0 失败
  全量  96 个 PHP 文件语法检查通过;包内 lint 通过
  发布  已推 3.2.46 到 /theme-update/,update.json 已落 3.2.46;
        服务器 wp-content/themes/kratos_yang/ 仍为 3.2.45(未覆盖)

另:把 _archives_unit.php 从 `__DIR__` 定位改为接受根目录参数,
    否则它无法登记进 verify_all.sh,只能手动跑(手动跑 = 长期不跑)。
    5 个新套件已登记进 verify_all.sh(2j ~ 2o)。
2026-10-26 13:25:51 +08:00

697 lines
26 KiB
PHP
Raw 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;
}
// 年份倒序、月份升序 —— 见文件头「排序」一节
krsort($years);
foreach ($years as $y => $yv) {
ksort($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
* @return array
*/
function yang_archives_atts($atts)
{
$a = shortcode_atts(array(
'year' => '',
'month' => '1',
'nav' => '1',
), $atts, 'archives');
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">'
. 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>';
}
/**
* 短代码主体
*
* @param array $atts
* @return string
*/
function yang_archives_shortcode($atts)
{
$opt = yang_archives_atts($atts);
$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'),
),
),
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 标签是**大小写不敏感**的(它内部统一 strtolower),
* 所以注册小写 `archives` 就能同时匹配 [Archives] / [ARCHIVES] / [archives]。
* 这里按 WP 惯例注册小写。 */
add_shortcode('archives', 'yang_archives_shortcode');