Files
wordpress-kratos-yang/inc/theme-toc.php
T
laoyang cbe0f2dc59 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(真实文章渲染目录 + 真实评论数据渲染)。
2026-10-24 01:03:36 +08:00

472 lines
17 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
/**
* 文章目录(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);