Files
wordpress-kratos-yang/inc/theme-toc.php
T

1086 lines
41 KiB
PHP
Raw Normal View History

<?php
/**
* 文章目录(TOC)
*
* ── 两种用法 ──────────────────────────────────────────────────
* ① 手动:在文章内容的任意位置写一行
*
* [TOC]
*
* 这个位置就会出现本文的目录,点条目跳到对应标题。
* 可调参数(都可省略):
*
* [TOC title="本文目录" levels="1,2,3" numbering="1" toggle="0" open="0"]
*
* · title 目录标题,默认「目录」
* · levels 收录哪几级标题,逗号分隔,默认 "1,2,3"
* · numbering 1 = 条目前加 1 / 1.1 这样的序号,默认 0
* · toggle 1 = 标题可点击折叠(<details>),默认 1
* · open 1 = 默认展开,0 = 默认收起,默认 1
*
* 手写标记里写了的参数优先于后台设置,没写的才用后台设置。
*
* ② 自动:后台「主题设置 → 自动目录」里选好,正文不用写任何东西。
* 显示方式是**两个独立开关**,可同时开启:
* · 在正文中显示 → 子选项:正文中的对齐(靠左 / 靠右)、插入位置
* (文章开头 / 第一个 H1、H2 之前 / 文章结尾)
* · 浮动显示 → 子选项:浮动方向(左侧 / 右侧)
* 全开时同一篇文章里正文一份 + 浮动一份,两份指向同一批锚点。
* 另外还有:添加方式(手动 / 自动)、默认状态(显示 / 隐藏)、
* 目录标题、收录层级、是否显示序号。
*
* ⚠️ 「靠左 / 靠右」是**正文容器内部**的对齐,不是浮到页面边上;
* 「浮动方向」才是贴页面哪一边。这两件事曾经被合并成一个三选一的
* place 字段(center/left/right),导致两者永远互斥、无法同时开启。
*
* ── 手写永远优先(仅限正文那一份)────────────────────────────
* 只要正文里有 [TOC],就**只**按作者写的位置渲染正文那一份,不再自动
* 插第二份。理由:作者明确表过态的地方,机器不该再替他做一次决定;
* 否则老文章升级后会突然多出一个目录,位置还未必是他要的。
*
* 但浮动面板是**独立开关**:它不属于正文流,作者写了 [TOC] 也没说过
* 「不要浮动目录」。所以 float=1 时照样出浮动面板。
*
* ── 为什么默认收 h1(曾经错设成 "2,3")──────────────────────
* 原先的理由是「h1 是文章标题本身,不该进目录」。这个理由是**假的**:
* 文章标题输出在 single.php 的 <h1 class="title">,在 the_content
* **之外**,根本进不到这里被扫描。能被扫到的 h1 只可能是作者在正文里
* 自己写的一级章节标题。
*
* 一旦正文用 h1 划分章节(很常见:从 Markdown 或别的编辑器粘过来,
* 或原本的 ## 被降级),默认 "2,3" 会把这些一级章节整段丢掉,
* 于是二级标题集体失去父级、目录变成平铺 —— 层级信息全错。
* 所以默认跟 h2/h3 一起收 h1,最多到 h4(h5 以下通常是细节,不进目录)。
*
* ── 为什么在 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](小工具、页面构建器),短代码那条路也能用,且复用
* 同一个渲染函数,不会出现两套行为。
*
* ── 侧栏目录为什么在 wp_footer 输出 ──────────────────────────
* 侧栏是 position:fixed 的独立元素,不属于正文流。挂在正文里会让
* wpautop、各种内容过滤器和「正文首块」样式都去处理一个本不属于
* 正文的盒子,而且一旦有人调 the_content(相关文章、摘要),就会
* 跟着重复输出。放在页面末尾、只认「当前请求就是这篇文章」,最稳。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* 后台选项的运行时默认值(唯一事实来源)
*
* ⚠️ 只改这里不够:后台表单的 std(inc/theme-options.php)是给
* 「后台预填」和「恢复默认」用的,两者必须一致,否则会出现
* 「页面照 A 渲染、后台框里显示 B」的错位。改默认值时两处一起改。
*
* @return array
*/
function yang_toc_defaults()
{
return array(
/* 两个**互相独立**的显示开关,可同时为 1。
*
* 为什么不是「三选一」:曾经用单个 place=center/left/right 表达,
* 把「放在正文里」和「浮在页面边上」混成了同一个维度的三个值,
* 于是两者永远互斥 —— 用户想「正文里放一份、边上再浮一份」时无法表达。
* 现在拆成 inline / float 两个开关 + 各自的子选项:
* inline 在正文流里插一份
* inline_align inline 生效时的对齐:left | right
* float 在页面边缘浮一份
* float_side float 生效时浮哪边:left | right
*/
'inline' => 1, // 0 | 1
'inline_align' => 'left', // left | right(正文容器内部对齐)
'float' => 0, // 0 | 1
'float_side' => 'left', // left | right(贴页面哪一边)
'mode' => 'manual', // manual | auto
'anchor' => 'first', // start | first | end(只对 inline 有效)
'default' => 'show', // show | hide
'title' => __('目录', 'kratos'),
'levels' => array('1' => 1, '2' => 1, '3' => 1, '4' => 0),
'numbering' => 0,
);
}
/**
* 把「收录层级」的多种存法归一成 array(1..6 => true)
*
* 三种输入都可能出现:
* · 库里的 multicheck 值:array('1'=>1,'2'=>1,'3'=>1,'4'=>0)
* · 短代码属性:"2,3"
* · 什么都没配:null / ''
*
* 全空时不返回空数组,而是回落到 1,2,3 —— 「一个级别都没勾」和
* 「目录不显示」在用户心里不是一回事:前者多半是误操作,后者应该
* 用「插入位置=手动且不写标记」表达。这里宁可退回可见的默认。
*
* @param mixed $raw
* @return array
*/
function yang_toc_normalize_levels($raw)
{
$levels = array();
if (is_array($raw)) {
foreach ($raw as $k => $v) {
if (empty($v)) {
continue;
}
$n = (int) $k;
if ($n >= 1 && $n <= 6) {
$levels[$n] = true;
}
}
} elseif (is_string($raw)) {
foreach (preg_split('/[\s,,、]+/u', $raw) 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);
}
return $levels;
}
/**
* 把「原始配置」归一化成可用的设置(纯函数,不查库)
*
* 为什么单独拆出来:这里要判的坏值很多(下拉框被写坏、导入配置带了
* 别的值、老版本残留的键),而真正的判断逻辑与 WordPress 无关。
* 拆成纯函数之后,测试可以直接喂几十种组合,不用动数据库、
* 也不用为了测试给「读设置」那层加后门。
*
* 非法值一律回落到默认值:后台的下拉框理论上不会送来非法值,但
* 配置可能被「恢复默认」「导入配置」「手改数据库」写坏。这里不抛错、
* 不报警,只是让页面别因为一个坏字符串变成空白正文。
*
* @param array $raw 原始键值(可能缺键、可能类型不对)
* @return array
*/
function yang_toc_normalize_settings($raw)
{
$d = yang_toc_defaults();
if (!is_array($raw)) {
$raw = array();
}
$pick = function ($key, $allowed) use ($raw, $d) {
$v = array_key_exists($key, $raw) ? $raw[$key] : $d[$key];
return in_array($v, $allowed, true) ? $v : $d[$key];
};
$s = array();
/* checkbox 的存值有两种:框架写回时是 '1' 或 ''(也可能 true/false)。
* 关键区别在「键不存在」和「键存在但为空」:
* · 键不存在 = 从没配过(新站 / 老库升级)= 用默认值
* · 键存在且为空 = 用户明确取消勾选 = 0
* 用 !empty() 一把梭会把这两种情况混成一件事,结果**新装站点默认不显示
* 正文目录** —— 后台勾选框看起来是勾上的(那是 std 预填),页面却不出。
* 这种错配极难发现,因为两边单独看都"正常"。 */
$flag = function ($key) use ($raw, $d) {
if (!array_key_exists($key, $raw)) {
return $d[$key]; // 从没配过
}
return empty($raw[$key]) ? 0 : 1;
};
$s['inline'] = $flag('inline');
$s['float'] = $flag('float');
$s['inline_align'] = $pick('inline_align', array('left', 'right'));
$s['float_side'] = $pick('float_side', array('left', 'right'));
$s['mode'] = $pick('mode', array('manual', 'auto'));
$s['anchor'] = $pick('anchor', array('start', 'first', 'end'));
$s['default'] = $pick('default', array('show', 'hide'));
$title = array_key_exists('title', $raw) ? $raw['title'] : $d['title'];
$title = is_string($title) ? trim(wp_strip_all_tags($title)) : '';
$s['title'] = $title !== '' ? $title : $d['title'];
$s['levels'] = yang_toc_normalize_levels(
array_key_exists('levels', $raw) ? $raw['levels'] : $d['levels']
);
// checkbox 存的是 '1' / true / false,统一成 0/1
$s['numbering'] = !empty($raw['numbering']) ? 1 : 0;
return $s;
}
/**
* 当前设置(从库里读一次,本请求内缓存)
*
* @return array
*/
function yang_toc_settings()
{
static $cache = null;
if ($cache !== null) {
return $cache;
}
$raw = array();
foreach (array('inline', 'inline_align', 'float', 'float_side',
'mode', 'anchor', 'default', 'title', 'levels', 'numbering') as $k) {
$raw[$k] = kratos_option('toc_' . $k, null);
}
// 库里没有的键用 null 占位,normalize 时按「未配置」回落默认值
foreach ($raw as $k => $v) {
if ($v === null) {
unset($raw[$k]);
}
}
$cache = yang_toc_normalize_settings($raw);
return $cache;
}
/**
* 把设置翻译成渲染参数(与短代码属性同名同格式)
*
* 这样「后台设置」和「手写属性」能直接 array_merge,不需要两套
* 参数体系 —— 两套体系是同类功能最经典的 bug 来源:改了后台不生效、
* 或者手写参数被后台悄悄覆盖。
*
* @param array $s 设置(不传则取当前设置)
* @return array
*/
function yang_toc_atts_from_settings($s = null)
{
if (!is_array($s)) {
$s = yang_toc_settings();
}
$levels = array();
$map = is_array($s['levels']) ? $s['levels'] : yang_toc_normalize_levels($s['levels']);
ksort($map);
foreach ($map as $k => $v) {
if (!empty($v)) {
$levels[] = $k;
}
}
return array(
'title' => $s['title'],
'levels' => implode(',', $levels),
'numbering' => !empty($s['numbering']) ? '1' : '0',
'toggle' => '1',
'open' => $s['default'] === 'show' ? '1' : '0',
);
}
/**
* 正文里是否有手写的 [TOC] 标记
*
* @param string $content
* @return bool
*/
function yang_toc_has_marker($content)
{
return is_string($content) && stripos($content, '[toc') !== false;
}
/**
* 本次请求该怎么办
*
* 返回:
* inline_at null 正文里不插目录
* 'marker' 按作者写的 [TOC] 位置插
* 'start' 文章开头
* 'first' 第一个 H1/H2 之前
* 'end' 文章结尾
* inline_align 'left'/'right' 正文章节的对齐方向
* rail bool 是否输出侧边浮动目录
* side 'left'/'right' 侧栏挂哪边
* settings array 归一化设置
*
* 规则(顺序即优先级):
* ① 正文有 [TOC] ⇒ 只在标记处渲染正文那一份;浮动仍按 float 开关独立决定。
* 手写标记只管「正文那一份在哪」,没道理连带把浮动面板也关掉 ——
* 那是两件事。但正文绝不会出现第二份。
* ② 添加方式 = 手动 ⇒ 不自动插正文那份;浮动同样独立决定。
* ③ inline=1 ⇒ 按 anchor 插进正文;inline=0 ⇒ 正文不动。
* ④ float=1 ⇒ 出浮动面板,方向按 float_side。
*
* ③ 和 ④ 完全独立,可以同时成立:正文一份 + 浮动一份,共用同一批锚点。
*
* @param bool $has_marker
* @param array $s 设置(不传则取当前设置)
* @return array
*/
function yang_toc_plan($has_marker = false, $s = null)
{
if (!is_array($s)) {
$s = yang_toc_settings();
}
$plan = array(
'inline_at' => null,
'inline_align' => $s['inline_align'],
'rail' => false,
'side' => $s['float_side'],
'settings' => $s,
);
/* 浮动独立于手写标记与添加方式:它不属于正文流,
作者写了 [TOC] 也没说过「不要浮动目录」。 */
if (!empty($s['float'])) {
$plan['rail'] = true;
}
/* 正文那一份:手写标记 > 自动插入 */
if ($has_marker) {
$plan['inline_at'] = 'marker';
return $plan;
}
if ($s['mode'] === 'manual') {
return $plan;
}
if (!empty($s['inline'])) {
$plan['inline_at'] = $s['anchor'];
}
return $plan;
}
/**
* 把标题文本变成锚点 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;
}
/**
* 把条目数组渲染成 <ul class="yang-toc-list">(只出列表,不含外壳)
*
* 侧栏只要列表,内嵌要完整盒子。拆成两个函数是为了让两边共用同一份
* 嵌套/序号算法 —— 抄一份出来必然会漂移(改了一边忘了另一边)。
*
* @param array $items 条目(含 level/text/id)
* @param array $atts 解析后的属性
* @return array array('list' => HTML, 'picked' => 收录的条目数)
*/
function yang_toc_build_list($items, $atts = array())
{
if (empty($items)) {
return array('list' => '', 'picked' => 0);
}
$levels = yang_toc_normalize_levels(
isset($atts['levels']) ? (string) $atts['levels'] : ''
);
$numbering = isset($atts['numbering']) && (string) $atts['numbering'] === '1';
// ── 过滤出要收录的标题 ──
$picked = array();
foreach ($items as $it) {
if (isset($levels[$it['level']])) {
$picked[] = $it;
}
}
if (empty($picked)) {
// 文章里一个符合条件的标题都没有 —— 用户明确要求「没有就不显示」
return array('list' => '', 'picked' => 0);
}
// ── 序号(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();
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;
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;
}
// 收尾:关最后一条 + 逐层收掉所有 ul
$o[] = '</li>';
for ($k = $prev_depth; $k > 0; $k--) {
$o[] = '</ul></li>';
}
$o[] = '</ul>';
return array('list' => implode('', $o), 'picked' => count($picked));
}
/**
* 把条目数组渲染成目录 HTML(完整盒子,用于正文内嵌)
*
* @param array $items 条目(含 level/text/id)
* @param array $atts 解析后的属性
* @return string 空串表示不该输出(没有可用标题)
*/
function yang_toc_render($items, $atts = array())
{
$built = yang_toc_build_list($items, $atts);
if ($built['list'] === '') {
return '';
}
$list = $built['list'];
$count = $built['picked'];
// ── 参数归一 ──
$title = isset($atts['title']) && trim($atts['title']) !== ''
? trim($atts['title'])
: __('目录', 'kratos');
$toggle = !isset($atts['toggle']) || (string) $atts['toggle'] === '1';
$open = !isset($atts['open']) || (string) $atts['open'] === '1';
/* 折叠用 <details>:原生元素,不需要 JS 就能工作。
JS 被禁用 / 加载失败时,目录仍然可以展开收起 ——
比用自定义 div + class 切换更抗故障。
默认收起时不写 open 属性(details 的展开态只由 open 决定,
不认 CSS)——「默认隐藏」靠的就是这一点。 */
if ($toggle) {
$html = '<details class="yang-toc"' . ($open ? ' open' : '') . '>'
. '<summary class="yang-toc-head">'
. '<span class="yang-toc-title">' . esc_html($title) . '</span>'
. '<span class="yang-toc-count">' . $count . '</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 . '</span>'
. '</div>'
. '<nav class="yang-toc-body" aria-label="' . esc_attr($title) . '">'
. $list
. '</nav>'
. '</div>';
}
return $html;
}
/**
* 当前请求是不是「一篇文章的正文」
*
* 自动插入和侧栏只在这里生效。散文、列表页、附件页都不该冒出目录。
*
* 为什么还要求「就是被查询的那一篇」:主题里可能用 related posts 之类的
* 二次循环,那些内容也会过 the_content。按 id 比对能确保只有正主
* 才拿到目录,不会让「相关文章」的标题也被塞进目录里。
*
* @return bool
*/
function yang_toc_is_article_page()
{
if (is_admin() || !is_singular() || is_attachment()) {
return false;
}
if (!in_the_loop()) {
return false;
}
$qid = (int) get_queried_object_id();
$cid = (int) get_the_ID();
return ($qid > 0 && $cid === $qid);
}
/**
* 侧栏是否可以输出(正文已被本主题扫过一遍的凭据)
*
* ── 为什么需要这个开关 ────────────────────────────────────────
* 侧栏在 wp_footer 输出,那时正文可能**根本没走** the_content 过滤器:
* 模板可能直接用 get_the_content()、OEmbed、缓存插件直出 HTML,
* 或者正文被别的插件接管。这些情况下正文里的标题**没有 id**,
* 而侧栏照旧按扫描结果生成一堆 href="#xxx" —— 点下去一个都跳不动,
* 而且看起来完全正常(目录有内容、链接有颜色),排查极费劲。
*
* 所以侧栏不自己判断"该不该显示",而是只认「正文过滤器确实为这篇文章
* 补过 id」这个事实。宁可不出侧栏,也不出一份点不动的目录。
*
* @param bool|null $set 传入 true/false 则写入
* @return bool
*/
function yang_toc_rail_ready($set = null)
{
static $ready = false;
if ($set !== null) {
$ready = (bool) $set;
}
return $ready;
}
/**
* 扫描结果缓存(按文章 id)
*
* 正文过滤器和侧栏分别在两个时机跑,必须拿到**同一份**条目(id 一旦
* 不一致,点侧栏就跳不到标题)。正文过滤器先跑,把结果存这里;
* 侧栏直接复用。若侧栏先跑(或正文没走过滤器),退回扫描原文。
*
* @param int $post_id
* @param array $scan 传入则写入缓存
* @return array array('content' => ..., 'items' => ...)
*/
function yang_toc_scan_cache($post_id, $scan = null)
{
static $cache = array();
$post_id = (int) $post_id;
if (is_array($scan)) {
$cache[$post_id] = $scan;
return $scan;
}
if (isset($cache[$post_id])) {
return $cache[$post_id];
}
$post = get_post($post_id);
$cache[$post_id] = $post && isset($post->post_content)
? yang_toc_scan($post->post_content)
: array('content' => '', 'items' => array());
return $cache[$post_id];
}
/**
* the_content 主处理
*
* 顺序很重要:先给标题补 id(扫描出条目),再插目录。
* 反过来的话,目录生成时拿不到标题清单。
*/
function yang_toc_filter_content($content)
{
if (is_admin() || !is_string($content) || $content === '') {
return $content;
}
$has_marker = yang_toc_has_marker($content);
$plan = yang_toc_plan($has_marker);
/* 什么都不用做:没有手写标记、也没有自动插入、也不出侧栏。
这时**连 id 都不补**是刻意的 —— 没开目录的文章不该被塞进一堆 id,
那会改变页面上已有的选择器和锚点行为。 */
if ($plan['inline_at'] === null && !$has_marker && !$plan['rail']) {
return $content;
}
/* 手写标记之外的一切(自动插入 / 侧栏)只在真正的文章正文里干。
手写标记放到哪里都认,那是作者自己的选择。 */
$is_article = yang_toc_is_article_page();
if ($plan['inline_at'] !== 'marker' && !$is_article) {
return $content;
}
$scan = yang_toc_scan($content);
$items = $scan['items'];
$out = $scan['content'];
// 侧栏要用的那份条目必须与正文里的 id 完全一致,存起来给 footer 用
if ($is_article) {
yang_toc_scan_cache(get_the_ID(), $scan);
// 正文确实被扫过、标题确实补了 id ⇒ 侧栏的链接才保证跳得动
yang_toc_rail_ready(true);
}
return yang_toc_apply($content, $out, $items, $has_marker, $plan);
}
add_filter('the_content', 'yang_toc_filter_content', 9);
/**
* 按 plan 把目录落到已补好 id 的正文上(纯函数)
*
* 单独拆出来是为了让测试能「喂一份设置 → 直接看输出」:上面那个壳依赖
* WordPress 运行时(is_singular / in_the_loop / get_the_ID 全要对上),
* 而设置是从库里读的、一次请求只能有一份。拆开之后,几十种组合可以在
* 同一个进程里逐个跑,不必为了测试给读设置那层开后门。
*
* @param string $content 原始正文(替换失败时原样返回它)
* @param string $out 已补好标题 id 的正文
* @param array $items yang_toc_scan() 得到的条目
* @param bool $has_marker 原文里有没有手写 [TOC]
* @param array $plan yang_toc_plan() 的结果
* @return string
*/
function yang_toc_apply($content, $out, $items, $has_marker, $plan)
{
/* ── ① 手写标记:按作者写的位置渲染,且不再自动插第二份 ── */
if ($has_marker) {
/* 后台设置当默认值,标记里写了的属性覆盖它。
替换 [TOC] / [toc] / [TOC ...]。大小写都认 ——
用户不会记得"这里的标签必须大写",凭直觉写 [toc] 就该能用。 */
$base = yang_toc_atts_from_settings($plan['settings']);
$replaced = preg_replace_callback(
'/\[toc(\s[^\]]*)?\]/i',
function ($m) use ($items, $base) {
$atts = array_merge($base, yang_toc_parse_atts(isset($m[1]) ? $m[1] : ''));
return yang_toc_render($items, $atts);
},
$out
);
return is_string($replaced) ? $replaced : $content;
}
/* ── ② 只出浮动:正文只补 id,目录由 wp_footer 输出 ── */
if ($plan['inline_at'] === null) {
return $out;
}
/* ── ③ 正文里插一份 ── */
$html = yang_toc_render($items, yang_toc_atts_from_settings($plan['settings']));
if ($html === '') {
// 一个符合层级的标题都没有 —— 不输出空壳
return $out;
}
$pos = $plan['inline_at'];
$slot = yang_toc_slot_html($html, $pos, $plan['inline_align']);
return yang_toc_inject($out, $slot, $pos);
}
/**
* 正文目录的外层盒子(带插入位置与对齐方向)
*
* 对齐为什么放在这一层、而不是 .yang-toc 上:
* · .yang-toc 自己已经有 24px 上下的外边距,再叠一层 margin 会叠加;
* · 靠左/靠右本质是「盒子在正文容器里怎么摆」,属于外层容器的职责;
* · 用 data 属性把方向带下去,CSS 只需一条 [data-align="right"] 规则,
* 不必再生成 -right 这种 class 变体(class 会越想越多)。
*
* 注意靠左/靠右**不改变盒子的宽度**:目录块是否撑满正文栏由 .yang-toc
* 的样式决定(默认 auto,即撑满)。对齐只在它不撑满时才有可见效果,
* 所以这里同时给了 inline-size 相关的钩子,样式里再按需收窄。
*
* @param string $html 目录 HTML
* @param string $pos start | first | end
* @param string $align left | right
* @return string
*/
function yang_toc_slot_html($html, $pos, $align = 'left')
{
$align = ($align === 'right') ? 'right' : 'left';
return '<div class="yang-toc-slot yang-toc-slot-' . esc_attr($pos) . '"'
. ' data-align="' . esc_attr($align) . '">' . $html . '</div>';
}
/**
* 把目录插进正文(纯函数)
*
* @param string $content 已补好 id 的正文
* @param string $slot 目录盒子
* @param string $pos start | first | end
* @return string
*/
function yang_toc_inject($content, $slot, $pos)
{
if ($pos === 'start') {
return $slot . $content;
}
if ($pos === 'end') {
return $content . $slot;
}
/* 'first':插在第一个 H1 / H2 之前。
· 用 h1 或 h2 是作者视角的「第一个章节标题」。文章大标题在正文之外,
不会被算进来(见文件头注释)。
· 用 preg_match 的 PREG_OFFSET_CAPTURE 拿字节偏移,插在**开标签之前**;
若改成"在 <h2 后面再拆"就会把标签切断,页面结构直接坏掉。
· 只有 h3/h4 的文章不命中 ⇒ 退回插在开头,总比不显示好。 */
if (preg_match('/<h[12]\b/i', $content, $m, PREG_OFFSET_CAPTURE)) {
$at = $m[0][1];
return substr($content, 0, $at) . $slot . substr($content, $at);
}
return $slot . $content;
}
/**
* 短代码兜底
*
* 正常路径下 [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);
$base = yang_toc_atts_from_settings();
$atts = is_array($atts) ? $atts : array();
return yang_toc_render($scan['items'], array_merge($base, $atts));
}
add_shortcode('toc', 'yang_toc_shortcode');
add_shortcode('TOC', 'yang_toc_shortcode');
/**
* 侧边浮动目录的 HTML
*
* 不在正文流里:一个贴边的细长标签 + 一块可滑出的面板。默认展开与否
* 由「默认状态」决定,展开态直接写进 class(is-open),所以**没有 JS
* 也能看到默认状态**;JS 只负责切换。
*
* <aside> + <button> 而不是纯 div:按钮天生可聚焦、可回车触发,键盘
* 用户不需要额外的 tabindex / role 补丁。
*
* @param array $items 条目
* @param array $plan yang_toc_plan() 的结果
* @return string
*/
function yang_toc_rail_html($items, $plan)
{
$s = $plan['settings'];
$built = yang_toc_build_list($items, yang_toc_atts_from_settings($s));
if ($built['list'] === '') {
return '';
}
$side = ($plan['side'] === 'right') ? 'right' : 'left';
$open = ($s['default'] === 'show');
$title = $s['title'];
$cls = 'yang-toc-rail yang-toc-rail-' . $side . ($open ? ' is-open' : '');
return '<aside class="' . esc_attr($cls) . '" data-side="' . esc_attr($side) . '"'
. ' data-state="' . ($open ? 'open' : 'closed') . '">'
. '<button type="button" class="yang-toc-rail-tab" aria-expanded="' . ($open ? 'true' : 'false') . '"'
. ' aria-controls="yang-toc-rail-panel">'
. '<span class="yang-toc-rail-tab-text">' . esc_html($title) . '</span>'
. '</button>'
. '<div class="yang-toc-rail-panel" id="yang-toc-rail-panel">'
. '<div class="yang-toc-rail-head">'
. '<span class="yang-toc-rail-title">' . esc_html($title) . '</span>'
. '<span class="yang-toc-count">' . $built['picked'] . '</span>'
. '<button type="button" class="yang-toc-rail-close" aria-label="' . esc_attr(__('收起目录', 'kratos')) . '">'
. '<span aria-hidden="true">&times;</span>'
. '</button>'
. '</div>'
. '<nav class="yang-toc-rail-body" aria-label="' . esc_attr($title) . '">'
. $built['list']
. '</nav>'
. '</div>'
. '</aside>';
}
/**
* 输出侧边目录(页面末尾)
*
* 只认「当前请求就是这篇文章」这个条件,不看正文里有没有标记 ——
* 那个判断在过滤器里已经做过一次,这里再查一次原文是必须的:
* wp_footer 时正文可能已经被别人改过,而侧栏只关心作者写没写标记。
*/
function yang_toc_render_rail()
{
if (is_admin() || !is_singular() || is_attachment()) {
return;
}
$post = get_post();
if (!$post || !isset($post->post_content)) {
return;
}
/* 手写 [TOC] 只管「正文那一份在哪」,不再连带关掉浮动面板。
两者现在是独立开关,作者写了标记也不等于说过「不要浮动目录」。 */
$plan = yang_toc_plan(yang_toc_has_marker($post->post_content));
if (!$plan['rail']) {
return;
}
// 正文没走本主题的过滤器 ⇒ 标题没有 id ⇒ 目录点不动,不如不出
if (!yang_toc_rail_ready()) {
return;
}
$scan = yang_toc_scan_cache($post->ID);
echo yang_toc_rail_html($scan['items'], $plan);
}
add_action('wp_footer', 'yang_toc_render_rail', 20);
/**
* 需要时才加载样式和脚本
*
* 每篇文章都加一份 TOC 资源是浪费;只有「真的会出现目录」才加载:
* · 正文里写了 [TOC];或
* · 后台开了自动插入(中间 + 非手动);或
* · 后台开了侧栏(左/右 + 非手动)。
*
* 依赖 'kratos' 保证排在主题主样式之后,不会被覆盖。
*/
function yang_toc_assets()
{
if (is_admin() || !is_singular() || is_attachment()) {
return;
}
$post = get_post();
if (!$post || !isset($post->post_content)) {
return;
}
/* 上面已经排除了列表页/附件页,走到这里的都是单篇内容。
自动插入和侧栏是否真的生效还要看「是不是正文的这次渲染」,
但那要等 the_content 才知道;这里宁可多加载一份资源,
也不能让目录出现时样式没跟上(那是肉眼可见的坏页面)。 */
$plan = yang_toc_plan(yang_toc_has_marker($post->post_content));
if ($plan['inline_at'] === null && !$plan['rail']) {
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);