Files
laoyang 76c69b31e8 正文标题自动编号,与目录编号共用同一份计算 #30
需求:目录里有编号、正文标题没有,读长文时要回头去目录找对应关系。
本版给 h1/h2/h3 加编号,样式为灰色小号(标题字号的 0.62 倍),
不抢标题视觉重心,与目录 .yang-toc-num 同风格。

编号规则(编号范围跟着后台 toc_levels 设置走,默认 1/2/3):
- 按「实际出现的最外层」归一,没有 h1 时 h2 直接是一级(1/2/3),
  不会编成 2/2.1。天然满足用户"h1 没有就是 h2 一级"的要求。
- 作者已手写编号的标题跳过并占位(1./1、/1)/1.2/一、/(一)/第一章
  等写法),不叠加成"3.1 1. xxx",但编号序列不错位。

实现要点:
- 抽出 yang_toc_pick / yang_toc_depths / yang_toc_number_map 三个纯函数,
  目录与正文共用同一份编号计算,杜绝两边漂移(用户痛点正是"两边不一致")。
- 插入按 id 回填:条目清单只来自 scan() 那一份,id 是唯一键。
- 用 preg_match_all + PREG_OFFSET_CAPTURE 拿字节偏移、从后往前拼接插入。
  早期写成 preg_replace_callback('#<h([1-6])([^>]*)>(.{0,120})#is'),
  那个 .{0,120} 贪婪跨过块边界,导致 5 个标题只有第 1 个拿到编号且不报错。
- 幂等用 strncmp(substr($html,$end), $needle, strlen($needle)),不手数字节
  (写成固定 26 而实际 25 会让幂等静默失效、编号插两层)。

CSS 修一个自己造的问题:编号把标题撑高 3px。
line-height:inherit 无效——同行高下小字号半行距更大,盒底反而更往下探
(h2 46→49)。改为 display:inline-block + line-height:1 成为原子行内盒,
实测 46→46、37→37。八组隔离试验数据见 _tooling/_diag362b.js。

验证(全部本地/线上实跑,非推断):
- 正向判据 43/0;反证 22/0(10 组注入均红在预期条目)
- 线上 4 篇真实文章 20/0(含"只有 h3"、"h1+h2"、"h2+h3"三种形态)
- 新旧两版目录 HTML 逐字节比对 7/0(4/4 篇相同,证明重构没改坏老行为)
- 几何判据 68/0,含两条反证:回到 inline+inherit ⇒ 高度差 3.00px;
  编号 display:block 掉行 ⇒ G3 差 14.88px
- 打包自检 59/0 + 4 组注入反证全红
2026-10-28 08:27:53 +08:00

