),默认 1 * · open 1 = 默认展开,0 = 默认收起,默认 1 * * 手写标记里写了的参数优先于后台设置,没写的才用后台设置。 * * ② 自动:后台「主题设置 → 自动目录」里选好,正文不用写任何东西。 * 显示方式是**两个独立开关**,可同时开启: * · 在正文中显示 → 子选项:正文中的对齐(靠左 / 靠右)、插入位置 * (文章开头 / 第一个 H1、H2 之前 / 文章结尾) * · 浮动显示 → 子选项:浮动方向(左侧 / 右侧) * 全开时同一篇文章里正文一份 + 浮动一份,两份指向同一批锚点。 * 另外还有:添加方式(手动 / 自动)、默认状态(显示 / 隐藏)、 * 目录标题、收录层级、是否显示序号。 * * ⚠️ 「靠左 / 靠右」是**正文容器内部**的对齐,不是浮到页面边上; * 「浮动方向」才是贴页面哪一边。这两件事曾经被合并成一个三选一的 * place 字段(center/left/right),导致两者永远互斥、无法同时开启。 * * ── 手写永远优先(仅限正文那一份)──────────────────────────── * 只要正文里有 [TOC],就**只**按作者写的位置渲染正文那一份,不再自动 * 插第二份。理由:作者明确表过态的地方,机器不该再替他做一次决定; * 否则老文章升级后会突然多出一个目录,位置还未必是他要的。 * * 但浮动面板是**独立开关**:它不属于正文流,作者写了 [TOC] 也没说过 * 「不要浮动目录」。所以 float=1 时照样出浮动面板。 * * ── 为什么默认收 h1(曾经错设成 "2,3")────────────────────── * 原先的理由是「h1 是文章标题本身,不该进目录」。这个理由是**假的**: * 文章标题输出在 single.php 的

,在 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 —— 等短代码执行时,内容已经被换行转成了

, * 再去插 id 就和扫描时的文本不是同一份了。 * * 所以在优先级 9(早于 wpautop 和 do_shortcode)一次做完: * ① 给每个 h1~h6 补 id(已有 id 的沿用,不动别人的锚点) * ② 把 [TOC] 直接替换成目录 HTML,或按后台设置插到指定位置 * * 放在

里是刻意的:wpautop 会把「块级元素」两边的

剥掉, * 所以不会出现

…

