Files
wordpress-kratos-yang/inc/theme-toc.php
T
laoyang 6f51d50a58 3.2.30: 文章目录进后台(新增「自动目录」标签页)—— 位置/插入点/默认展开可配,手写 [TOC] 永远优先
后台「主题设置」新增第 5 个标签页「自动目录」,7 个选项:
  目录位置   正文中间 / 页面左侧(浮动)/ 页面右侧(浮动)
  添加方式   手动(只认 [TOC])/ 自动
  自动插入位置  文章开头 / 第一个 H1、H2 之前 / 文章结尾
  默认状态   默认显示 / 默认隐藏
  另外:目录标题、收录层级(H1~H4)、是否显示序号

设计要点:
- 插入方式拆成 toc_mode(manual/auto) x toc_anchor(start/first/end) 两个正交
  字段,不合成一个四值下拉:侧栏模式下插入位置毫无意义,合并会产生歧义。
- 手写 [TOC] 绝对优先:yang_toc_plan() 里 $has_marker 最先返回,inline_at
  ='marker' 且 rail=false —— 否则同一篇会出现两份目录,老文章升级后突然多
  出一个目录。这条是刻意守住的。
- 纯函数 + 薄壳分层:normalize_settings / plan / inject / build_list 全部不查库,
  单进程可喂几十种组合;settings() 只负责读库 + 调纯函数 + static 缓存。
- <details open> 是唯一可靠的「默认收起」手段:展开态不接受 CSS 覆盖,只能
  由 open 属性决定,所以隐藏时必须不输出该属性。
- 侧栏收起态必须 translateX(±100%) 完全移出视口:只做 opacity 会让透明面板
  留在原位吃掉正文点击(探针 elementFromPoint 才抓得到)。
- yang_toc_rail_ready() 开关:侧栏在 wp_footer 输出,那时正文可能没走
  the_content(缓存插件/OEmbed),标题没 id ⇒ 侧栏链接全跳不动却看起来正常。
  宁可不出侧栏。
- 后台联动隐藏「插入位置」不用 jQuery show()/hide():会把 .section 的行内
  display 写成 block,悄悄回退 3.2.28 的 display:flow-root BFC 修复。改用
  class + display:none!important。

验证(全部在本轮重跑):
  _test_toc.php       53/0   旧 TOC 行为回归
  _toc330_logic.php   88/0   逻辑判据
  _toc330_falsify     13/13  注入全变红且红对地方
  _geo330_rail.js     73/0   侧栏真实几何(左/右 x 展开/收起 x 宽窄屏)
  _geo330_falsify     8/8
  _guard330.php       46/0   后台控件与运行时默认值双向一致
  _guard330_falsify   8/8
  _admin330_tab_check 36/0   新标签页几何 + 联动 + 载入态
  _admin330_falsify   8/8
  _admin328_check     13/0   3.2.28 布局回归

发布:只发 zip + readme + update.json 到 theme-update/,不动服务器主题目录,
用户在 WP 后台自行升级(沿用 3.2.24 起的策略)。
2026-10-25 12:18:52 +08:00

990 lines
35 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 之前 / 文章结尾
* · 默认状态:默认显示 / 默认隐藏
* · 另外可配目录标题、收录层级、是否显示序号
*
* ── 手写永远优先 ──────────────────────────────────────────────
* 只要正文里有 [TOC],就**只**按作者写的位置渲染,不再自动插第二份、
* 也不出侧栏。理由:作者明确表过态的地方,机器不该再替他做一次决定;
* 否则老文章升级后会突然多出一个目录,位置还未必是他要的。
*
* ── 为什么默认收 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(
'place' => 'center', // center | left | right
'mode' => 'manual', // manual | auto
'anchor' => 'first', // start | first | end(只对 center 有效)
'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();
$s['place'] = $pick('place', array('center', '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('place', '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' 文章结尾
* rail bool 是否输出侧边浮动目录
* side 'left'/'right' 侧栏挂哪边
* settings array 归一化设置
*
* 规则(顺序即优先级):
* ① 正文有 [TOC] ⇒ 只在标记处渲染,不出侧栏、不自动插;
* ② 添加方式 = 手动 ⇒ 什么都不自动做;
* ③ 目录位置 = 中间 ⇒ 按「自动插入位置」插进正文;
* ④ 目录位置 = 左/右 ⇒ 出侧栏,正文不动(插入位置对侧栏无效)。
*
* @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,
'rail' => false,
'side' => 'left',
'settings' => $s,
);
if ($has_marker) {
$plan['inline_at'] = 'marker';
return $plan;
}
if ($s['mode'] === 'manual') {
return $plan;
}
if ($s['place'] === 'center') {
$plan['inline_at'] = $s['anchor'];
} else {
$plan['rail'] = true;
$plan['side'] = ($s['place'] === 'right') ? 'right' : 'left';
}
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);
}
/* ── ① 手写标记:按作者写的位置渲染,且不再自动插第二份 ── */
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 = '<div class="yang-toc-slot yang-toc-slot-' . esc_attr($pos) . '">' . $html . '</div>';
return yang_toc_inject($out, $slot, $pos);
}
add_filter('the_content', 'yang_toc_filter_content', 9);
/**
* 把目录插进正文(纯函数)
*
* @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]:手写优先,不出侧栏
if (yang_toc_has_marker($post->post_content)) {
return;
}
$plan = yang_toc_plan(false);
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);