Files
wordpress-kratos-yang/inc/theme-archives.php
T
laoyang db2e9c4230 3.2.47: 修 [Archives] 首字母大写不生效(WP 短代码标签区分大小写)#17
用户线上实测:页面 /blogtime 写 [Archives] 原样输出 <p>[Archives]</p>,
看起来像"短代码没装/被 markdown 插件转义",实际是标签名大小写对不上。

根因(内核证据,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() 用 $m[2] 精确查 $shortcode_tags
  ⇒ add_shortcode('archives') 只匹配全小写,[Archives] 完全匹配不上。

线上实测(真实 WP 环境跑 do_shortcode):
  [archives]  长度=209239  解析=是
  [Archives]  长度=10      解析=否(原样返回)
  [ARCHIVES]  长度=10      解析=否(原样返回)

3.2.46 里我写的注释"WP 短代码标签大小写不敏感、内部统一 strtolower"
是错的,这一版一并改正。

改动:
  · inc/theme-archives.php
    - 同时注册 archives / Archives / ARCHIVES 三种写法
      (不为任意混合大小写膨胀正则,[aRcHiVeS] 刻意不支持,已加断言锁住)
    - 修短代码签名:$atts, $content, $tag —— 补上中间的 $content
      WP 通过 call_user_func($fn, $attr, $content, $tag) 传三个参数,
      漏掉 $content 会让 $tag 落到第 2 个形参上,实际收到空串,
      shortcode_atts_{$tag} 退化成无主的 shortcode_atts_
    - shortcode_atts 第 3 参数改为实际命中的 $tag(原来写死 'archives')
  · style.css 版本号 3.2.46 -> 3.2.47

测试也一并修(这才是本轮最值得记的):
  · _archives_unit.php 的 add_shortcode 桩原来写了 strtolower($tag),
    比真实 WP 宽容 ⇒ 产品坏着、94 条断言全绿。
    现在照抄内核不归一化,并新增真实的 do_shortcode 解析实现。
  · ⑪ 组从"查注册表有没有 archives 这个键"改成"拿真实写法解析一遍"
  · 新增断言:三种写法都渲染出 .yang-archives、
    混合大小写原样返回(刻意不支持)、未注册标签原样保留、
    shortcode_atts 收到的 tag 恰好是实际命中的那个
  · _archives_unit_falsify.py 新增 R5~R8 四条注入:
    R5 只注册小写(3.2.46 的错误形态)→ 红 10 条
    R6 大写注册指向不存在的回调       → 红 4 条
    R7 shortcode_atts 的 tag 写死小写 → 红 2 条
    R8 签名去掉中间的 $content        → 红 3 条

验证:
  2j 评论关闭零输出        通过  54 / 0 失败
  2k 评论关闭反向验证      真红  13 / 0 无效
  2l 归档单测              通过 109 / 0 失败(原 94,新增 15 条)
  2m 归档端到端            通过 109 / 0 失败
  2n 归档端到端反向        真红  21 / 0 无效
  2o 归档源码级反向        真红  10 / 0 无效(原 6,新增 4 条)
  lint  96 个 PHP 文件全过

线上验证(真实 WordPress,不是模拟):
  补注册前:只 ["archives"],[Archives] 原样返回
  补注册后:["archives","Archives","ARCHIVES"],三种写法长度全是 209239

发布:已推 3.2.47 到 /theme-update/,update.json 已落 3.2.47;
      服务器 wp-content/themes/kratos_yang/ 仍为 3.2.46(未覆盖,
      等用户在后台点升级)。
2026-10-26 14:04:12 +08:00

735 lines
29 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
* @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">'
. 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'),
),
),
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');