3.2.18: 清明按节气换算、主题内 [TOC] 目录、重建评论区替代 wpDiscuz

三项功能:

1. 清明不再用固定 04-04
   清明是节气,日期在 4/4~4/6 之间浮动。硬编码 04-04 实测 1900-2100
   共 201 年里只有 76 年是对的(约 38%)。改为内嵌逐年表换算真实日期,
   超出 1900-2100 返回 null 明确放弃,不退化成近似值。
   兼容 3.2.17 及以前勾过的 '04-04' 键,升级后勾选不会被静默丢弃。

2. 主题内实现 [TOC]
   文章里写 [TOC],该位置输出本文目录,点击跳到对应标题。
   在 the_content 优先级 9 处理(早于 wpautop 10 / do_shortcode 11),
   保证"补 id"与"扫目录"用的是同一份文本,href 与 id 必然对得上。
   嵌套用深度归一化,h1→h3 跳级不会算错层级。
   scroll-margin-top 声明式让位,原生锚点/外部 #hash/后退全都生效。

3. 重建评论区
   旧实现的问题:列表渲染与 AJAX 渲染各写一份 HTML,已经分叉
   (AJAX 返回的新评论没有回复按钮);<li> 被 <div> 包着,嵌套回复错位。
   现在统一到 yang_comment_html() 单一渲染函数,列表与 AJAX 只差一个
   is-new 类。同时修掉三处真实缺陷:
     · 评论项用 flex 会把 <ol class="yang-children"> 当成第三个 flex 项,
       子评论跑到父评论右侧、正文被挤到 156px、页面横向溢出 75px —— 改 grid
     · 成功提示在 exitReplyMode() 之前设置,被其末尾的 setStatus('','')
       擦掉,用户提交成功却看不到反馈 —— 调整顺序
     · 窄屏媒体查询打不过深层选择器(媒体查询不增加特异性),
       嵌套头像反向变大 —— 补深层覆盖

   与 wpDiscuz 解耦:邮件模块不再无条件 return false(那等于永久关掉
   notify_postauthor 且不报错);插件在场时不加载主题评论资源,
   避免双份表单互抢。