1485 lines
60 KiB
PHP
Raw Permalink 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 有效)
/* ── 默认开合:两份**各自独立**(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',
/* 正文那份默认开合。只影响正文内嵌的 <details open>;
浮层走 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(可能含 <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;
}
/**
* 从扫描结果里筛出「要收录的标题」
*
* 抽成独立函数是为了让**目录**和**正文标题编号**用同一份筛选结果 ——
* 一旦两处各自写一遍 `isset($levels[...])`,将来改收录规则(比如
* 「h1 永远收」)必然只改一处,两边就对不上了:目录里有 8 条、
* 正文编号到 7 就走歪了。(本项目的 #432 就是「同名逻辑抄两份」的坑。)
*
* @param array $items 条目(含 level/text/id)
* @param array $levels array(1..6 => true)
* @return array 顺序保持原样,索引重排(0,1,2…)
*/
function yang_toc_pick($items, $levels)
{
$picked = array();
foreach ($items as $it) {
if (isset($levels[$it['level']])) {
$picked[] = $it;
}
}
return $picked;
}
/**
* 把每个条目的「层级」归一成**深度**(0 = 最外层)
*
* ── 为什么不用绝对 level 当层级 ───────────────────────────────
* 标题层级会有跳级和回退(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。
*
* ── 这个深度同时是「编号」的分层依据(工单 #30)──────────────
* 用户要的是「没有 h1 时 h2 就是一级编号」。绝对 level 做不到这点
* 只靠它自己:h2 的 level 是 2,若直接拿 level 当编号首段就是 "2"。
* 深度天然满足 —— 只有 h2 的文章,第一个 h2 深度 0 ⇒ 编号从 1 开始;
* 只有 h3 的文章(线上 #2182 就是),h3 深度 0 ⇒ 同样从 1 开始。
*
* @param array $picked
* @return array 与 $picked 同序的深度数组
*/
function yang_toc_depths($picked)
{
$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;
}
return $depths;
}
/**
* 算出每个条目的编号(1 / 1.1 / 1.1.1)
*
* 目录和正文标题**必须用同一份结果**:目录写 3.2,正文那个标题也必须是
* 3.2。所以这里做成纯函数,两处各自调用它、喂同一份 `$picked`,
* 而不是各写一遍计数循环。
*
* ── 为什么按**深度**计数,不按绝对 level ──────────────────────
* 按 level 计数在跳级时会算出重复编号:`h2 → h4 → h3` 三连,
* level 版本得到 "1" / "1.1" / "1.1"(两个 1.1 撞车),
* 深度版本得到 "1" / "1.1" / "1.2"(h4 与 h3 是兄弟,各自占一格)。
* 线上目前没有 h4,但 feed 里贴进来的文章随时会有,不能留个错算法等着。
*
* 深度是从 0 连续递增的,所以 `$counter` 的键就是 0,1,2…;
* 走到更浅的深度就把更深的计数全部清掉,回到上层后重新从 1 开始。
*
* @param array $picked
* @return array array(picked 的索引 => '1.1')
*/
function yang_toc_number_map($picked)
{
$nums = array();
if (empty($picked)) {
return $nums;
}
$depths = yang_toc_depths($picked);
$counter = array();
foreach ($picked as $i => $it) {
$d = $depths[$i];
$counter[$d] = isset($counter[$d]) ? $counter[$d] + 1 : 1;
// 更深的层级清零,回到上层后重新从 1 开始
foreach (array_keys($counter) as $k) {
if ($k > $d) {
unset($counter[$k]);
}
}
ksort($counter);
$nums[$i] = implode('.', $counter);
}
return $nums;
}
/**
* 标题文本里是否已经有作者手写的编号
*
* ── 为什么必须检测(线上实测出来的)─────────────────────────────
* #5240 那篇的 6 个 h3 是手写的「1. 自定义短语」「2. 候选词竖排显示」…
* 它的父级是第 3 个 h2,所以自动编号会给它们 3.1~3.6。
* 若不管不顾地加前缀,标题会变成「3.1 1. 自定义短语」—— 双层编号。
*
* 用户选的策略是「检测到就跳过该条」:**不加前缀,但仍占一个编号位**。
* 占位是刻意的 —— 目录里那一行写着 3.1,正文若不占位,后面的 3.2
* 会跟目录错开一格,越往下越歪。
*
* 识别范围(都是中文写作里真实会用的形态):
* 1. 标题 1、标题 1) 标题 1)标题
* 1.2 标题 1.2.3 标题
* 一、标题 (一)标题 (1) 标题
* 第一章 / 第一节 / 第一部分
*
* ⚠️ 只认「句首」的编号:`2026 年的计划` 这种以数字开头但其实是
* 正文内容的标题,末位必须是 `.`/`、`/`)` 或「第X章」才判为编号,
* 否则会误跳过。所以 `1.2 版本` 会被认(有 `.`),`2026 展望`
* 不会被认(数字后直接跟空格 + 汉字,没有分隔符)。
*
* @param string $text 标题的纯文本(已 strip_tags)
* @return bool
*/
function yang_toc_has_manual_number($text)
{
if (!is_string($text) || $text === '') {
return false;
}
// 全角空格也要吃掉,编辑器粘贴常见
$t = ltrim($text, " \t\n\r\0\x0B\xC2\xA0\xE3\x80\x80");
/* ① 「第X章/节/部分」中文数字或阿拉伯数字 */
if (preg_match('/^第\s*[0-9一二三四五六七八九十百千零〇两]+\s*[章节節部分篇]/u', $t)) {
return true;
}
/* ② 括号编号:(一) / (1) / (1) */
if (preg_match('/^[((]\s*[0-9一二三四五六七八九十百千零〇两]+\s*[))]/u', $t)) {
return true;
}
/* ③ 「一、」这种中文数字 + 顿号 */
if (preg_match('/^[一二三四五六七八九十百千零〇两]+\s*[、..]/u', $t)) {
return true;
}
/* ④ 阿拉伯数字 + 分隔符:1. / 1、 / 1) / 1)/ 1.2 / 1.2.3
* 分隔符是必需的,否则「2026 年展望」会被误判 */
if (preg_match('/^[0-9]+(?:\.[0-9]+)*\s*[..、,,))::]/u', $t)) {
return true;
}
return false;
}
/**
* 把编号插进正文标题(工单 #30)
*
* ── 为什么按 id 回填,而不是再扫一遍标题 ──────────────────────
* 目录里的编号和正文标题的编号必须**逐条相同**。若这里重新扫一遍标题、
* 重新计数,那就是两套算法:一旦某处筛选条件不同(比如手写编号跳过、
* 或者收录级别不一样),两边就会错开一格,越往下越歪,而且肉眼很难
* 发现——目录里 3.2 对应的标题正文写着 3.3。
*
* 所以:条目清单只来自 `yang_toc_scan()` 那一份,`id` 是唯一键,
* 拿 id 去正文里找对应开标签、把编号塞进去。任何一处不一致都不可能发生。
*
* ── 为什么编号是 <span> 而不是改标题文本 ──────────────────────
* ① 不动 `post_content`:编辑器、RSS、搜索结果、摘要里都不会冒出编号;
* ② 可以单独设样式(灰色小号),让编号不抢标题的视觉重心;
* ③ 作者手写编号时直接不插 span 即可,无需清洗原文。
*
* ── 手写编号:跳过但占位 ──────────────────────────────────────
* `$byId[$id] = null` 表示「这一条不加前缀」。注意它**仍然被算进编号序列**:
* 目录里写着 3.1,正文那条是作者自己写的「1. 自定义短语」,但它占了 3.1
* 这个位置,后面那条才是 3.2。不占位的话正文与目录会错开。
*
* ── 幂等 ──────────────────────────────────────────────────────
* `the_content` 可能被多个插件/相关文章各调一次。已经在标题开头有
* `yang-h-num` 的绝不再插一次,否则会出现「1.1 1.1 标题」。
*
* @param string $html 已补好 id 的正文
* @param array $items yang_toc_scan() 得到的条目
* @param array $levels array(1..6 => true),与目录用同一份
* @param bool $numbering 是否启用编号
* @return string
*/
function yang_toc_number_headings($html, $items, $levels, $numbering = true)
{
if (!$numbering || !is_string($html) || $html === '' || empty($items)) {
return $html;
}
$picked = yang_toc_pick($items, $levels);
if (empty($picked)) {
return $html;
}
$nums = yang_toc_number_map($picked);
/* id => 编号串 或 null(null = 作者已手写编号,跳过) */
$by_id = array();
foreach ($picked as $i => $it) {
if (!isset($it['id']) || $it['id'] === '') {
continue;
}
$plain = wp_strip_all_tags($it['text']);
$by_id[$it['id']] = yang_toc_has_manual_number($plain) ? null : $nums[$i];
}
if (empty($by_id)) {
return $html;
}
/* ── 定位所有标题开标签,按 id 匹配 ─────────────────────────
*
* 🔴 这里**绝不能**用 `preg_replace_callback('#<h([1-6])([^>]*)>(.{0,120})#s')`
* 那种"开标签 + 后面一小段"的写法。正则里的 `.{0,120}` 是贪婪的,
* 会一路吃过后面的 `<h2 ...>` 开标签;替换时把那截原样吐出来,
* 于是**第二个及以后的标题永远不会被处理** —— 实测就是这样:
* 5 个标题只有第 1 个拿到编号,其余全空,而且不报错。
* (这是 #422/#430 的同族:正则跨过了块边界。)
*
* 改成「先用 preg_match_all 拿到每个开标签的字节偏移 + id,再从后往前
* 拼接插入」:从后往前是为了让前面的偏移量保持有效(从前往后插会让
* 后面所有偏移失效,得反复重算)。 */
if (!preg_match_all('#<h([1-6])([^>]*)>#i', $html, $mm, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
return $html;
}
$inserts = array(); // array(偏移, 要插入的 span HTML)
foreach ($mm as $one) {
$attrs = $one[2][0];
$end = $one[0][1] + strlen($one[0][0]); // 开标签结束位置
if (!preg_match('/\bid\s*=\s*(["\'])(.*?)\1/is', $attrs, $im)) {
continue;
}
$id = $im[2];
if (!array_key_exists($id, $by_id) || $by_id[$id] === null) {
continue;
}
/* 幂等:开标签之后紧跟着的就是编号 span ⇒ 已经处理过,跳过。
不用 strpos 在整个文档里找 —— 那会因为同名 class 出现在别处误判。
🔴 这里用 strncmp 而不是 `substr(...) === $needle`:
前者不需要数长度,标签改了写法也不会因为"我数错了几字节"
而静默失效(实测把 25 写成 26 就整条判据失灵,
表现成"幂等没生效、编号被插了两次")。 */
$needle = '<span class="yang-h-num">';
if (strncmp(substr($html, $end), $needle, strlen($needle)) === 0) {
continue;
}
$inserts[] = array($end, '<span class="yang-h-num">' . esc_html($by_id[$id]) . '</span>');
}
if (empty($inserts)) {
return $html;
}
// 从后往前插,前面的偏移才不会失效
usort($inserts, function ($a, $b) {
return $b[0] - $a[0];
});
foreach ($inserts as $ins) {
$html = substr($html, 0, $ins[0]) . $ins[1] . substr($html, $ins[0]);
}
return $html;
}
/**
* 把条目数组渲染成 <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 = yang_toc_pick($items, $levels);
if (empty($picked)) {
// 文章里一个符合条件的标题都没有 —— 用户明确要求「没有就不显示」
return array('list' => '', 'picked' => 0);
}
// ── 序号(1 / 1.1 / 1.1.1)──
// 与正文标题编号共用同一个纯函数,保证两边逐条一致(工单 #30)
$nums = $numbering ? yang_toc_number_map($picked) : array();
// ── 生成嵌套结构 ──
// 深度归一化抽到 yang_toc_depths():编号也要用同一份深度
$depths = yang_toc_depths($picked);
$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)
{
$settings = $plan['settings'];
/* ── ⓪ 正文标题加编号(工单 #30)─────────────────────────────
*
* 放在最前面,且在三条分支之外 —— 因为它与「正文目录插在哪」无关:
* 手写标记、自动插入、只出浮动,三种情况正文标题都该有编号。
*
* 级别取**设置里的收录级别**(不是短代码属性):用户选的是
* 「跟着目录收录级别走」。手写 [TOC levels="2"] 只影响那一份目录的
* 条目多少,不该让正文标题的编号跟着变 —— 否则同一篇文章里
* 目录少了几条、正文编号却全在,反而更乱。
*
* 开关也取设置里的 `numbering`:目录有编号正文就有、目录关掉正文也关。
* 用户的原话就是「因为目录是有编号的,正文没有编号,给人的感觉很奇怪」
* —— 两件事本来就该是同一个开关,分成两个开关反而会被人调成不一致。
*/
$out = yang_toc_number_headings(
$out,
$items,
yang_toc_normalize_levels($settings['levels']),
!empty($settings['numbering'])
);
/* ── ① 手写标记:按作者写的位置渲染,且不再自动插第二份 ── */
if ($has_marker) {
/* 后台设置当默认值,标记里写了的属性覆盖它。
替换 [TOC] / [toc] / [TOC ...]。大小写都认 ——
用户不会记得"这里的标签必须大写",凭直觉写 [toc] 就该能用。 */
$base = yang_toc_atts_from_settings($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($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';
/* 浮层那份默认开合,**独立于**正文那份(3.2.59 起)。
展开态写在 class 上(is-open),所以没有 JS 也能看到默认状态;
JS 只负责之后的切换 —— 这个特性由 3.2.30 定下,拆分后必须保留。 */
$open = ($s['float_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);