这种非法嵌套。 * * 同时仍然注册了短代码(见文件末尾):万一有人在 the_content 之外 * 调用 [TOC](小工具、页面构建器),短代码那条路也能用,且复用 * 同一个渲染函数,不会出现两套行为。 * * ── 侧栏目录为什么在 wp_footer 输出 ────────────────────────── * 侧栏是 position:fixed 的独立元素,不属于正文流。挂在正文里会让 * wpautop、各种内容过滤器和「正文首块」样式都去处理一个本不属于 * 正文的盒子,而且一旦有人调 the_content(相关文章、摘要),就会 * 跟着重复输出。放在页面末尾、只认「当前请求就是这篇文章」,最稳。 * * @author Yongquan Yang * @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 有效) /* ── 默认开合:两份**各自独立**(3.2.59 起)─────────────── * * 原来只有一个 'default',正文那份和浮层那份共用同一个值, * 改一个另一个必然跟着变。用户要的是「浮层默认隐藏、正文默认显示」 * 这种组合,一个值表达不了。 * * ⚠️ 'default' 不再出现在返回数组里,但它**仍然是被接受的输入键** * (见 yang_toc_normalize_settings() 里的迁移段)。老配置、 * 老版本的子主题调用传进来都还能生效。 * * 为什么不在返回值里保留 'default' 当"两者的投影": * 那会变成一个可能跟两个新键不一致的影子值, * 将来谁读到它就成了一个说不清来源的 bug。宁可让老键 * 在输出里彻底消失 —— 输入兼容、输出单一事实。 */ 'inline_default' => 'show', // show | hide(正文那份) 'float_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')); /* ── 默认开合:两份各自独立 + 老配置迁移 ─────────────────────── * * 3.2.59 把原来的单个 'default' 拆成 inline_default / float_default。 * 这里要同时满足四件事,少一件就会有人升级后设置被偷偷改掉: * * ① 新键存在且合法 → 用它 * ② 新键存在但值非法 → 回落该键的默认(不用老键顶,老键更可能是脏的) * ③ 新键**不存在**、老键存在 → 老键的值(这就是迁移:老站点只存了 * toc_default,升级后两份都该保持原样) * ④ 新旧都不存在 → 各键默认 * * 🔴 为什么不能简单写成 $pick('inline_default', ...): * $pick 的语义是「键不存在 ⇒ 取 $d[$key]」,也就是 ④。 * 那样一来 ③ 就落进 ④,老站点设了「默认隐藏」的,升级后 * 两份**全部变回 show**,而界面上还看不出任何异常 —— * 这正是「静默重置用户配置」,比崩掉更难发现。 * * 为什么顺序是「先看新键,再退回老键」而不是反过来: * 用户一旦在 3.2.59 之后动过新下拉框,库里就有新键了, * 此时老键成了历史残留 —— 必须让新键完全压过它,否则 * 用户「改成默认显示」保存后会发现没生效(老键还在那儿赢)。 */ $legacy = null; if (array_key_exists('default', $raw) && in_array($raw['default'], array('show', 'hide'), true)) { $legacy = $raw['default']; } $new_pair = array('inline_default', 'float_default'); $s['inline_default'] = $d['inline_default']; $s['float_default'] = $d['float_default']; foreach ($new_pair as $k) { if (array_key_exists($k, $raw)) { $s[$k] = in_array($raw[$k], array('show', 'hide'), true) ? $raw[$k] : $d[$k]; } elseif ($legacy !== null) { $s[$k] = $legacy; // ③ 迁移:只存过老键的站点 } } $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', 'title', 'levels', 'numbering', 'inline_default', 'float_default', /* 'default' 是 3.2.58 及更早版本的旧键,**必须继续读**: 不读就不知道老配置里存了什么,迁移段拿不到值, 老站点升级后默认开合会被重置成 show。 读进来之后由 normalize 的迁移段消化掉,不会出现在输出里。 */ 'default') 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; } /** * 后台表单读设置时,把老键 toc_default 的值"投影"到两个新键上 * * ── 为什么需要这一层(3.2.59,工单 #27,实测出来的问题)─────────── * * 迁移在 yang_toc_normalize_settings() 里已经做对了:老站点只存 * toc_default=hide 时,页面**实际**渲染成 hide。 * * 但后台表单不是走 normalize 的 —— 框架(class-options-interface.php:87-96) * 的规则是: * $val = $value['std']; // 先取 std * if (isset($settings[$id])) { $val = $settings[$id]; } // 库里有就覆盖 * 老站点库里**没有** toc_inline_default 这个键 ⇒ `$val` 停在 std=show。 * * 结果就是(真机上实测到的): * 页面实际渲染:hide * 后台下拉框: show ← 错位 * * 用户看到下拉框写着「默认显示」,会以为自己的设置丢了;而且他若不 * 主动去改,就永远不会触发保存,这个错位会一直存在。 * 更糟的是"看起来是 show、实际是 hide",两边单独看都正常。 * * ── 怎么做:内存里投影,不写库 ───────────────────────────────── * 用 `option_kratos` filter(WP 核心在 get_option 之后触发,**只影响读取**), * 把老键的值补进缺的两个新键。库不动 —— 这样: * · 用户没保存:每次读都投影一次,显示始终和页面一致 * · 用户保存了:框架写库时带上新键,之后投影自动失效(他动过了) * · 想回滚到旧版:库里从未被加过新键,旧版读 toc_default 一切照旧 * * 🔴 为什么不用 `pre_option_kratos`:那个 filter 在 get_option **之前**, * 拿到的是 null,得自己去查库,等于绕开缓存重查一次。 * `option_kratos` 拿到的已经是解析好的数组,改完直接返回即可。 * * 🔴 为什么不放在 normalize 里做:normalize 是纯函数,它的职责是 * 「输入 → 可渲染的设置」。让它去改后台表单看到的东西,是把 * 「渲染语义」和「配置界面语义」混在一起。这里用一层 filter 分开, * 哪一层出问题都能单独定位。 * * @param mixed $value get_option('kratos') 的返回值 * @return mixed */ function yang_toc_admin_legacy_values($value) { if (!is_array($value)) { return $value; } /* 老键必须存在且合法,否则没有可投影的值 */ if (!array_key_exists('toc_default', $value)) { return $value; } $legacy = $value['toc_default']; if (!in_array($legacy, array('show', 'hide'), true)) { return $value; } /* 只补**缺失**的键。已经有的不动 —— 用户动过新下拉框之后, 新键才是真相,老键是历史残留。 */ foreach (array('toc_inline_default', 'toc_float_default') as $k) { if (!array_key_exists($k, $value)) { $value[$k] = $legacy; } } return $value; } /* 只在后台挂。前台也挂上的话,会让 kratos_option() 读到的值多出两个 本来不存在的键 —— 虽然无害(normalize 有白名单),但那是没有理由的 行为改变,且会让前台与"库里真实内容"不再一致。 */ if (is_admin()) { add_filter('option_kratos', 'yang_toc_admin_legacy_values'); } /** * 把设置翻译成渲染参数(与短代码属性同名同格式) * * 这样「后台设置」和「手写属性」能直接 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', /* 正文那份默认开合。只影响正文内嵌的
; 浮层走 yang_toc_rail_html() 自己的 float_default,两条路互不引用。 */ 'open' => $s['inline_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(可能含 之类) * @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),也可能跨行。…

当成一对)。 */ $out = preg_replace_callback( '#]*)>(.*?)#is', function ($m) use (&$items, &$used) { $level = (int) $m[1]; $attrs = $m[2]; $inner = $m[3]; // 已经有 id 的标题:沿用。绝不覆盖 —— 那会打断别人已有的 // 锚点链接(包括从旧插件迁移过来的、以及手写的 )。 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 '' . $inner . ''; }, $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; } /** * 把条目数组渲染成 '; 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'; /* 折叠用
:原生元素,不需要 JS 就能工作。 JS 被禁用 / 加载失败时,目录仍然可以展开收起 —— 比用自定义 div + class 切换更抗故障。 默认收起时不写 open 属性(details 的展开态只由 open 决定, 不认 CSS)——「默认隐藏」靠的就是这一点。 */ if ($toggle) { $html = '
' . '' . '' . esc_html($title) . '' . '' . $count . '' . '' . '' . '
'; } else { $html = '
' . '
' . '' . esc_html($title) . '' . '' . $count . '' . '
' . '' . '
'; } 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 '
' . $html . '
'; } /** * 把目录插进正文(纯函数) * * @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 拿字节偏移,插在**开标签之前**; 若改成"在

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 只负责切换。 * * '; } /** * 输出侧边目录(页面末尾) * * 只认「当前请求就是这篇文章」这个条件,不看正文里有没有标记 —— * 那个判断在过滤器里已经做过一次,这里再查一次原文是必须的: * 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);