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:
@@ -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);
|
||||
Reference in New Issue
Block a user