Files
wordpress-kratos-yang/inc/theme-comments.php
T
laoyang da0d9ae4a1 代码高亮整套自研:10 浅 + 10 深配色、语言标签、评论区贴代码 (#11)
不再依赖第三方高亮插件,全部由主题自己实现。

主题配色(20 套)
- 浅色 10:github / vs / atom-one-light / idea / xcode /
  stackoverflow-light / intellij-light / googlecode / magula / color-brewer
- 深色 10:github-dark / atom-one-dark / monokai / github-dark-dimmed /
  a11y-dark / stackoverflow-dark / night-owl / srcery /
  panda-syntax-dark / tomorrow-night-blue
- 深色卡片带 data-kind="dark",顶栏跟着换深色底
- 只加载当前选中的一套(20 套全加载会让后出现的规则压前面的,
  切换配色容易"看起来没变")

代码语言标签(用户要求:防止复制错语言)
- 作者标了 language-xxx → 实线边框、正体
- 自动识别猜出来的 → 虚线边框、斜体
- 认不出来 → 显示 "—",仍保持虚线斜体(不冒充作者标注)

行号
- 默认关;行号槽在 <pre> 外面,复制天然不带行号
- 改掉原主题的 word-break:break-all(任意字符断行,代码像乱码)
- 归零 hljs 主题自带的 pre code.hljs{padding:1em},
  否则代码被压低 1em、与行号槽错位 15.5px

复制
- 默认开;安全上下文判断 + execCommand 退路(HTTP 下 clipboard 是 undefined)
- 取 textContent 而非 innerHTML
- 默认 opacity:0、hover 显示、触屏覆盖回 1
  (旧写法只在 @media(hover:none) 里写 1,等于空操作)

自动识别
- 做成选项,默认开
- 只对没标语言的块生效;站内实测长块准确率 22%,不能覆盖作者标注

评论区贴代码
- 评论工具条加"代码"按钮 + 29 种语言面板
- kses 放行 pre 并给 pre/code 放开 class
  (WP 默认 pre 整条剥掉、code 属性全清,此前根本贴不了代码)
- 放行 pre 之后 wpautop/wptexturize/make_clickable 自动让路,
  代码里的连续空行、引号、URL 都不会被改写
- comments.js 用占位符保护代码块,避免 walk() 的 /\n{3,}/ 压缩

其它
- 资源版本号改为读主题头,不再写死(写死会导致以后升级后
  浏览器一直用旧缓存,改了 CSS/JS 完全不生效)
- 删除 footer.php 里旧的 Prism 兜底(两套高亮器同页会互相打架)

验收
- 用线上 284 个真实代码块跑产品代码:284/284 全部变卡片,内容丢字 0、双转义 0
- 47 个 text 标记全部映射到 plaintext(hljs 无 text 语言,不映射会静默不上色)
- 80 个裸 <pre>(无 code 子元素)全部覆盖
- 核心纯函数 65/65;核心反向 10/10 变红
- 浏览器端到端 32/32;行号几何逐行对齐偏差 0.00px,几何反向 4/4 变红
- 端到端反向 7/7 变红;语言标签反向 2/2 变红
- 全量 PHP 语法 95 文件 0 错误

发布:走 theme-update 通道(zip + readme + update.json),
未覆盖服务器主题目录;线上主题目录仍是 3.2.41,待后台升级。
2026-10-26 09:00:17 +08:00

1021 lines
42 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
/**
* 评论区:统一渲染 + 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;
}
/**
* 3.2.24 富文本工具条:给访客评论放行 <span style="color:…">
*
* 工具条插入的三种标签里,<strong>/<em> 本来就在 WP 评论的
* kses 白名单($allowedtags)里,不用管;<span style="color:…">
* 不在 —— 访客提交时会被 wp_filter_kses 整个剥掉,颜色功能就白做了。
*
* 这里把 span(带 style / class 属性)追加进 kses 白名单。安全性:
* 1. kses 对 style 属性的内容会再过一遍 safecss_filter_attr(),
* 那是一份固定白名单(color、font-weight、text-align…),
* expression()/url()/behavior() 这些危险属性一律剥掉;
* 2. 工具条前端只生成 color 一个声明,白名单只是兜底;
* 3. 有 unfiltered_html 权限的用户(管理员)本来就不走 kses,不受影响。
*
* ⚠️ 不按 context 区分:wp_filter_kses 实际传进来的 context 串
* 在 WP 各版本间不稳定,写死 'pre_comment_user' 之类的会静默失效
* (失效 = 标签被剥 = 功能没了但没有任何报错)。span+style 放行到
* 任何 kses 场景都是安全的(style 内容有 safecss_filter_attr 挡着),
* 所以对除 'strip' 外的所有 context 一律追加。
*/
function yang_kses_allow_span($tags, $context)
{
if ($context === 'strip') {
return $tags;
}
if (!is_array($tags)) {
$tags = array();
}
$tags['span'] = array(
'style' => true,
'class' => true,
);
/* ── 3.2.42 评论贴代码 ────────────────────────────────────
* 用真实 WP 7.1.3 实测的结论:不加这一段,访客在评论里**根本贴不了代码**。
* · `pre` 不在 $allowedtags 里 → 整条内容连带代码一起被剥掉
* · `code` 虽然在,但没有任何属性 → class="language-xxx" 被剥,
* 语言标记保不住,代码块只能当纯文本
* 所以必须同时做两件事:放行 pre;给 pre/code 放开 class。
*
* 🔴 放行 pre 之后有三件事会**自动变好**,不用另写代码,
* 但必须知道原因,否则以后有人"顺手优化"掉 pre 就会全崩:
* ① wpautop 不再套 <p> —— 缩进得救。
* 没有 pre 时 <p> 的 white-space:normal 会把 4 个空格折叠成 1 个;
* 有 pre 时 wpautop 会跳过那一整段。实测确认。
* ② wptexturize 被挡住 —— 引号不再变弯。
* 否则 ' → &#8217;、-- → &#8212;、" → &#8220;&#8221;,代码直接跑不了。
* ③ make_clickable 被挡住 —— 代码里的 URL 不会变成 <a href rel="nofollow">,
* 否则复制出来全是 HTML。
* ②③ 是最容易忽略的两条:它们**只在没有 pre 包裹时才破坏**。
* 以上全部已在真实 WP 核心上逐条验证(11 条判据全通过)。
*
* 安全性:pre/code 都不接受任何 URL 类属性,class 只用于语言标记,
* 本体不产生任何可执行内容。kses 仍会继续过滤 pre/code 内部的一切标签。
*/
$tags['pre'] = array(
'class' => true,
);
$tags['code'] = array(
'class' => true,
);
return $tags;
}
add_filter('wp_kses_allowed_html', 'yang_kses_allow_span', 10, 2);
/**
* 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 返回的新评论
* @param bool $close_li 是否在末尾闭合 </li>
* —— 走 wp_list_comments 时必须传 false,
* 否则子评论会被挤到 </li> 外面(详见下方注释)
* @return string
*/
/**
* 嵌套引导线的按层配色(3.2.27)。
*
* $depth 是「线里装着的评论」的深度:
* 2 = 直接回复(第 1 层嵌套)→ 返回 '',CSS 回落到主题浅蓝(保持现状);
* 3 = 第 2 层嵌套 → 深蓝;
* 4 = 第 3 层嵌套 → 点赞按钮背景色(.btn-thumbs 的 #fb5235);
* 5+ → 扩展色板按层循环:确定性的"随机"——同层恒同色、相邻层必异色,
* 页面刷新不变(真随机会让同一层的线各页各色,层级信号反而乱)。
*
* 颜色都取饱和偏深的中明度色:线上是白底,太浅的(如淡黄)贴白底看不见。
*
* @param int $depth 评论深度(最小 2,即第 1 层嵌套)
* @return string 颜色值;'' 表示用 CSS 默认(主题浅蓝)
*/
function yang_comment_guide_color($depth)
{
if ($depth <= 2) {
return '';
}
if ($depth === 3) {
return '#0057b8'; // 深蓝:与浅蓝同色相,明度拉开
}
if ($depth === 4) {
return '#fb5235'; // 点赞按钮(.btn-thumbs)的背景/描边色
}
$pool = array('#7c4dff', '#00a77a', '#e91e63', '#f59e0b', '#5b8def', '#8bc34a');
return $pool[($depth - 5) % count($pool)];
}
function yang_comment_html($comment, $args = array(), $depth = 1, $is_ajax = false, $close_li = true)
{
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();
/* 3.2.42 评论里的代码块也走和正文同一套渲染。
🔴 走同一个函数 yang_hl_render_block(),不另写一份。
理由:正文和评论的代码块如果各写一套,两边样式一定分叉,
而这类分叉在页面上不容易一眼看出来(评论区的块通常更短)。
渲染器负责的是"裸 pre → 卡片"的规范化,评论和正文的输入形态是一样的。
🔴 为什么在 ob 取到之后再处理,而不是挂 comment_text 过滤器:
挂过滤器会让 AJAX 返回和列表渲染各自经过一次,容易漏一侧;
而 yang_comment_html() 是**列表与 AJAX 共用**的唯一出口
(本文件开头就是为这个才重写的),在这里处理不会分叉。 */
if (function_exists('yang_hl_render_block')
&& function_exists('yang_hl_enabled') && yang_hl_enabled()
&& strpos($content, '<pre') !== false) {
$rendered = preg_replace_callback(
'#<pre\b([^>]*)>(.*?)</pre>#is',
function ($m) {
$cls = '';
if (preg_match('/class\s*=\s*"([^"]*)"/i', $m[1], $cm)
|| preg_match("/class\s*=\s*'([^']*)'/i", $m[1], $cm)) {
$cls = $cm[1];
}
return yang_hl_render_block($m[2], $cls);
},
$content
);
// preg_replace_callback 失败返回 null,绝不能把 null 当内容输出
if ($rendered !== null) {
$content = $rendered;
}
}
$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();
/* 嵌套引导线按层配色(3.2.27):
线画在子容器 ol.yang-children 的 ::before 上,颜色从 --yq-guide-c 取。
给本 li 注入的值 = 「本条的直接回复那一层」该用的颜色:
第 1 层嵌套(直接回复)→ 不注入,CSS 回落到主题浅蓝(保持现状);
第 2 层 → 深蓝;第 3 层 → 点赞按钮背景色 #fb5235;
更深 → 扩展色板按层循环(确定性的"随机",同层同色、跨层异色)。
自定义属性沿 DOM 继承:内层 li 自己注入的值会覆盖继承值,
所以每个 ol 读到的都是离它最近的那个父 li 注入的颜色。 */
$guide_c = yang_comment_guide_color($depth + 1);
$style_attr = '';
if ($guide_c !== '') {
$style_attr = ' style="--yq-guide-c:' . esc_attr($guide_c) . ';"';
}
$o[] = '<li id="comment-' . $comment_id . '" class="' . esc_attr(implode(' ', $classes)) . '"'
. ' data-comment-id="' . $comment_id . '"'
. ' data-depth="' . $depth . '"' . $style_attr . '>';
// ── 头像列 ──
$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
/* 闭合 </li> 交给调用方决定。
wp_list_comments 的 walker 会在 start_el 之后、start_lvl 之前
插入子评论容器,所以走列表渲染时这条 </li> **必须先不闭合**,
否则子评论会被挤到 </li> 外面,变成父评论的兄弟节点 ——
HTML 会变成 </li></li><ol class="children"> 这种畸形结构,
CSS 里 "子评论占满整行" 的规则就完全不生效。
AJAX 单独拼 HTML 时没有 walker,才需要自己闭合。 */
if ($close_li) {
$o[] = '</li>';
}
return implode('', $o);
}
/**
* 评论列表的 walker
*
* 只覆盖两处,其余沿用 Walker_Comment:
* 1. 子评论容器类名用 `yang-children`(CSS 按这个类名写缩进与栅格)。
* 核心固定输出 `class="children"`,样式对不上。
* 2. 配合 `comment_callbacks()` 不再自己闭合 </li>,
* 让核心的 end_el 去闭合 —— 这样 <ol> 才落在 <li> 内部。
*/
class Yang_Walker_Comment extends Walker_Comment
{
public function start_lvl(&$output, $depth = 0, $args = array())
{
$GLOBALS['comment_depth'] = $depth + 1;
$output .= '<ol class="yang-children">' . "\n";
}
}
/**
* wp_list_comments 的回调(列表渲染)
*
* 只是把参数转交给统一渲染函数。保留这个函数名是因为
* comments.php 里引用了它,且子主题可能覆盖过。
*
* 注意末尾传 false:不在这里闭合 </li>,交给 walker 的 end_el。
* 原因见 yang_comment_html() 里的注释。
*/
function comment_callbacks($comment, $args = array(), $depth = 1)
{
echo yang_comment_html($comment, $args, $depth, false, 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));
}
/**
* 取「第 N 页评论」:按**顶层评论**分页,再把每条的子孙展开成平铺数组
*
* ── 为什么不直接把 page/per_page 交给 wp_list_comments ──────
* WP 那个分支有缺陷:当 `page`/`per_page` 与它自己从查询变量推导出的
* 当前页完全相等时,内层 `if` 不成立,`$_comments` 就**一次都没被赋值**
* (源码里那句"fall back on $wp_query->comments"的注释下面并没有回退代码),
* 最终 `paged_walk(null, ...)` 返回空字符串。表现就是:
* 「评论(N)」标题在、输入框在,列表一条不显示。
* 单页评论的文章(max_num_comment_pages=1)必然命中,多页的反而侥幸正常 ——
* 这也是它为什么看起来"时好时坏"。
*
* 所以取数由这里负责,`wp_list_comments()` 的第 2 个参数直接收数组,
* 走它的 `null !== $comments` 分支,不碰那段有问题的推导。
*
* ── 为什么按顶层分页 ────────────────────────────────────────
* 分页若按"行"算,一条顶层评论和它的 10 条回复可能被切到两页,
* 第 2 页会出现一堆没有父评论的孤儿回复。按顶层 + 整体展开,
* 父子永远同页。
*
* @param int $post_id 文章 ID
* @param int $page 第几页(从 1 开始)
* @param int $per_page 每页多少条顶层评论;0 = 不分页,全取
* @return WP_Comment[] 平铺后的评论(父在子前,可直接交给 wp_list_comments)
*/
function yang_comment_page($post_id, $page = 1, $per_page = 0)
{
$post_id = (int) $post_id;
if ($post_id <= 0) {
return array();
}
$order = strtoupper((string) get_option('comment_order', 'asc')) === 'DESC' ? 'DESC' : 'ASC';
$args = array(
'post_id' => $post_id,
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => $order,
'parent' => 0,
'hierarchical' => 'threaded',
);
if ($per_page > 0) {
$args['number'] = (int) $per_page;
$args['offset'] = max(0, ((int) $page - 1) * (int) $per_page);
}
$top = get_comments($args);
if (!$top) {
return array();
}
/* 展开子孙。用 get_children() 而不是读 $comment->children ——
后者是 protected,而且要先经 hierarchical='threaded' 才会被填充。 */
$flat = array();
foreach ($top as $c) {
$flat[] = $c;
$kids = $c->get_children(array(
'format' => 'flat',
'status' => 'approve',
'orderby' => 'comment_date_gmt',
'order' => $order,
));
foreach ($kids as $kid) {
$flat[] = $kid;
}
}
return $flat;
}
/**
* 「加载更多评论」AJAX
*
* 旧模板里 `previous_comments_link(__('加载更多'))` 是个普通链接,
* 点了整页跳转(还跳到评论分页 URL)。这里改成原地加载。
*
* 取数与首屏共用 `yang_comment_page()`:以前这里自己写了一份只取顶层、
* 只渲染顶层的查询,导致点「加载更多」出来的评论**全部没有子回复**
* (实测某篇 10 条顶层名下有 12 条回复被吞掉)。
*/
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 = yang_comment_page($post_id, $page, $per_page);
$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,
);
/* 渲染走 wp_list_comments + 同一个 walker,和首屏完全同一条路径。
以前这里是 foreach 手动拼 yang_comment_html(),没有 walker,
于是子回复被平铺成父评论的兄弟节点、层级信息全丢
—— 实测某篇 10 条顶层名下的 12 条回复结构全错。 */
ob_start();
wp_list_comments(array(
'type' => 'comment',
'callback' => 'comment_callbacks',
'walker' => new Yang_Walker_Comment(),
'style' => 'ol',
'max_depth' => $max_depth > 0 ? $max_depth : 5,
'avatar_size' => 48,
'per_page' => 0,
'page' => 0,
), $comments);
$html = ob_get_clean();
// 分页按顶层算,所以"还有没有更多"也要按顶层比,不能拿总评论数比
$top_total = (int) get_comments(array(
'post_id' => $post_id,
'status' => 'approve',
'parent' => 0,
'count' => true,
));
$loaded_top = $page * $per_page;
$total = (int) get_comments_number($post_id);
wp_send_json(array(
'ok' => true,
'html' => $html,
'page' => $page,
'has_more' => $loaded_top < $top_total,
'next_page' => $page + 1,
'loaded' => $loaded_top,
'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'),
'boldPlaceholder' => __('加粗文字', 'kratos'),
'colorPlaceholder' => __('彩色文字', '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);