验证:PHP 语法 73/73;_static_qingming 36、_test_toc 53、
_test_comments 62、_test_comments_falsify 17、
_toc_geometry 121、_toc_geometry_falsify 9、
_comments_geometry 98、_comments_geometry_falsify 23,全部 0 失败。
线上端到端 29/0(真实文章渲染目录 + 真实评论数据渲染)。
This commit is contained in:
2026-10-24 01:03:36 +08:00
parent 4beb09307c
commit cbe0f2dc59
14 changed files with 3577 additions and 192 deletions
+180 -21
View File
@@ -31,6 +31,15 @@
* 当成"每年重复",而是保留为可自定义的具体日期,避免明年 4 月 4 日
* 又自动变黑白(那是错的)。
*
* D. 节气(日期随年份浮动,必须逐年换算)
* · 清明 太阳视黄经达 15° 的时刻所在日
* 清明不是"每年 4 月 4 日"。它在 4 月 4 日、5 日、6 日之间浮动:
* 1900~2100 这 201 年里,落在 4 日的只有 76 年(约 38%),
* 落在 5 日的有 112 年,落在 6 日的有 13 年(都集中在 1939 年以前)。
* 所以**不能**把它当固定 MM-DD 塞进清单里 —— 那样有六成年份是错的,
* 而且错得毫无提示,比不给这个功能更糟。
* 详见 yang_qingming_day()。
*
* ── 设计取舍 ──────────────────────────────────────────────────
* 1. **按站点时区判断**,不按服务器时区。用 current_time('md'),
* 它走 WordPress 设置的时区。否则服务器在 UTC,北京时间 9 月 18 日
@@ -50,12 +59,84 @@ if (!defined('ABSPATH')) {
exit;
}
/**
* 清明节在指定年份的月日(`MM-DD`),换算不出来返回 null
*
* ── 为什么是查表,不是现场算 ──────────────────────────────────
* 清明的天文学定义是「太阳视黄经达到 15° 的那一刻」。理论上可以现场用
* Meeus 的太阳位置公式解方程,但在这个场景下不该这么做:
*
* 1. **跨平台浮点差异会翻日期**。清明时刻可能离午夜只有几十秒
* (例如 1943 年落在 23:58、2071 年落在 00:02),此时 PHP 的
* `sin()/cos()` 在 x86 与 ARM 上的 ULP 级差异足以让二分退化到
* 隔壁一天。日期错一天的代价是「整站黑白错日子」,很难被发现。
* 2. **可验证性**。查表的内容是 201 个字符,可以逐条与天文年历核对;
* 现场算的公式在主题这种被到处复制分发的代码里几乎不可能被验证。
* 3. **成本**。201 个数字就是几十字节,比几十行公式便宜得多。
*
* 表里的值由 ephem 星历计算、并用两套独立实现交叉验证过:
* · ephem(VSOP87 截断)+ 二分求黄经 15°;
* · Meeus《Astronomical Algorithms》第 25 章公式独立实现;
* · cnlunar 民间历法数据表。
* 三者在 2000~2050 完全一致;ephem 与 Meeus 在 1900~2100 的 201 年
* 只有 1943 年不同,而该年清明时刻为 23:58(差距在秒级),属精度边界。
* 与气象/天文年历公布的 2020~2027 时刻比对,日期 8/8 全对,
* 时刻差 4~16 分钟(纯属低精度星历的太阳黄经误差,不跨日)。
*
* ── 为什么超出范围返回 null 而不是猜一个 ─────────────────────
* 用户的要求是「是哪一天就是哪一天,要么就不要这个日期」。
* 2101 年以后的清明我算不准,与其给个可能错的日子,不如**不出现**:
* 那天站点保持彩色,是明确的无害行为;黑白错日子才是真事故。
* 表覆盖到 2100 年,届时主题早该升级了。
*
* @param int|null $year 不传则用站点时区的当前年份
* @return string|null 如 '04-04';无法换算时 null
*/
function yang_qingming_md($year = null)
{
/* 1900~2100 每年清明落在 4 月的哪一天,按年份顺序拼成的字符串。
每个字符对应一年:'4' = 4 月 4 日,'5' = 5 日,'6' = 6 日。
例:第 0 位是 1900 年 → '5' → 1900-04-05。 */
static $seq = '556655665566555655565556555655565556555655555555555555555555'
. '555555555555555545554555455545554555455545554555445544554455'
. '445544554455445544554445444544454445444544454445444544444444'
. '444444444444444444445';
if ($year === null) {
$year = (int) current_time('Y');
}
$year = (int) $year;
// 表只覆盖 1900~2100,范围外明确放弃(见上面注释)
if ($year < 1900 || $year > 2100) {
return null;
}
$idx = $year - 1900;
if (!isset($seq[$idx])) {
return null;
}
$day = (int) $seq[$idx];
if ($day < 1 || $day > 31) {
return null;
}
// 清明永远在 4 月,所以月份写死不会错(表里也只存了「日」)
return sprintf('04-%02d', $day);
}
/**
* 内置纪念日清单
*
* 返回 array( 键 => array(月份, 日, 名称, 说明, 分类, 是否默认勾选) )
* 返回 array( 键 => array(名称, 说明, 分类, 是否默认勾选 [, 动态换算方式]) )
*
* 键名用 `m-d` 形式(补零),与 current_time('m') / current_time('d') 直接可比。
* 键名通常是 `m-d` 形式(补零),与 current_time('m') / current_time('d')
* 直接可比。
*
* **例外:清明**。它的日期随年份浮动(4 月 4/5/6 日),不能用一个固定
* 的 `m-d` 当键 —— 否则等于给自己钉死一个错误的近似值。所以它的键是
* `qingming`,并带 `dynamic => qingming` 标记,由 yang_mourning_days()
* 在运行时按「哪一年」换算成真正的 `MM-DD`。
*/
function yang_mourning_presets()
{
@@ -111,50 +192,106 @@ function yang_mourning_presets()
'group' => 'custom',
'default' => 0,
),
'04-04' => array(
// ── D 类:节气,日期逐年换算 ──
'qingming' => array(
'name' => __('清明节', 'kratos'),
'note' => __('节气日期每年浮动,这里固定为 4 月 4 日,仅作近似', 'kratos'),
'note' => __('节气,日期每年浮动(4 月 4~6 日之间),已按天文历逐年换算,不是固定 4 月 4 日', 'kratos'),
'group' => 'custom',
'default' => 0,
// 让后台清单里不要显示出 "qingming" 这个内部键
'key_label' => __('逐年', 'kratos'),
// 标记:这个日期的 MM-DD 要按年份现算
'dynamic' => 'qingming',
),
);
}
/**
* 读取用户勾选的纪念日
* 把某个日期键解析成「指定年份的真实 MM-DD」
*
* 静态日期(09-03 之类)原样返回;动态日期(清明)按年份换算。
* 换算不出来时返回 null —— 调用方必须把 null 当作「这天不存在」,
* **不要**退化成某个固定值。
*
* @param string $key presets 的键
* @param array $info 该项的 presets 定义
* @param int|null $year 年份,null 用站点时区当前年
* @return string|null
*/
function yang_mourning_resolve_key($key, $info, $year = null)
{
if (isset($info['dynamic']) && $info['dynamic'] === 'qingming') {
return yang_qingming_md($year);
}
// 静态日期:必须是合法 MM-DD 才放行,防止脏数据
if (preg_match('/^\d{2}-\d{2}$/', $key)) {
return $key;
}
return null;
}
/**
* 读取用户勾选的纪念日,返回**该年份真实生效的 `MM-DD` 列表**
*
* 老站点升级上来时这个选项还不存在,此时返回 presets 里 default=1 的那些。
* 注意**不能**简单用 `kratos_option('g_rip_days', 默认数组)`:kratos_option
* 对未设置的键返回默认值,但如果用户曾经保存过空数组(全部取消勾选),
* 存进去的就是空数组,此时必须尊重「用户就是要关掉」,不能再回退到默认。
* 所以这里用「option 数组里有没有这个键」来判断,而不是判断值是否为空。
*
* ── 返回值是 MM-DD,不是勾选的键 ──────────────────────────────
* 因为清明的键是 `qingming`、真实日期是 `04-04`/`04-05`,两者不同。
* 统一解析成 MM-DD 之后,上游的 in_array($today, $days) 一行都不用改。
*
* ── 兼容旧的 '04-04' 勾选 ─────────────────────────────────────
* 3.2.17 及以前的 presets 里清明就是 `04-04`,勾过它的站点库里存着
* `04-04 => 1`。如果直接改键名,这些站点的勾选会被静默丢弃 ——
* 用户明明选了清明,升级后反而没了。所以把 `04-04` 视为 `qingming`。
*
* @param int|null $year 解析哪一年,null 用站点时区当前年
*/
function yang_mourning_days()
function yang_mourning_days($year = null)
{
$presets = yang_mourning_presets();
$opts = get_option('kratos');
$has = is_array($opts) && array_key_exists('g_rip_days', $opts);
// 勾选状态:键 => bool
$picked = array();
if (!$has) {
// 从未保存过:用内置默认
$out = array();
foreach ($presets as $md => $info) {
foreach ($presets as $key => $info) {
if (!empty($info['default'])) {
$out[] = $md;
$picked[$key] = true;
}
}
} else {
$saved = $opts['g_rip_days'];
if (!is_array($saved)) {
return array();
}
foreach ($saved as $key => $v) {
if (!$v) {
continue;
}
// 旧键迁移:3.2.17 及以前的清明
if ($key === '04-04' && isset($presets['qingming'])) {
$picked['qingming'] = true;
continue;
}
// 只保留 presets 里存在的键(防止脏数据)
if (isset($presets[$key])) {
$picked[$key] = true;
}
}
return $out;
}
$saved = $opts['g_rip_days'];
if (!is_array($saved)) {
return array();
}
// 只保留在 presets 里存在、且值为真的日期(防止脏数据)
// 解析成真实 MM-DD
$out = array();
foreach ($saved as $md => $v) {
if ($v && isset($presets[$md])) {
foreach ($picked as $key => $_) {
$md = yang_mourning_resolve_key($key, $presets[$key], $year);
if ($md !== null && !in_array($md, $out, true)) {
$out[] = $md;
}
}
@@ -293,12 +430,27 @@ function yang_mourning_status_text()
sort($days);
$today = current_time('m') . '-' . current_time('d');
$is = in_array($today, $days, true);
/* 列表里给清明加个标注:它在库里是 `qingming` 这个键,
但真正生效的日期由当年换算得出,光看 `04-05` 用户不知道
那是清明还是他自己手填的。 */
$sorted = $days;
$qmd = yang_qingming_md();
if ($qmd !== null) {
foreach ($sorted as $i => $one) {
if ($one === $qmd) {
$sorted[$i] = sprintf(__('%s(清明)', 'kratos'), $one);
break;
}
}
}
return sprintf(
/* translators: 1: 今天的月日, 2: 是否处于哀悼, 3: 生效的日期列表 */
__('今天是 %1$s,%2$s。当前会变黑白的日期:%3$s', 'kratos'),
$today,
$is ? __('正在哀悼,站点已黑白', 'kratos') : __('不在哀悼日', 'kratos'),
implode('、', $days)
implode('、', $sorted)
);
}
/**
@@ -497,9 +649,16 @@ function yang_mourning_today_name()
$md = current_time('m') . '-' . current_time('d');
$presets = yang_mourning_presets();
$days = yang_mourning_days();
if (isset($presets[$md]) && in_array($md, yang_mourning_days(), true)) {
return $presets[$md]['name'];
// 按「解析后的真实 MM-DD」匹配。不能直接 isset($presets[$md]):
// 清明的键是 'qingming' 而不是 '04-04',那样会漏掉它。
if (in_array($md, $days, true)) {
foreach ($presets as $key => $info) {
if (yang_mourning_resolve_key($key, $info) === $md) {
return $info['name'];
}
}
}
// 命中的是「附加日期」里用户自己填的那一天