Files
wordpress-kratos-yang/inc/theme-toc.php
T
laoyang 4a2d62b1aa 3.2.31 目录「正文内显示」与「浮动显示」拆成两个独立开关(#1)
原来「目录位置」是一个三选一下拉:正文中间 / 页面左侧 / 页面右侧,
等于把「放进正文」和「浮在页边」当成互斥的一件事 —— 想同时在正文
开头放一份、又在边上浮一份,做不到。

改成两个独立勾选,各自带子选项:

  · 在正文中显示 → 正文中的对齐(靠左 / 靠右)+ 自动插入位置
  · 浮动显示     → 浮动方向(页面左侧 / 页面右侧)

两个都勾就同时出现两份,只勾一个就只出现一种。四种组合都在后台
联动里逐个验证过(探针 70/0)。

新增能力「正文内靠左 / 靠右」是本单的主要新需求。实现上两个要点:

  1. 必须给正文目录一个宽度上限(max-width: min(100%, 28em))。
     块级盒子默认撑满正文栏,此时 margin:auto 没有剩余空间可分配,
     「靠左/靠右」会是一个属性正确、画面纹丝不动的空操作。
  2. 用 data-align 属性把方向带下去,CSS 只需两条规则,
     不随方向数量膨胀。属性在 yang_toc_slot_html() 里输出。

语义收窄:手写 [TOC] 以前会让整份自动处理全停(连带 rail=false),
现在只手管「正文那一份」—— 手写位置优先,浮动仍由 float 开关独立
决定。这样「精确定位正文位置」和「全站边上都有目录」可以并存。

顺手修掉一处默认值错配:checkbox 用 !empty() 判断会把「键不存在」
和「键明确为空」混成一件事,导致新装站点后台框显示勾上、页面却
不出目录。改用 array_key_exists() 区分的三态判断。

老配置不迁移:3.2.30 的 toc_place 直接作废,不做兼容。
默认 inline=1 / float=0,升级后页面与升级前一致。

重构:把内容过滤器拆成 yang_toc_apply()(纯执行层)+ 运行时壳,
使设置组合能在单进程单测里逐个喂进去(PHP 无法重置函数 static)。

验证
  逻辑 101/0 · 逻辑反向 15/15
  旧回归 53/0
  静态契约 58/0 · 反向 11/11
  后台几何+交互 70/0 · 反向 11/11
  侧栏几何 73/0 · 反向 8/8
  正文对齐几何 23/0 · 反向 5/5(含阴性对照)
  包内容契约 59/0 · 反向 9/9

正文对齐的几何探针量到真实的 Δleft = 348px(靠右比靠左),
而不是只断言 data-align 字符串存在。
2026-10-25 13:37:26 +08:00

1086 lines
41 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" 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);