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
+18 -101
View File
@@ -197,101 +197,21 @@ function pagelist($range = 5)
}
}
// 文章评论
function comment_scripts()
{
wp_enqueue_script('comment', ASSET_PATH . '/assets/js/comments.min.js', array(), THEME_VERSION);
wp_localize_script('comment', 'ajaxcomment', array(
'ajax_url' => admin_url('admin-ajax.php'),
'order' => get_option('comment_order')
));
}
add_action('wp_enqueue_scripts', 'comment_scripts');
function comment_err($a)
{
header('HTTP/1.0 500 Internal Server Error');
header('Content-Type: text/plain;charset=UTF-8');
echo $a;
exit;
}
function comment_callback()
{
$comment = wp_handle_comment_submission(wp_unslash($_POST));
if (is_wp_error($comment)) {
$data = $comment->get_error_data();
if (!empty($data)) {
comment_err($comment->get_error_message());
} else {
exit;
}
}
$user = wp_get_current_user();
do_action('set_comment_cookies', $comment, $user);
$GLOBALS['comment'] = $comment;
?>
<li class="comment cleanfix" id="comment-<?php echo esc_attr(comment_ID()); ?>">
<div class="avatar float-left d-inline-block mr-2">
<?php if (function_exists('get_avatar') && get_option('show_avatars')) {echo get_avatar($comment, 50);}?>
</div>
<div class="info clearfix">
<cite class="author_name"><?php echo get_comment_author_link();?></cite>
<div class="content pb-2">
<?php comment_text();?>
</div>
<div class="meta clearfix">
<div class="date d-inline-block float-left"><?php echo get_comment_date('Y年m月d日'); ?><?php if (current_user_can('edit_posts')) {echo '<span class="ml-2">';edit_comment_link(__('编辑', 'kratos'));echo '</span>';};?>
</div>
</div>
</div>
</li>
<?php die();
}
add_action('wp_ajax_nopriv_ajax_comment', 'comment_callback');
add_action('wp_ajax_ajax_comment', 'comment_callback');
function comment_post($incoming_comment)
{
$incoming_comment['comment_content'] = htmlspecialchars($incoming_comment['comment_content']);
$incoming_comment['comment_content'] = str_replace("'", '&apos;', $incoming_comment['comment_content']);
return ($incoming_comment);
}
add_filter('preprocess_comment', 'comment_post', '', 1);
function comment_display($comment_to_display)
{
$comment_to_display = str_replace('&apos;', "'", $comment_to_display);
return $comment_to_display;
}
add_filter('comment_text', 'comment_display', '', 1);
function comment_callbacks($comment, $args, $depth = 2)
{
$GLOBALS['comment'] = $comment;?>
<li class="comment cleanfix" id="comment-<?php echo esc_attr(comment_ID()); ?>">
<div class="avatar float-left d-inline-block mr-2">
<?php if (function_exists('get_avatar') && get_option('show_avatars')) {echo get_avatar($comment, 50);}?>
</div>
<div class="info clearfix">
<cite class="author_name"><?php echo get_comment_author_link();?></cite>
<div class="content pb-2">
<?php comment_text();?>
</div>
<div class="meta clearfix">
<div class="date d-inline-block float-left"><?php echo get_comment_date('Y年m月d日'); ?><?php if (current_user_can('edit_posts')) {echo '<span class="ml-2">';edit_comment_link(__('编辑', 'kratos'));echo '</span>';};?>
</div>
<div class="tool reply ml-2 d-inline-block float-right">
<?php
$defaults = array('add_below' => 'comment', 'respond_id' => 'respond', 'reply_text' => '<i class="kicon i-reply"></i><span class="ml-1">' . __('回复', 'kratos') . '</span>');
comment_reply_link(array_merge($defaults, array('depth' => $depth, 'max_depth' => $args['max_depth'])));
?>
</div>
</div>
</div>
<?php
}
/* ── 评论相关已迁到 inc/theme-comments.php(3.2.18)──────────────
*
* 原先这里有一段 201~294 行的评论实现(comment_scripts /
* comment_callback / comment_callbacks)和 380~385 行的
* recover_comment_fields。迁走的原因是:
*
* · 列表渲染与 AJAX 渲染**各写了一份 HTML**,已经分叉 ——
* AJAX 返回的新评论没有「回复」按钮,必须刷新页面才出现;
* · 提交逻辑用的是旧的 layer 弹窗 + 直接透传后端原始响应;
* · 缺少 nonce 校验、缺少防重复提交。
*
* 新实现统一到 yang_comment_html() 一个渲染函数,两边都调它,
* 从结构上消除分叉可能。函数名 comment_callbacks / comment_scripts
* 也都在新文件里以兼容形式保留,所以 comments.php 不必改调用点。
* ------------------------------------------------------------ */
// 文章评论表情
function custom_smilies_src($img_src, $img, $siteurl)
@@ -377,12 +297,9 @@ function comment_add_at($comment_text, $comment = '') {
}
add_filter('comment_text' , 'comment_add_at', 20, 2);
function recover_comment_fields($comment_fields){
$comment = array_shift($comment_fields);
$comment_fields = array_merge($comment_fields ,array('comment' => $comment));
return $comment_fields;
}
add_filter('comment_form_fields','recover_comment_fields');
/* recover_comment_fields() 已由 inc/theme-comments.php 里的
yang_comment_form_fields() 接替(同样的字段顺序,但不再用
array_shift 硬拿第一个 —— 那样在插件往字段数组前面插东西时会拿错)。 */
$new_meta_boxes =
array(
+761
View File
@@ -0,0 +1,761 @@
<?php
/**
* 评论区:统一渲染 + AJAX 提交 + 评论通知
*
* ── 为什么要重写 ──────────────────────────────────────────────
* 旧实现有两个根本问题,都不是"不好看"能概括的:
*
* 1. **同一份评论渲染了两遍,而且已经分叉**。
* `comment_callbacks()`(列表渲染)里有「回复」按钮,
* `comment_callback()`(AJAX 提交后返回的新评论)里**没有** ——
* 于是通过 AJAX 刚发出来的那条评论,页面上根本没有回复入口,
* 刷新一次才会出现(因为刷新走的是列表渲染)。
* 这类 bug 的根因不是漏写,是"两个地方各写一份 HTML"。
* 现在两边都调 `yang_comment_html()`,物理上不可能再分叉。
*
* 2. **列表容器是 <div>,里面直接放 <li>**。
* `comments.php` 原来写的是 `<div class="list">` + `wp_list_comments()`,
* 后者输出的是 `<li>`。`<li>` 的父元素必须是 <ul>/<ol>,
* 否则浏览器会自己"猜"结构,不同浏览器猜法不同,
* 嵌套回复(<ul class="children">)在某些浏览器里会错位。
*
* ── AJAX 设计的三条约束 ──────────────────────────────────────
* 1. **不用 layer 弹提示**。layer 是个通用弹窗库,用来显示"评论提交成功"
* 太重(遮罩、动画、层级都多余),而且它失败时会把整个提示吞掉。
* 这里用轻量的内联状态条,并直接显示后端返回的**人话**错误。
* 2. **必须防重复提交**。旧实现 beforeSend 只弹了个"提交中",
* 按钮仍可点,网络慢时用户会连点,产生多条重复评论。
* 3. **后端错误要可读**。WordPress 的评论错误是 WP_Error,
* 旧代码把 message 直接扔给前端,有些情况下是空串或英文提示。
* 这里统一映射成中文,并区分"表单错误"(可修正)与"系统错误"。
*
* ── 与 wpDiscuz 的关系 ───────────────────────────────────────
* 这个文件是**替代** wpDiscuz 的实现,不是与它共存。装了插件时
* 插件会接管评论表单,主题这套不生效(也不该生效,两套会打架)。
* 卸载插件后,主题这套自动接管,且评论通知不依赖插件 ——
* 见本文件末尾的通知部分。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* wpDiscuz 是否在接管评论
*
* 用途:卸载插件后要自动切到主题通知,不能依赖插件还活着。
* 只看类/函数是否存在,不做 is_plugin_active() —— 那需要
* 在后台上下文才有 plugin.php,前台调用会 Fatal。
*/
function yang_comments_plugin_active()
{
return class_exists('WpdiscuzCore') || function_exists('wpdiscuz');
}
/**
* 渲染单条评论的 HTML
*
* **列表渲染与 AJAX 返回共用这一个函数**。这是本次重写的核心 ——
* 只要两处都调它,就不可能再出现"AJAX 出来的评论少个按钮"。
*
* @param WP_Comment $comment 评论对象
* @param array $args wp_list_comments 传入的参数(深度等)
* @param int $depth 当前深度
* @param bool $is_ajax 是否是被 AJAX 返回的新评论
* @return string
*/
function yang_comment_html($comment, $args = array(), $depth = 1, $is_ajax = false)
{
if (!($comment instanceof WP_Comment)) {
return '';
}
$comment_id = (int) $comment->comment_ID;
$max_depth = isset($args['max_depth']) ? (int) $args['max_depth'] : 0;
$depth = max(1, (int) $depth);
// 作者能否编辑(管理员/编辑在每条上看到「编辑」入口)
$can_edit = current_user_can('edit_comment', $comment_id);
// 评论者是不是文章作者(给个标记,便于区分)
$post = get_post($comment->comment_post_ID);
$is_post_author = $post && ((int) $post->post_author === (int) $comment->user_id) && $comment->user_id > 0;
$author_name = $comment->comment_author !== ''
? $comment->comment_author
: __('匿名访客', 'kratos');
/* 评论内容:comment_text() 会走 WordPress 的完整过滤链
(smilies、wpautop、kese、@提及高亮都在那上面挂着),
所以必须用它,而不是 $comment->comment_content。 */
ob_start();
comment_text($comment_id);
$content = ob_get_clean();
$avatar = '';
if (get_option('show_avatars') && function_exists('get_avatar')) {
$avatar = get_avatar($comment, 48, '', $author_name, array(
'class' => 'yang-comment-avatar-img',
'extra_attr' => 'loading="lazy"',
));
}
$classes = array('yang-comment');
if ($is_ajax) {
$classes[] = 'is-new';
}
if ($comment->comment_approved === '0') {
$classes[] = 'is-pending';
}
$o = array();
$o[] = '<li id="comment-' . $comment_id . '" class="' . esc_attr(implode(' ', $classes)) . '"'
. ' data-comment-id="' . $comment_id . '"'
. ' data-depth="' . $depth . '">';
// ── 头像列 ──
$o[] = '<div class="yang-comment-avatar">';
if ($avatar !== '') {
$o[] = $avatar;
} else {
// 没头像时给个占位,别让左侧塌掉导致文字贴边
$o[] = '<span class="yang-comment-avatar-ph" aria-hidden="true">'
. esc_html(mb_substr($author_name, 0, 1, 'UTF-8'))
. '</span>';
}
$o[] = '</div>';
// ── 主体 ──
$o[] = '<div class="yang-comment-main">';
$o[] = '<div class="yang-comment-head">';
$o[] = '<cite class="yang-comment-author">' . get_comment_author_link($comment) . '</cite>';
if ($is_post_author) {
$o[] = '<span class="yang-comment-badge">' . __('作者', 'kratos') . '</span>';
}
if ($comment->comment_approved === '0') {
$o[] = '<span class="yang-comment-badge is-pending">' . __('待审核', 'kratos') . '</span>';
}
$o[] = '<time class="yang-comment-time" datetime="'
. esc_attr(mysql2date('c', $comment->comment_date)) . '">'
. esc_html(mysql2date('Y年m月d日 H:i', $comment->comment_date))
. '</time>';
$o[] = '</div>'; // head
// 被回复者的提示(@某人),由 comment_add_at 过滤器加到正文里,
// 这里不再重复,避免出现两次
$o[] = '<div class="yang-comment-content">' . $content . '</div>';
// ── 操作区 ──
$o[] = '<div class="yang-comment-actions">';
/* 回复按钮:可回复的前提有三个 ——
① 评论还开着;② 没超过主题设置的嵌套层数;③ 不是待审核的。 */
$can_reply = comments_open($comment->comment_post_ID);
if ($max_depth > 0 && $depth >= $max_depth) {
$can_reply = false;
}
if ($comment->comment_approved === '0' && !$can_edit) {
$can_reply = false;
}
if ($can_reply) {
$o[] = '<button type="button" class="yang-comment-reply"'
. ' data-comment-id="' . $comment_id . '"'
. ' data-author="' . esc_attr($author_name) . '">'
. '<i class="kicon i-reply" aria-hidden="true"></i>'
. '<span>' . __('回复', 'kratos') . '</span>'
. '</button>';
}
if ($can_edit) {
$o[] = '<a class="yang-comment-edit" href="' . esc_url(get_edit_comment_link($comment_id)) . '">'
. __('编辑', 'kratos') . '</a>';
}
$o[] = '</div>'; // actions
$o[] = '</div>'; // main
$o[] = '</li>';
return implode('', $o);
}
/**
* wp_list_comments 的回调(列表渲染)
*
* 只是把参数转交给统一渲染函数。保留这个函数名是因为
* comments.php 里引用了它,且子主题可能覆盖过。
*/
function comment_callbacks($comment, $args = array(), $depth = 1)
{
echo yang_comment_html($comment, $args, $depth, false);
}
/**
* 把 WordPress 的评论错误翻译成人话
*
* ── 为什么需要这层 ────────────────────────────────────────────
* `wp_handle_comment_submission()` 返回的 WP_Error,message 有时是
* 英文(插件/核心的默认文案)、有时是空串、有时是带 HTML 的。
* 直接扔到前端会显示成 "ERROR: please type a comment",或者一片空白
* —— 用户看到空白只会以为"点了没反应",然后继续点。
*
* 这里做三件事:给空 message 兜底、按 code 覆盖成中文、
* 剥掉残留的 HTML 标签。
*/
function yang_comment_error_text($error)
{
if (!is_wp_error($error)) {
return __('提交失败,请稍后重试。', 'kratos');
}
$code = $error->get_error_code();
$msg = trim(wp_strip_all_tags((string) $error->get_error_message()));
// 按错误码给确定的中文文案(比核心的英文默认值友好)
$map = array(
'require_name_email' => __('昵称和邮箱是必填的。', 'kratos'),
'comment_author_email_invalid' => __('邮箱格式不对,请检查一下。', 'kratos'),
'comment_author_url_invalid' => __('网址格式不对,请检查一下。', 'kratos'),
'comment_id_not_found' => __('这篇文章不存在,无法评论。', 'kratos'),
'comment_closed' => __('这篇文章已关闭评论。', 'kratos'),
'comment_on_trash' => __('这篇文章在回收站里,无法评论。', 'kratos'),
'comment_on_draft' => __('草稿状态的文章无法评论。', 'kratos'),
'comment_on_password_protected' => __('这是密码保护的文章,需要先输入密码。', 'kratos'),
'comment_duplicate' => __('你刚才已经发过一模一样的评论了,请勿重复提交。', 'kratos'),
'comment_flood' => __('发得太快了,歇一会儿再发吧。', 'kratos'),
'not_logged_in' => __('需要登录后才能评论。', 'kratos'),
'unapproved_comment' => __('你的评论正在等待审核。', 'kratos'),
);
if (isset($map[$code])) {
return $map[$code];
}
// 没映射到的:有 message 就用(剥掉 HTML),没有就给通用兜底
if ($msg !== '') {
return $msg;
}
return __('提交失败,请稍后重试。', 'kratos');
}
/**
* AJAX 提交评论
*
* ── 返回格式 ──────────────────────────────────────────────────
* 统一 JSON:
* 成功 { ok: true, html: '<li>…</li>', comment_id, depth,
* count, count_text, parent_id, message }
* 失败 { ok: false, message, code, field }
*
* `field` 指出是哪个字段的问题(author/email/comment),
* 前端据此把焦点送回去 —— 比只弹一句"昵称必填"有用。
*
* ── 兼容旧的 action 名 ───────────────────────────────────────
* 旧版前端用的 action 是 `ajax_comment`。新前端也用同一个名字,
* 这样浏览器缓存了旧 JS 的站点在过渡期也不会 404。
*/
function yang_ajax_submit_comment()
{
// 评论必须走 nonce 校验:没有它,第三方页面可以借访客身份刷评论
$nonce = isset($_POST['yang_comment_nonce']) ? $_POST['yang_comment_nonce'] : '';
if (!wp_verify_nonce($nonce, 'yang_comment_submit')) {
wp_send_json(array(
'ok' => false,
'code' => 'bad_nonce',
'message' => __('页面已过期,请刷新后重新提交。', 'kratos'),
), 403);
}
$comment = wp_handle_comment_submission(wp_unslash($_POST));
if (is_wp_error($comment)) {
$data = $comment->get_error_data();
// 403 = 被判定为垃圾/需要登录等;422 = 表单填写问题
$status = 400;
if (isset($data['status'])) {
$status = (int) $data['status'];
}
wp_send_json(array(
'ok' => false,
'code' => $comment->get_error_code(),
'field' => yang_comment_error_field($comment->get_error_code()),
'message' => yang_comment_error_text($comment),
), $status > 0 ? $status : 400);
}
// 写入评论者 cookie,这样他下次来名字邮箱还在
$user = wp_get_current_user();
do_action('set_comment_cookies', $comment, $user);
$post_id = (int) $comment->comment_post_ID;
$parent_id = (int) $comment->comment_parent;
/* 深度:wp_list_comments 的 depth 从 1 开始。
父评论的深度 + 1 就是新评论的深度;顶层评论是 1。 */
$depth = 1;
if ($parent_id > 0) {
$parent = get_comment($parent_id);
if ($parent) {
// 用已有的 wp_list_comments 深度逻辑:数祖先层数 + 1
$ancestors = get_comment_ancestors($parent);
$depth = count($ancestors) + 2;
}
}
$max_depth = (int) get_option('thread_comments_depth', 5);
if ($max_depth <= 0) {
$max_depth = 5;
}
$args = array(
'max_depth' => $max_depth,
'style' => 'ul',
'short_ping' => true,
'avatar_size' => 48,
);
$html = yang_comment_html($comment, $args, $depth, true);
// 评论数:只数已通过的(待审核不该算进"N 条评论")
$count = (int) get_comments_number($post_id);
wp_send_json(array(
'ok' => true,
'html' => $html,
'comment_id' => (int) $comment->comment_ID,
'depth' => $depth,
'max_depth' => $max_depth,
'parent_id' => $parent_id,
'count' => $count,
'count_text' => yang_comment_count_text($count),
'approved' => ($comment->comment_approved !== '0'),
'message' => ($comment->comment_approved === '0')
? __('评论已提交,正在等待审核。', 'kratos')
: __('评论发表成功。', 'kratos'),
));
}
add_action('wp_ajax_nopriv_ajax_comment', 'yang_ajax_submit_comment');
add_action('wp_ajax_ajax_comment', 'yang_ajax_submit_comment');
/**
* 取某个评论的所有祖先 id(从近到远)
*
* WordPress 没有现成函数,但它自己有 wp_list_comments 内部用的逻辑。
* 这里自己走一遍 parent 链,顺便防环(脏数据有可能会造成死循环)。
*/
function get_comment_ancestors($comment, $limit = 20)
{
$out = array();
$seen = array((int) $comment->comment_ID => true);
$cur = (int) $comment->comment_parent;
$i = 0;
while ($cur > 0 && $i < $limit) {
if (isset($seen[$cur])) {
break; // 环,直接停
}
$seen[$cur] = true;
$out[] = $cur;
$c = get_comment($cur);
if (!$c) {
break;
}
$cur = (int) $c->comment_parent;
$i++;
}
return $out;
}
/**
* 错误码 → 出问题的字段(前端用来聚焦)
*/
function yang_comment_error_field($code)
{
if (strpos($code, 'author') !== false || $code === 'require_name_email') {
return 'author';
}
if (strpos($code, 'email') !== false) {
return 'email';
}
if (strpos($code, 'url') !== false) {
return 'url';
}
if ($code === 'comment_duplicate' || $code === 'comment_flood') {
return 'comment';
}
return '';
}
/**
* 评论数文案(与 single.php 里 comments_number() 的风格保持一致)
*
* 单独抽出来是因为 AJAX 返回后前端要就地更新"N 条评论",
* 不能为了改个数字重新渲染整页。
*/
function yang_comment_count_text($n)
{
$n = (int) $n;
if ($n === 0) {
return __('0 条评论', 'kratos');
}
if ($n === 1) {
return __('1 条评论', 'kratos');
}
return sprintf(__('%s 条评论', 'kratos'), number_format_i18n($n));
}
/**
* 「加载更多评论」AJAX
*
* 旧模板里 `previous_comments_link(__('加载更多'))` 是个普通链接,
* 点了整页跳转(还跳到评论分页 URL)。这里改成原地加载。
*
* 分页参数用 WordPress 原生的 cpage,保证"直接访问第 2 页评论"也能工作。
*/
function yang_ajax_load_comments()
{
$post_id = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
$page = isset($_POST['page']) ? max(2, (int) $_POST['page']) : 2;
if ($post_id <= 0 || !get_post($post_id)) {
wp_send_json(array('ok' => false, 'message' => __('参数错误。', 'kratos')), 400);
}
$per_page = (int) get_option('comments_per_page', 20);
if ($per_page <= 0) {
$per_page = 20;
}
$comments = get_comments(array(
'post_id' => $post_id,
'status' => 'approve',
'order' => strtoupper(get_option('comment_order', 'asc')) === 'DESC' ? 'DESC' : 'ASC',
'number' => $per_page,
'offset' => ($page - 1) * $per_page,
// 只取顶层,子评论随父一起输出
'parent' => 0,
));
$max_depth = (int) get_option('thread_comments_depth', 5);
$args = array(
'max_depth' => $max_depth > 0 ? $max_depth : 5,
'style' => 'ul',
'short_ping' => true,
'avatar_size' => 48,
);
$html = '';
foreach ($comments as $c) {
$html .= yang_comment_html($c, $args, 1, false);
}
$total = (int) get_comments_number($post_id);
$loaded = $page * $per_page;
wp_send_json(array(
'ok' => true,
'html' => $html,
'page' => $page,
'has_more' => $loaded < $total,
'next_page' => $page + 1,
'loaded' => $loaded,
'total' => $total,
));
}
add_action('wp_ajax_nopriv_yang_load_comments', 'yang_ajax_load_comments');
add_action('wp_ajax_yang_load_comments', 'yang_ajax_load_comments');
/**
* 前端需要的配置,localize 给 JS
*
* ── 样式为什么也在这里加载 ────────────────────────────────
* 评论只在「单页/页面 + 评论开启」时才出现,没必要全站加载一份
* 10KB 的评论样式。放这里与 JS 同一个条件,一起加载一起不加载。
*/
function yang_comment_scripts()
{
if (is_admin()) {
return;
}
// 只在会渲染评论的页面加载(与 comments_template() 的触发条件一致)
if (!is_singular()) {
return;
}
/* wpDiscuz 在场时**不加载**主题这套脚本与样式。
*
* 原因:插件会接管评论表单(换成它自己的 #wpdcom),并绑定自己的
* 提交逻辑。若主题的 comments.js 也照常加载,它会在
* document 上挂 click 侦听去认 `.yang-comment-reply`,
* 还会找 `#commentform` 去绑 submit —— 找得到就形成"两套一起提交",
* 找不到就静默什么都不做。两种都不对。
*
* 同理 CSS:插件的评论样式自成体系,主题的 comments.css 用的是
* .yang-comment-* 前缀不会撞,但会给插件不存在的元素加规则,
* 白白增加一份体积与排查噪音。
*
* 注意:这只影响"装了插件还没卸载"的过渡期。卸载后自动接管。 */
if (yang_comments_plugin_active()) {
return;
}
wp_enqueue_style(
'yang-comments',
ASSET_PATH . '/assets/css/comments.css',
array('kratos'),
THEME_VERSION
);
wp_enqueue_script(
'yang-comments',
ASSET_PATH . '/assets/js/comments.js',
array(),
THEME_VERSION,
true
);
wp_localize_script('yang-comments', 'yangComment', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('yang_comment_submit'),
'postId' => get_the_ID() ? (int) get_the_ID() : 0,
'order' => get_option('comment_order', 'asc'),
'maxDepth' => (int) get_option('thread_comments_depth', 5),
'i18n' => array(
'submitting' => __('提交中…', 'kratos'),
'submit' => __('提交评论', 'kratos'),
'replyTo' => __('回复', 'kratos'),
'cancelReply' => __('取消回复', 'kratos'),
'networkErr' => __('网络异常,请检查网络后重试。', 'kratos'),
'emptyComment' => __('评论内容不能为空。', 'kratos'),
'tooLong' => __('评论内容太长了。', 'kratos'),
'loading' => __('加载中…', 'kratos'),
'noMore' => __('没有更多评论了', 'kratos'),
),
));
}
add_action('wp_enqueue_scripts', 'yang_comment_scripts', 21);
/**
* 兼容旧函数名
*
* inc/theme-article.php 里原来有一个同名函数并在 wp_enqueue_scripts
* 上注册。那个已经删掉,但子主题/缓存里的旧代码可能还在调它,
* 保留一个空实现避免 Fatal。
*
* 注意:**这里刻意不重复注册钩子**。两个都注册会导致评论样式与脚本
* 各加载两遍(浏览器去重但 wp_localize 会输出两份配置对象)。
*/
if (!function_exists('comment_scripts')) {
function comment_scripts()
{
// 由 yang_comment_scripts() 接管,这里保持空实现仅为兼容
}
}
/* ══════════════════════════════════════════════════════════════
* 评论通知(不依赖 wpDiscuz)
*
* ── 与 theme-mail.php 的分工 ─────────────────────────────────
* theme-mail.php 负责"通道"(SMTP、去重、格式),本文件负责
* "什么时候该发、发给谁"。分开是因为通道层要能独立工作 ——
* 即使评论通知全关掉,测试邮件也得能发出去。
*
* ── 卸载 wpDiscuz 后的行为 ──────────────────────────────────
* 插件在时:通知由插件发(带订阅/退订),主题这边全部让开。
* 插件不在时:主题接管,发两类邮件 ——
* · 有人回复了你的评论 → 给被回复者
* · 文章有新评论 → 给文章作者(可关)
* 这个切换靠 yang_comments_plugin_active() 判断,自动完成,
* 用户不需要改任何设置。
* ══════════════════════════════════════════════════════════════ */
/**
* 有新评论时,通知被回复的那个人
*
* 只在**回复**场景发($comment->comment_parent > 0)。
* 顶层新评论通知文章作者,走的是 WordPress 核心的 notify_postauthor,
* 不需要在这里重复一遍 —— 否则又是"一条评论两封邮件"。
*
* ── 为什么不用 comment_post 而用 wp_insert_comment ──────────
* comment_post 在评论写入后立刻触发,此时 comment_approved 已就位;
* wp_insert_comment 更底层,会连后台手动插入也触发。
* 这里要的是"访客提交"这一种,所以用 comment_post,并显式检查
* 是否是待审核(待审核的评论不该通知被回复者去看一条不存在的回复)。
*/
function yang_notify_comment_reply($comment_id, $comment_approved = 1)
{
if (!yang_mail_smtp_ready()) {
return;
}
// 插件在就让它发,别抢
if (yang_comments_plugin_active()) {
return;
}
// 主题选项里允许整体关掉
if (!kratos_option('m_comment_notify', true)) {
return;
}
$comment = get_comment($comment_id);
if (!$comment) {
return;
}
$parent_id = (int) $comment->comment_parent;
if ($parent_id <= 0) {
return; // 顶层评论:交给核心的 notify_postauthor
}
$parent = get_comment($parent_id);
if (!$parent) {
return;
}
// 被回复者没留邮箱就发不了;自己回复自己也不发
$to = trim((string) $parent->comment_author_email);
if ($to === '' || !is_email($to)) {
return;
}
$new_mail = trim((string) $comment->comment_author_email);
if ($new_mail !== '' && strtolower($new_mail) === strtolower($to)) {
return;
}
// 同一个收件人对同一条评论只发一次(AJAX 重试、插件重复触发都能挡住)
$key = 'yang_creply_' . $comment_id . '_' . md5($to);
if (get_transient($key)) {
return;
}
set_transient($key, 1, 300);
$post = get_post($comment->comment_post_ID);
$title = $post ? get_the_title($post) : __('(文章已删除)', 'kratos');
$link = get_comment_link($comment);
$author = $comment->comment_author !== '' ? $comment->comment_author : __('匿名访客', 'kratos');
$quotes = array(
array(
'label' => sprintf(__('你之前说的(%s)', 'kratos'), $parent->comment_author),
'content' => yang_mail_text($parent->comment_content, 1200),
'meta' => mysql2date('Y-m-d H:i', $parent->comment_date),
),
array(
'label' => sprintf(__('%s 回复说', 'kratos'), $author),
'content' => yang_mail_text($comment->comment_content, 1200),
'meta' => mysql2date('Y-m-d H:i', $comment->comment_date),
),
);
$body = yang_mail_render(array(
'title' => sprintf(__('%s · 新回复', 'kratos'), get_bloginfo('name')),
'preheader' => sprintf(__('%s 在《%s》下回复了你的评论。', 'kratos'), $author, $title),
'badge' => __('新回复', 'kratos'),
'heading' => __('有人回复了你的评论', 'kratos'),
'intro' => sprintf(__('你在《%s》下的评论收到了一条回复。', 'kratos'), $title),
'quotes' => $quotes,
'rows' => array(
array('label' => __('文章', 'kratos'), 'value' => $title),
array('label' => __('评论者', 'kratos'), 'value' => $author, 'strong' => true),
array('label' => __('时间', 'kratos'), 'value' => mysql2date('Y-m-d H:i', $comment->comment_date)),
),
'cta_text' => __('查看完整讨论', 'kratos'),
'cta_url' => $link,
'note' => __('你收到这封邮件,是因为有人回复了你的评论。', 'kratos'),
));
// 注意:这里**不做** yang_mail_escape_for_wpdiscuz() 的预编码。
// 那个函数是为了抵消 wpDiscuz 自己多 decode 一次;现在没有插件了,
// 预编码反而会让邮件正文里出现字面的 &amp;lt; —— 是实打实的 bug。
wp_mail(
$to,
sprintf(__('[%s] 有人回复了你的评论', 'kratos'), get_bloginfo('name')),
$body
);
}
add_action('comment_post', 'yang_notify_comment_reply', 20, 2);
/**
* 评论通过审核时通知评论者
*
* 与上面同理:插件在就让它发,不在就主题发。
* 这个通知很有用 —— 访客留了评论却因为审核没看到,
* 不告诉他,他只会以为没提交成功。
*/
function yang_notify_comment_approved($comment)
{
if (!yang_mail_smtp_ready() || yang_comments_plugin_active()) {
return;
}
if (!kratos_option('m_comment_approved_notify', true)) {
return;
}
if (!($comment instanceof WP_Comment)) {
return;
}
$to = trim((string) $comment->comment_author_email);
if ($to === '' || !is_email($to)) {
return;
}
$key = 'yang_cappr_' . (int) $comment->comment_ID;
if (get_transient($key)) {
return;
}
set_transient($key, 1, 300);
$post = get_post($comment->comment_post_ID);
$title = $post ? get_the_title($post) : __('(文章已删除)', 'kratos');
$body = yang_mail_render(array(
'title' => sprintf(__('%s · 评论已通过审核', 'kratos'), get_bloginfo('name')),
'preheader' => sprintf(__('你在《%s》下的评论已通过审核并公开显示。', 'kratos'), $title),
'badge' => __('已通过', 'kratos'),
'heading' => __('你的评论已通过审核', 'kratos'),
'intro' => sprintf(__('你在《%s》下提交的评论已经审核通过,现在其他读者可以看到它了。', 'kratos'), $title),
'quotes' => array(
array(
'label' => __('你说过的', 'kratos'),
'content' => yang_mail_text($comment->comment_content, 1200),
'meta' => mysql2date('Y-m-d H:i', $comment->comment_date),
),
),
'rows' => array(
array('label' => __('文章', 'kratos'), 'value' => $title),
),
'cta_text' => __('去看看', 'kratos'),
'cta_url' => get_comment_link($comment),
'note' => __('这是评论审核结果通知,无需回复。', 'kratos'),
));
wp_mail(
$to,
sprintf(__('[%s] 你的评论已通过审核', 'kratos'), get_bloginfo('name')),
$body
);
}
add_action('comment_unapproved_to_approved', 'yang_notify_comment_approved', 20, 1);
/**
* 评论表单的字段顺序与占位符调整
*
* WordPress 默认顺序是 comment → author → email → url,
* 而主题的视觉设计是「昵称/邮箱/网址在上,内容在下」。
* 原主题用 recover_comment_fields() 把 comment 挪到最后,
* 这里保留同样的顺序,但改成可预测的实现。
*/
function yang_comment_form_fields($fields)
{
if (!isset($fields['comment'])) {
return $fields;
}
$comment = $fields['comment'];
unset($fields['comment']);
$fields['comment'] = $comment;
return $fields;
}
add_filter('comment_form_fields', 'yang_comment_form_fields', 20);
+62 -3
View File
@@ -221,7 +221,7 @@ function yang_mail_short_circuit($return, $atts)
add_filter('pre_wp_mail', 'yang_mail_short_circuit', 10, 2);
/**
* 关掉核心的重复通知
* 关掉核心的重复通知(**仅在 wpDiscuz 存在时**)
*
* ── 为什么要关 ──
* 站点用了 wpDiscuz,评论通知由插件负责(它能带订阅、退订链接、
@@ -237,12 +237,27 @@ add_filter('pre_wp_mail', 'yang_mail_short_circuit', 10, 2);
* ── 只关通知,不关审核 ──
* 注意这里只拦 `notify_*` 两个 filter,评论审核流程(comment_moderation)
* 完全不受影响,评论该进待审还是进待审。
*
* ── 🔴 3.2.18:插件不在时必须放行 ─────────────────────────────
* 这一条是卸载 wpDiscuz 时最容易出的事故:
* 原来无条件返回 false,等于永久关掉核心的「有新评论通知作者」。
* 插件在时无所谓(插件自己会发);插件一卸,就没有任何东西通知作者了。
* 而主题自带的 yang_notify_comment_reply() 只处理**回复**场景
* ($comment_parent > 0),顶层新评论仍然依赖核心的 notify_postauthor。
* 两者叠加的结果是:作者从此收不到任何新评论邮件,且没有任何报错,
* 只有"怎么一直没邮件"这种模糊现象。
*
* 所以这里的判据必须跟着插件走,而不是"只要开了 SMTP 就关"。
*/
function yang_mail_disable_core_notify($maybe, $comment_id)
{
if (!yang_mail_smtp_ready()) {
return $maybe;
}
// 没有插件接管 → 核心通知是唯一的顶层评论通知渠道,必须放行
if (!yang_mail_wpdiscuz_present()) {
return $maybe;
}
// 用户在主题选项里可以保留核心通知(默认关)
if (kratos_option('m_keep_core_notify', false)) {
return $maybe;
@@ -329,12 +344,35 @@ function yang_mail_send_test($to = null)
* 解法:把 `&` 全部预编码成 `&amp;`。这样插件解码一次之后,
* 恰好还原成我们想要的转义状态(decode("&amp;lt;") === "&lt;")。
* 只处理 `&` 就够,因为其它实体的风险都源于 `&` 被还原。
*
* ── 3.2.18:这个函数只在插件存在时才能用 ─────────────────────
* 主题现在自带评论通知(inc/theme-comments.php),不再依赖插件。
* 如果这里无条件做 `&` 预编码,而没有插件去 decode 那一次,
* 收件人看到的邮件正文里就会出现字面的 `&amp;lt;`、`&amp;nbsp;`
* —— 比安全风险更常见的实际故障,而且极难联想到原因。
*
* 所以调用点必须先判断插件在不在。见下方两个 add_filter 的包裹。
*/
function yang_mail_escape_for_wpdiscuz($html)
{
return str_replace('&', '&amp;', $html);
}
/**
* 当前是否有 wpDiscuz 在接管评论邮件
*
* 单独抽出来,是因为 theme-mail.php 加载早于 theme-comments.php
* 时(子主题调整了 require 顺序)`yang_comments_plugin_active()`
* 可能还不存在 —— 这时退回自己探测,不能让后台白屏。
*/
function yang_mail_wpdiscuz_present()
{
if (function_exists('yang_comments_plugin_active')) {
return yang_comments_plugin_active();
}
return class_exists('WpdiscuzCore') || function_exists('wpdiscuz');
}
/**
* 把 wpDiscuz 的通知邮件接管成主题模板
*
@@ -351,6 +389,17 @@ function yang_mail_escape_for_wpdiscuz($html)
* 上面那两段引用,正文骨架完全一样。做成两套模板会立刻分叉,
* 以后改一次配色要改两处。
*
* ── 3.2.18:这整段只在装了 wpDiscuz 时才注册 ─────────────────
* 主题已经自带评论通知(inc/theme-comments.php)。若插件不存在,
* 这两个 `wpdiscuz_*` 过滤器永远不会被触发 —— 注册了也是死代码,
* 但它们会让人误以为"邮件通知依赖插件",日后维护时是个陷阱。
* 更实际的问题:这些函数内部调用 yang_mail_escape_for_wpdiscuz()
* 做 `&` 预编码,那是专门给插件多一次 decode 用的;没有插件时
* 一旦被别处误调,邮件里就会出现字面的 &amp;lt;。
*
* 所以整个注册包在 if 里。插件卸载后,通知自动由主题接管,
* 用户不需要改任何配置。
*
* @param string $message 插件生成的 HTML(丢弃)
* @param WP_Comment $comment 新评论
* @param array $email_data 订阅记录
@@ -418,7 +467,14 @@ function yang_mail_render_comment_notify($message, $comment, $email_data = array
return yang_mail_escape_for_wpdiscuz($body);
}
add_filter('wpdiscuz_email_content', 'yang_mail_render_comment_notify', 20, 3);
/* 只在插件确实存在时挂这两个过滤器。
插件不在时(用户按 3.2.18 的建议卸载了),评论通知由
inc/theme-comments.php 的 yang_notify_comment_reply() /
yang_notify_comment_approved() 接管,发的是**不预编码**的正文。 */
if (yang_mail_wpdiscuz_present()) {
add_filter('wpdiscuz_email_content', 'yang_mail_render_comment_notify', 20, 3);
}
/**
* 审核通过通知也套同一套模板
@@ -449,4 +505,7 @@ function yang_mail_render_approved_notify($message, $post_id, $email)
'note' => __('这是评论审核结果通知,无需回复。', 'kratos'),
)));
}
add_filter('wpdiscuz_comment_approved_email_content', 'yang_mail_render_approved_notify', 20, 3);
if (yang_mail_wpdiscuz_present()) {
add_filter('wpdiscuz_comment_approved_email_content', 'yang_mail_render_approved_notify', 20, 3);
}
+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'];
}
}
}
// 命中的是「附加日期」里用户自己填的那一天
+39 -7
View File
@@ -94,6 +94,30 @@ if (!function_exists('yang_opt_mourning_extra_raw')) {
}
}
/**
* 今年清明的月日,给后台说明文字用(如「4 月 5 日」)
*
* 换算不出来(超出年份表范围)时返回一句明确的说明,
* **不要**退化成「4 月 4 日」—— 那正是用户要求删掉的近似值。
*/
if (!function_exists('yang_opt_qingming_label')) {
function yang_opt_qingming_label()
{
if (!function_exists('yang_qingming_md')) {
return __('换算不可用', 'kratos');
}
$md = yang_qingming_md();
if ($md === null) {
return sprintf(
__('今年(%s 年)超出可换算范围,清明不会自动生效', 'kratos'),
current_time('Y')
);
}
list($mo, $da) = explode('-', $md);
return sprintf(__('%d 月 %d 日', 'kratos'), (int) $mo, (int) $da);
}
}
/**
* 自定义 CSS 的默认值(来自 inc/theme-custom-code.php)
*
@@ -467,26 +491,34 @@ function kratos_options()
// 把内置清单转成 multicheck 需要的格式
$rip_legal = array();
$rip_custom = array();
foreach (yang_opt_mourning_presets() as $md => $info) {
foreach (yang_opt_mourning_presets() as $key => $info) {
/* 键有两种:静态日期(`09-03`)和动态日期(`qingming`)。
动态日期的键是内部标识,不能直接摆在用户面前 ——
所以看 key_label,没有就回退到键本身(静态日期就是 MM-DD,
显示出来正好是用户认得的日期)。 */
$label = isset($info['key_label']) ? $info['key_label'] : $key;
$entry = array(
'name' => $md . ' ' . $info['name'],
'name' => $label . ' ' . $info['name'],
'note' => $info['note'],
);
if (isset($info['group']) && $info['group'] === 'legal') {
$rip_legal[$md] = $entry;
$rip_legal[$key] = $entry;
} else {
$rip_custom[$md] = $entry;
$rip_custom[$key] = $entry;
}
}
$rip_default = array();
foreach (yang_opt_mourning_presets() as $md => $info) {
$rip_default[$md] = !empty($info['default']) ? 1 : 0;
foreach (yang_opt_mourning_presets() as $key => $info) {
$rip_default[$key] = !empty($info['default']) ? 1 : 0;
}
$options[] = array(
'name' => __('纪念日清单', 'kratos'),
'desc' => __('勾选需要自动黑白的日期。<b>默认一个都不勾</b>,请按自己的意愿挑选。前三个是法定纪念日,后面几个是民间通行做法。日期按站点时区判断,只比对月和日,每年循环。', 'kratos'),
'desc' => sprintf(
__('勾选需要自动黑白的日期。<b>默认一个都不勾</b>,请按自己的意愿挑选。前三个是法定纪念日,后面几个是民间通行做法。日期按站点时区判断,只比对月和日,每年循环。<br><b>清明节</b>是节气,日期每年浮动(4 月 4~6 日之间),已脱离固定日期、按天文历逐年换算 —— 今年是 <b>%s</b>。', 'kratos'),
esc_html(yang_opt_qingming_label())
),
'id' => 'g_rip_days',
'std' => $rip_default,
'type' => 'multicheck',
+472
View File
@@ -0,0 +1,472 @@
<?php
/**
* 文章目录(TOC)
*
* ── 用法 ──────────────────────────────────────────────────────
* 在文章内容的任意位置写一行:
*
* [TOC]
*
* 这个位置就会出现本文的目录,点条目跳到对应标题。
*
* 可调参数(都可省略):
*
* [TOC title="本文目录" levels="1,2,3" numbering="1" toggle="0"]
*
* · title 目录标题,默认「目录」
* · levels 收录哪几级标题,逗号分隔,默认 "1,2,3"
* · numbering 1 = 条目前加 1 / 1.1 这样的序号,默认 0
* · toggle 1 = 标题可点击折叠,默认 1
*
* ── 为什么默认收 h1(曾经错设成 "2,3")──────────────────────
* 原先的理由是「h1 是文章标题本身,不该进目录」。这个理由是**假的**:
* 文章标题输出在 single.php 的 <h1 class="title">,在 the_content
* **之外**,根本进不到这里被扫描。能被扫到的 h1 只可能是作者在正文里
* 自己写的一级章节标题。
*
* 一旦正文用 h1 划分章节(很常见:从 Markdown 或别的编辑器粘过来,
* 或原本的 ## 被降级),默认 "2,3" 会把这些一级章节整段丢掉,
* 于是二级标题集体失去父级、目录变成平铺 —— 层级信息全错。
* 所以默认跟 h2/h3 一起收 h1,最多到 h3(h4 以下通常是细节,不进目录)。
*
* ── 为什么在 the_content 优先级 9 处理,而不是只靠短代码 ──────
* 目录能点,前提是「标题有 id」且「目录链接指向那个 id」。这两件事
* 必须用**同一份扫描结果**,否则一旦某个标题文本里有特殊字符、
* 或者有重名标题,两边各自算一遍就会算出不同的 id,点进去跳不动。
*
* 而短代码(do_shortcode)挂在 the_content 的优先级 11,
* wpautop 挂在 10 —— 等短代码执行时,内容已经被换行转成了 <p>,
* 再去插 id 就和扫描时的文本不是同一份了。
*
* 所以在优先级 9(早于 wpautop 和 do_shortcode)一次做完:
* ① 给每个 h1~h6 补 id(已有 id 的沿用,不动别人的锚点)
* ② 把 [TOC] 直接替换成目录 HTML
*
* 放在 <div> 里是刻意的:wpautop 会把「块级元素」两边的 <p> 剥掉,
* 所以不会出现 <p><div>…</div></p> 这种非法嵌套。
*
* 同时仍然注册了短代码(见文件末尾):万一有人在 the_content 之外
* 调用 [TOC](小工具、页面构建器),短代码那条路也能用,且复用
* 同一个渲染函数,不会出现两套行为。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 把标题文本变成锚点 id
*
* ── 为什么保留中文 ────────────────────────────────────────────
* 转拼音需要词典,主题里塞不进去;转成纯数字(toc-1、toc-2)则让
* 链接失去可读性,分享出去 `#section-7` 谁也看不出是哪一节。
* 中文本身在 HTML5 的 id 里是合法的,浏览器也认 —— 所以保留,
* 只把「会破坏 id 语义或选择器」的字符换掉。
*
* ── 去重 ──────────────────────────────────────────────────────
* 同一篇文章里出现两个「小结」是常事。第二个必须变成「小结-2」,
* 否则两个标题同 id,点目录永远只跳到第一个。
*
* @param string $text 标题的 HTML(可能含 <strong> 之类)
* @param array $used 已用过的 id(按引用传,跨标题累积)
* @return string
*/
function yang_toc_make_id($text, &$used)
{
$t = wp_strip_all_tags($text);
$t = html_entity_decode($t, ENT_QUOTES, 'UTF-8');
// 连续空白压成一个,避免 "a b" 变成 "a---b"
$t = trim(preg_replace('/\s+/u', ' ', $t));
/* 只保留:汉字、拉丁字母、数字、下划线、连字符。
其余(空格、各类标点、emoji)一律替换成连字符。
注意必须带 /u,否则按字节处理会把中文拆坏。 */
$t = preg_replace('/[^\p{Han}\p{Latin}\p{N}_-]+/u', '-', $t);
$t = trim((string) $t, '-');
// 标题是纯符号/纯 emoji 时兜个底,不能产出空 id
if ($t === '') {
$t = 'section';
}
$base = $t;
$i = 1;
while (isset($used[$t])) {
$i++;
$t = $base . '-' . $i;
}
$used[$t] = true;
return $t;
}
/**
* 扫描内容:给标题补 id,并收集目录条目
*
* @param string $content 原始内容(未过 wpautop)
* @return array array('content' => 改好的内容, 'items' => 条目数组)
* 条目:array('level' => 2, 'text' => '标题', 'id' => '锚点')
*/
function yang_toc_scan($content)
{
$items = array();
$used = array();
if (!is_string($content) || $content === '') {
return array('content' => (string) $content, 'items' => $items);
}
/* 标题可能带属性(class/style/已有 id),也可能跨行。<h([1-6]) 分组,
反向引用 \1 保证开闭标签配对(不会把 <h2>…</h3> 当成一对)。 */
$out = preg_replace_callback(
'#<h([1-6])([^>]*)>(.*?)</h\1>#is',
function ($m) use (&$items, &$used) {
$level = (int) $m[1];
$attrs = $m[2];
$inner = $m[3];
// 已经有 id 的标题:沿用。绝不覆盖 —— 那会打断别人已有的
// 锚点链接(包括从旧插件迁移过来的、以及手写的 <a href="#x">)。
if (preg_match('/\bid\s*=\s*(["\'])(.*?)\1/is', $attrs, $im)) {
$id = $im[2];
// 记进 used,避免后面的同名标题又生成同一个 id
$used[$id] = true;
} else {
$id = yang_toc_make_id($inner, $used);
$attrs .= ' id="' . esc_attr($id) . '"';
}
$items[] = array(
'level' => $level,
'text' => $inner,
'id' => $id,
);
return '<h' . $level . $attrs . '>' . $inner . '</h' . $level . '>';
},
$content
);
// preg_replace_callback 出错会返回 null,退回原文更安全
if (!is_string($out)) {
$out = $content;
$items = array();
}
return array('content' => $out, 'items' => $items);
}
/**
* 解析 [TOC] 的属性
*
* 只认 key="v" / key='v' / key=v 三种写法。不认识的键静默忽略,
* 打错的属性不该让整篇文章报错或渲染不出目录。
*
* @param string $raw 方括号里标签名之后的原文
* @return array
*/
function yang_toc_parse_atts($raw)
{
$atts = array();
if (!is_string($raw) || trim($raw) === '') {
return $atts;
}
if (preg_match_all('/([a-zA-Z_][a-zA-Z0-9_-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\']+))/', $raw, $mm, PREG_SET_ORDER)) {
foreach ($mm as $one) {
$k = strtolower($one[1]);
$v = '';
if (isset($one[2]) && $one[2] !== '') {
$v = $one[2];
} elseif (isset($one[3]) && $one[3] !== '') {
$v = $one[3];
} elseif (isset($one[4])) {
$v = $one[4];
}
$atts[$k] = $v;
}
}
return $atts;
}
/**
* 把条目数组渲染成目录 HTML
*
* @param array $items 条目(含 level/text/id)
* @param array $atts 解析后的属性
* @return string 空串表示不该输出(没有可用标题)
*/
function yang_toc_render($items, $atts = array())
{
if (empty($items)) {
return '';
}
// ── 参数归一 ──
$title = isset($atts['title']) && trim($atts['title']) !== ''
? trim($atts['title'])
: __('目录', 'kratos');
// levels:默认 1,2,3。文章标题本身在 the_content 之外,不会被扫到,
// 所以正文里的 h1 是作者写的一级章节标题,必须收 —— 详见文件头注释。
$levels = array();
$raw_levels = isset($atts['levels']) ? (string) $atts['levels'] : '1,2,3';
foreach (preg_split('/[\s,,、]+/u', $raw_levels) as $one) {
if ($one === '') {
continue;
}
$n = (int) $one;
if ($n >= 1 && $n <= 6) {
$levels[$n] = true;
}
}
if (empty($levels)) {
$levels = array(1 => true, 2 => true, 3 => true);
}
$numbering = isset($atts['numbering']) && (string) $atts['numbering'] === '1';
$toggle = !isset($atts['toggle']) || (string) $atts['toggle'] === '1';
// ── 过滤出要收录的标题 ──
$picked = array();
foreach ($items as $it) {
if (isset($levels[$it['level']])) {
$picked[] = $it;
}
}
if (empty($picked)) {
// 文章里一个符合条件的标题都没有 —— 用户明确要求「没有就不显示」
return '';
}
// ── 序号(1 / 1.1 / 1.1.1)──
$nums = array();
if ($numbering) {
$counter = array();
foreach ($picked as $i => $it) {
$lv = $it['level'];
$counter[$lv] = isset($counter[$lv]) ? $counter[$lv] + 1 : 1;
// 更深的层级清零,回到上层后重新从 1 开始
foreach (array_keys($counter) as $k) {
if ($k > $lv) {
unset($counter[$k]);
}
}
$parts = array();
$base_found = false;
foreach ($counter as $k => $v) {
$parts[] = $v;
}
$nums[$i] = implode('.', $parts);
}
}
// ── 生成嵌套结构 ──
/* 这里不用「绝对层级」直接建嵌套,而是先把每个条目归一成**深度**。
原因:标题层级会有跳级和回退(h2 → h4 → h3),按绝对 level 建栈,
回退时到底该关几次 </li> 极难算对 —— 第一版就是这么错的,
「回退 h4→h3」那一例 ul/li 标签不配平,被单元测试抓出来。
归一化规则(与直觉一致):
· h2 → h4 → h3 时,h4 和 h3 都算 h2 的下一层(深度相同),
因为它们之间不存在 level 3 的父项,都是 h2 的子项;
· h2 → h3 → h4 → h2 时,深度依次 0 / 1 / 2 / 0。
得到深度数组之后,建 HTML 就只是「深度变大开一层、变小收几层」,
关闭次数由深度差直接决定,不需要再猜。 */
$depths = array();
$lstack = array(); // 用过的 level,从外到内
foreach ($picked as $it) {
$lv = $it['level'];
if (empty($lstack)) {
$lstack[] = $lv;
} elseif ($lv > end($lstack)) {
// 更深一级:直接入栈
$lstack[] = $lv;
} else {
// 同级或回退:先弹到「不比它深」为止
while (count($lstack) > 1 && $lv <= end($lstack)) {
array_pop($lstack);
}
// 弹完之后若仍然更浅,说明是跳级回退(如 h4 → h3),
// 把这个 level 当作新的一层压进去,深度不变
if ($lv > end($lstack)) {
$lstack[] = $lv;
}
}
$depths[] = count($lstack) - 1;
}
$o = array();
$prev_depth = -1;
$max_depth = 0;
foreach ($picked as $i => $it) {
$lv = $it['level'];
$d = $depths[$i];
if ($prev_depth < 0) {
// 第一个条目
$o[] = '<ul class="yang-toc-list">';
} elseif ($d > $prev_depth) {
// 进到更深一层:上一个 <li> 还没关,正好作为父节点
$o[] = '<ul class="yang-toc-list">';
} else {
// 同级或回退:先关上一条
$o[] = '</li>';
// 深度差 = 要收掉的层数,每收一层补一个 </li>(关父条目)
for ($k = $prev_depth; $k > $d; $k--) {
$o[] = '</ul></li>';
}
}
$num = '';
if ($numbering && isset($nums[$i])) {
$num = '<span class="yang-toc-num">' . esc_html($nums[$i]) . '</span>';
}
/* href 用原始 id;id 里允许中文,浏览器会自己编码。
不要在这里 rawurlencode —— 那会和 id 对不上(id 是解码后的)。 */
$o[] = '<li class="yang-toc-item yang-toc-lv' . $lv . '">'
. '<a class="yang-toc-link" href="#' . esc_attr($it['id']) . '">'
. $num
. '<span class="yang-toc-text">' . wp_strip_all_tags($it['text']) . '</span>'
. '</a>';
$prev_depth = $d;
if ($d > $max_depth) {
$max_depth = $d;
}
}
// 收尾:关最后一条 + 逐层收掉所有 ul
if (!empty($picked)) {
$o[] = '</li>';
for ($k = $prev_depth; $k > 0; $k--) {
$o[] = '</ul></li>';
}
$o[] = '</ul>';
}
$list = implode('', $o);
/* 折叠用 <details>:原生元素,不需要 JS 就能工作。
JS 被禁用 / 加载失败时,目录仍然可以展开收起 ——
比用自定义 div + class 切换更抗故障。 */
if ($toggle) {
$html = '<details class="yang-toc" open>'
. '<summary class="yang-toc-head">'
. '<span class="yang-toc-title">' . esc_html($title) . '</span>'
. '<span class="yang-toc-count">' . count($picked) . '</span>'
. '</summary>'
. '<nav class="yang-toc-body" aria-label="' . esc_attr($title) . '">'
. $list
. '</nav>'
. '</details>';
} else {
$html = '<div class="yang-toc">'
. '<div class="yang-toc-head">'
. '<span class="yang-toc-title">' . esc_html($title) . '</span>'
. '<span class="yang-toc-count">' . count($picked) . '</span>'
. '</div>'
. '<nav class="yang-toc-body" aria-label="' . esc_attr($title) . '">'
. $list
. '</nav>'
. '</div>';
}
return $html;
}
/**
* the_content 主处理
*
* 顺序很重要:先给标题补 id(扫描出条目),再替换 [TOC]。
* 反过来的话,[TOC] 生成时拿不到标题清单。
*/
function yang_toc_filter_content($content)
{
if (is_admin() || !is_string($content) || $content === '') {
return $content;
}
if (stripos($content, '[toc') === false) {
// 没有 [TOC]:什么都不做。
// 不动标题 id 是刻意的 —— 不写 [TOC] 的文章不该被塞进一堆 id,
// 那会改变页面上已有的选择器和锚点行为。
return $content;
}
$scan = yang_toc_scan($content);
$items = $scan['items'];
/* 替换 [TOC] / [toc] / [TOC ...]。大小写都认 ——
用户不会记得"这里的标签必须大写",凭直觉写 [toc] 就该能用。 */
$out = preg_replace_callback(
'/\[toc(\s[^\]]*)?\]/i',
function ($m) use ($items) {
$atts = yang_toc_parse_atts(isset($m[1]) ? $m[1] : '');
return yang_toc_render($items, $atts);
},
$scan['content']
);
return is_string($out) ? $out : $content;
}
add_filter('the_content', 'yang_toc_filter_content', 9);
/**
* 短代码兜底
*
* 正常路径下 [TOC] 在优先级 9 就被替换掉了,走不到这里。
* 但如果在 the_content 之外被调用(小工具、页面构建器、do_shortcode()
* 显式调用),这条路径保证行为一致 —— 复用同一个渲染函数。
*/
function yang_toc_shortcode($atts = array(), $content = '', $tag = '')
{
$post = get_post();
if (!$post || !isset($post->post_content)) {
return '';
}
$scan = yang_toc_scan($post->post_content);
return yang_toc_render($scan['items'], is_array($atts) ? $atts : array());
}
add_shortcode('toc', 'yang_toc_shortcode');
add_shortcode('TOC', 'yang_toc_shortcode');
/**
* 需要时才加载样式
*
* 每篇文章都加一份 TOC 样式是浪费;只有正文里真的写了 [TOC] 才加载。
* 依赖 'kratos' 保证排在主题主样式之后,不会被覆盖。
*/
function yang_toc_assets()
{
if (is_admin() || !is_singular()) {
return;
}
$post = get_post();
if (!$post || !isset($post->post_content)) {
return;
}
if (!has_shortcode($post->post_content, 'toc') && !has_shortcode($post->post_content, 'TOC')) {
return;
}
wp_enqueue_style(
'yang-toc',
ASSET_PATH . '/assets/css/toc.css',
array('kratos'),
THEME_VERSION
);
wp_enqueue_script(
'yang-toc',
ASSET_PATH . '/assets/js/toc.js',
array(),
THEME_VERSION,
true
);
}
add_action('wp_enqueue_scripts', 'yang_toc_assets', 20);