Files
wordpress-kratos-yang/assets/js/archives.js
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

538 lines
17 KiB
JavaScript
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.
/*!
* 归档式文章列表([Archives])—— 年份切换
* Yongquan Yang <yangyq.net>
*
* ── 它只做一件事 ────────────────────────────────────────────
* 把 `<script type="application/json" data-role="data">` 里那份数据
* 按选中的年份重建成 DOM,替换 `[data-role="body"]` 的内容。
* **不发任何请求** —— 数据首屏就全在页面里了。
*
* ── 为什么"不刷新"要做成增强而不是替代 ──────────────────────
* 导航按钮本身是 `<a href="?arc_year=2025">`。这意味着:
* · JS 没加载 / 报错 → 点下去是新的一次请求,仍然能看到 2025 的归档;
* · 中键 / Ctrl+点击 → 新标签打开对应年份,行为符合直觉;
* · 爬虫 → 顺着链接能爬到每一年。
* JS 接管的只是"同页内的普通左键点击":`preventDefault` 后走 DOM 重建。
* 这样"不刷新"是锦上添花,而不是唯一能用的一条路。
*
* ── 为什么用事件委托而不是给每个按钮绑 ──────────────────────
* 每次切年都会重建导航(禁用态、目标年份都要变),逐个绑就得逐个解绑,
* 漏一个就是"点一下就切两年"。委托到容器上,重建多少次都不会漏。
*
* ── 为什么用 scrollIntoView 而不是 scrollTop ────────────────
* 切年后列表高度会变(12 个月 → 3 个月)。若不做处理,用户可能停在
* 页面中部、看到的是一片空白,会以为"点坏了"。切完把容器顶部滚到
* 视口内即可,同时尊重 `prefers-reduced-motion`。
*/
(function () {
'use strict';
var ROOT_SEL = '.yang-archives';
/**
* 安全的 textContent 设置
*
* 一律走 textContent,**绝不用 innerHTML 拼标题** ——
* 文章标题里可能带 `<`、`&`、引号,拼进去就是 XSS 面。
*/
function txt(node, s) {
node.textContent = s == null ? '' : String(s);
return node;
}
/**
* 建一个元素
*
* @param {string} tag
* @param {string} cls
* @param {string} [text]
* @returns {HTMLElement}
*/
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) {
n.className = cls;
}
if (text !== undefined) {
txt(n, text);
}
return n;
}
/**
* 读页面里的 JSON 数据
*
* 🔴 解析失败必须**出声**,不能静默 return。
* 这个 JSON 是 PHP 用 JSON_HEX_TAG 等四个 flag 编的,
* 正常不可能坏;一旦坏了(比如别的插件过滤了短代码输出),
* 页面会表现为"按钮点了没反应",而控制台一个字都没有 ——
* 那是最难查的一类。所以这里必须 console.error。
*
* @param {HTMLElement} root
* @returns {Object|null}
*/
function readData(root) {
var node = root.querySelector('[data-role="data"]');
if (!node) {
if (window.console && console.error) {
console.error('[yang-archives] 找不到数据节点 [data-role="data"],'
+ '年份切换不可用。检查 inc/theme-archives.php 的输出。');
}
return null;
}
try {
return JSON.parse(node.textContent || '{}');
} catch (e) {
if (window.console && console.error) {
console.error('[yang-archives] 数据解析失败,年份切换不可用:', e);
}
return null;
}
}
/**
* 渲染单条
*
* ⚠️ 类名必须与 PHP 的 yang_archives_render_item() **逐个一致**。
* 首屏是 PHP 出的、切年后是这里出的,两者样式必须完全一样,
* 否则会出现"刚进来是对的、点一下年就变样"。
* 判据在测试 `_archives_e2e.js`:它比对两条路径建出的类名集合。
*
* @param {Object} it
* @param {number} month
* @param {Object} i18n
* @returns {HTMLLIElement}
*/
function buildItem(it, month, i18n) {
var li = el('li', 'ar-item');
var date = el('time', 'ar-date', pad2(month) + '-' + pad2(it.day));
date.setAttribute('datetime', it.date || '');
li.appendChild(date);
var a = el('a', 'ar-title');
a.href = it.url || '#';
txt(a, it.title || i18n.untitled);
li.appendChild(a);
var stats = el('span', 'ar-stats');
stats.appendChild(buildStat('views', 'i-hot', it.views, i18n.views));
stats.appendChild(buildStat('comments', 'i-comments', it.comments, i18n.comments));
stats.appendChild(buildStat('love', 'i-good', it.love, i18n.love));
li.appendChild(stats);
return li;
}
/**
* 单个计数
*
* 结构与 PHP 的 yang_archives_render_stat() 一致(含 data-zero 与 sr-only)。
*
* @param {string} kind
* @param {string} icon
* @param {number} num
* @param {string} label
* @returns {HTMLSpanElement}
*/
function buildStat(kind, icon, num, label) {
var n = parseInt(num, 10);
if (!isFinite(n) || n < 0) {
n = 0;
}
var s = el('span', 'ar-stat ar-stat-' + kind);
s.setAttribute('title', label || '');
s.setAttribute('data-zero', n === 0 ? '1' : '0');
var i = document.createElement('i');
i.className = 'kicon ' + icon;
i.setAttribute('aria-hidden', 'true');
s.appendChild(i);
s.appendChild(el('span', 'ar-num', formatNum(n)));
var sr = el('span', 'sr-only', label || '');
s.appendChild(sr);
return s;
}
/** 两位补零 */
function pad2(v) {
v = String(v == null ? '' : v);
return v.length >= 2 ? v : ('0' + v);
}
/**
* 千分位
*
* 与服务端的 number_format_i18n 对齐。这里只做最常见的"每三位一个
* 逗号",不跟随 WP 的语言设置 —— 归档计数是纯数字,差异只在
* 少数语言的分隔符上;真要精确对齐,应该把格式化后的字符串
* 直接从 PHP 传下来。目前站内是中文/英文,逗号一致。
*/
function formatNum(n) {
var s = String(n);
if (s.length <= 3) {
return s;
}
return s.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* 渲染某一年的列表
*
* @param {Object} yd {count, months}
* @param {number} year
* @param {number} showMonth
* @param {Object} i18n
* @returns {DocumentFragment}
*/
function buildYear(yd, year, showMonth, i18n) {
var frag = document.createDocumentFragment();
if (!yd || !yd.months) {
frag.appendChild(el('p', 'ar-empty', i18n.empty));
return frag;
}
// 月份升序(1 月在先)—— 与 PHP 的 ksort 一致
var months = Object.keys(yd.months).map(Number).sort(function (a, b) {
return a - b;
});
if (!months.length) {
frag.appendChild(el('p', 'ar-empty', i18n.empty));
return frag;
}
months.forEach(function (m) {
var md = yd.months[m] || yd.months[String(m)];
if (!md) {
return;
}
var box = el('div', 'ar-month');
if (showMonth) {
var h = el('h4', 'ar-month-title', fmt(i18n.month, pad2MonthLabel(m)));
var cnt = el('span', 'ar-month-count', formatNum(md.count || 0));
h.appendChild(cnt);
box.appendChild(h);
}
var ul = el('ul', 'ar-list');
(md.items || []).forEach(function (it) {
ul.appendChild(buildItem(it, m, i18n));
});
box.appendChild(ul);
frag.appendChild(box);
});
return frag;
}
/**
* 月份标题文字
*
* PHP 侧用的是 number_format_i18n($m),中文环境就是 "10"。
* 这里不做补零(与 PHP 保持一致),只在必要时补。
*/
function pad2MonthLabel(m) {
return String(m);
}
/** 极简 %s 占位符替换(不引入 sprintf 依赖) */
function fmt(tpl, val) {
if (typeof tpl !== 'string') {
return String(val);
}
return tpl.replace('%s', val);
}
/**
* 找一个方向的目标年份
*
* `order` 是**倒序**年份数组:[2026, 2025, 2024]
* · prev(上一年/更近)→ 索引 - 1
* · next(下一年/更早)→ 索引 + 1
*
* 🔴 这个方向与"按下一年却回到过去"的文案一致,见 PHP 里的说明。
*
* @param {number[]} order
* @param {number} year
* @param {string} dir prev|next
* @returns {number} 0 表示没有
*/
function pickTarget(order, year, dir) {
var i = order.indexOf(Number(year));
if (i < 0) {
return 0;
}
var t = dir === 'prev' ? i - 1 : i + 1;
return order[t] === undefined ? 0 : Number(order[t]);
}
/**
* 重建导航
*
* 每次切年都要重建:目标年份变了、禁用态变了。
*
* @param {HTMLElement} navBox .ar-nav 容器
* @param {Object} cfg {order, year, i18n, baseUrl}
*/
function rebuildNav(navBox, cfg) {
if (!navBox) {
return;
}
navBox.innerHTML = '';
var prev = pickTarget(cfg.order, cfg.year, 'prev');
var next = pickTarget(cfg.order, cfg.year, 'next');
navBox.appendChild(buildNavBtn(prev, 'prev', cfg));
navBox.appendChild(buildNavBtn(next, 'next', cfg));
}
/**
* 单个导航按钮
*
* 结构与 PHP 的 yang_archives_render_nav_btn() 一致。
*
* @param {number} target
* @param {string} dir
* @param {Object} cfg
* @returns {HTMLElement}
*/
function buildNavBtn(target, dir, cfg) {
var isPrev = dir === 'prev';
var label = isPrev ? cfg.i18n.prev : cfg.i18n.next;
var arrow = isPrev ? '\u2191' : '\u2193';
if (!target) {
var span = el('span', 'ar-nav-btn is-disabled');
span.setAttribute('aria-disabled', 'true');
span.appendChild(el('span', 'ar-nav-arrow', arrow));
span.appendChild(el('span', 'ar-nav-label', label));
return span;
}
var a = el('a', 'ar-nav-btn ar-nav-' + dir);
/* 回退地址带上目标年份:无 JS / 新标签打开都能用。
* 用 URL 对象改参数,不去猜 baseUrl 里有没有 ? —— 手工拼
* 字符串在带 ? 的地址上会拼出 `?a=1&arc_year=2` 这种错。 */
a.href = buildYearUrl(cfg.baseUrl, target);
a.setAttribute('data-year', String(target));
a.setAttribute('rel', 'nofollow');
a.appendChild(el('span', 'ar-nav-arrow', arrow));
a.appendChild(el('span', 'ar-nav-label', label));
/* 🔴 年份不千分位。`formatNum(2025)` 会输出「2,025」——
* 与 PHP 侧的 number_format_i18n 犯的是同一个错,两边一起改。
* 年份是编号,不是数量。 */
a.appendChild(el('span', 'ar-nav-year', String(target)));
return a;
}
/**
* 拼 ?arc_year=YYYY
*
* @param {string} base
* @param {number} year
* @returns {string}
*/
function buildYearUrl(base, year) {
try {
var u = new URL(base || window.location.href, window.location.href);
u.searchParams.set('arc_year', String(year));
/* 去掉 page 参数:切年后还带着原来的分页页码,在分页型页面上
* 会跳到一个不存在的页,表现是"404 或空白"。 */
u.searchParams.delete('page');
return u.toString();
} catch (e) {
// URL 不可用(很老的浏览器)就退回字符串拼
var s = (base || window.location.pathname);
return s + (s.indexOf('?') >= 0 ? '&' : '?') + 'arc_year=' + year;
}
}
/**
* 切到某一年
*
* @param {HTMLElement} root
* @param {Object} data
* @param {number} year
* @param {boolean} [push] 是否把年份写进地址栏
*/
function switchTo(root, data, year, push) {
var yd = data.years[year] || data.years[String(year)];
if (!yd) {
// 目标年份没有数据 —— 正常不会走到(导航按 order 算的),
// 但**不能静默**:出声比"点了没反应"好查得多。
if (window.console && console.warn) {
console.warn('[yang-archives] 没有 ' + year + ' 年的归档数据');
}
return;
}
var body = root.querySelector('[data-role="body"]');
var titleEl = root.querySelector('[data-role="year-title"]');
var totalEl = root.querySelector('[data-role="year-total"]');
var i18n = data.i18n || {};
if (body) {
body.innerHTML = '';
body.appendChild(buildYear(yd, year, data.showMonth, i18n));
/* 淡入:先移除再加类,确保连续切年时动画每次都会重放
* (同一个类名不加移除,浏览器认为没变化,动画不会重跑)。 */
body.classList.remove('is-switching');
// 读一次布局属性强制回流,让上面的移除生效
void body.offsetWidth;
body.classList.add('is-switching');
}
if (titleEl) {
titleEl.textContent = String(year);
}
if (totalEl) {
totalEl.textContent = fmt(i18n.totalFmt || '共 %s 篇', formatNum(yd.count || 0));
}
root.setAttribute('data-year', String(year));
data.current = year;
// 两个导航(上排 + 下排)都要重建
var navs = root.querySelectorAll('.ar-nav');
for (var i = 0; i < navs.length; i++) {
rebuildNav(navs[i], {
order: data.order,
year: year,
i18n: i18n,
baseUrl: data.baseUrl || window.location.href
});
}
/* 地址栏同步。用 replaceState 而不是 pushState:
* 翻归档年份是"同一次浏览里的筛选",不该在历史里留下十几条记录
* (用户按后退会一年一年退回去,体验很差)。
* 同时让复制出去的链接能定位到当前年份 —— 这正是 PHP 侧
* 「?arc_year 优先于短代码 year」的配套。 */
if (push && window.history && typeof window.history.replaceState === 'function') {
try {
window.history.replaceState(
{ arcYear: year },
'',
buildYearUrl(data.baseUrl || window.location.href, year)
);
} catch (e) {
/* 跨域等情况下 replaceState 会抛。地址栏没同步不影响功能,
* 但也不该让整个切换失败 —— 所以只吞掉这一个异常,
* 上面的 DOM 已经更新好了。 */
}
}
/* 把容器顶部拉回视口。
* 🔴 必须放在 DOM 更新**之后**:切年前量的位置,切年后高度变了,
* 滚到旧位置可能正好落在空白处。 */
scrollToRoot(root);
}
/**
* 切年后把容器顶部带回视野
*
* 只在"用户已经滑过容器顶部"时才滚 —— 否则用户本来就在看列表,
* 无端滚动等于把阅读位置丢掉。
*
* @param {HTMLElement} root
*/
function scrollToRoot(root) {
try {
var rect = root.getBoundingClientRect();
var headRoom = 60; // 预留一点顶部空间,别贴着视口边缘
if (rect.top < headRoom) {
var reduce = window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
root.scrollIntoView({
behavior: reduce ? 'auto' : 'smooth',
block: 'start'
});
}
} catch (e) {
/* getBoundingClientRect / scrollIntoView 在极老浏览器上可能没有,
* 或元素已从文档里移除。滚不动不影响功能,静默即可。 */
}
}
/**
* 初始化一个容器
*
* @param {HTMLElement} root
*/
function initRoot(root) {
// 幂等:同一个容器被初始化两次会导致一次点击切两年
if (root.getAttribute('data-ar-ready') === '1') {
return;
}
var data = readData(root);
if (!data || !data.order || !data.order.length) {
return;
}
root.setAttribute('data-ar-ready', '1');
/* 事件委托:click 挂在容器上。
* 只接管"左键 + 无修饰键 + 落在 .ar-nav-btn 上"的点击,
* 其余(中键、Ctrl/Cmd/Shift + 点击)交给浏览器默认行为 ——
* 这样"新标签打开某一年"照常可用。 */
root.addEventListener('click', function (ev) {
if (ev.defaultPrevented || ev.button !== 0) {
return;
}
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) {
return;
}
var btn = ev.target && ev.target.closest
? ev.target.closest('.ar-nav-btn')
: null;
if (!btn || !root.contains(btn)) {
return;
}
if (btn.classList && btn.classList.contains('is-disabled')) {
return;
}
var target = parseInt(btn.getAttribute('data-year'), 10);
if (!target || target === data.current) {
return;
}
// 接管:不刷新页面
ev.preventDefault();
switchTo(root, data, target, true);
});
}
/**
* 启动
*
* 脚本在页脚输出(`in_footer = true`),正常此时 DOM 已就绪;
* 但仍然判一次 readyState —— 万一被别的插件提前加载,
* 直接 `querySelectorAll` 会拿到空集,页面就永久不工作了。
*/
function boot() {
var roots = document.querySelectorAll(ROOT_SEL);
for (var i = 0; i < roots.length; i++) {
initRoot(roots[i]);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();