正文标题自动编号,与目录编号共用同一份计算 #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 组注入反证全红
This commit is contained in:
@@ -221,6 +221,88 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 正文标题的自动编号(3.2.62 / 工单 #30)
|
||||
*
|
||||
* 结构由 PHP 插入:<h2 id="x"><span class="yang-h-num">1.1</span>标题</h2>
|
||||
* 编号是**独立 span**,不是改标题文本 —— 所以不进编辑器、不进 RSS、
|
||||
* 不进搜索结果、不进摘要。作者手写编号时压根不插这个 span。
|
||||
*
|
||||
* ── 为什么用灰色小号(用户选定的方案)──────────────────────────
|
||||
* 标题的核心是**文字本身**,编号只是索引辅助。若与标题同色同粗,
|
||||
* 视觉上"1.1"会和标题正文抢注意力,长文章里满屏都是粗体数字。
|
||||
* 灰色 + 小一号让编号退到背景,眼睛扫标题时能忽略它、需要定位时
|
||||
* 又能看见 —— 这也与目录里 `.yang-toc-num` 的处理一致(同为灰色小字),
|
||||
* 正文和目录的编号看起来是同一套东西。
|
||||
*
|
||||
* ── 字号为什么要跟标题走而不是写死 ─────────────────────────────
|
||||
* 编号要跟着标题一起缩放才有"附属于标题"的感觉。这里用 em:
|
||||
* 0.62em 在 h2(24px)上是 14.9px、h3(20px)上 12.4px,
|
||||
* 正好落在 12~15px 这个"能看清但不抢眼"的区间。
|
||||
* ⚠️ 若写成固定 px,h2 的编号会显得太小、h3 的又偏大。
|
||||
*
|
||||
* ── font-weight / font-style 必须显式重置 ────────────────────
|
||||
* span 会继承标题的 font-weight(700/600)和可能的斜体。
|
||||
* 不重置的话"灰色但加粗"仍然很重,弱化就白做了。
|
||||
*
|
||||
* ── margin-right 用 0.35em 而不是固定 px ────────────────────
|
||||
* 同样是为了跟标题字号联动;h2 的 24px ⇒ 8.4px,h3 的 20px ⇒ 7px,
|
||||
* 在两级上都刚好是"一个空格多一点"的呼吸感。
|
||||
*
|
||||
* ── 🔴 为什么必须 `display: inline-block`(这是本规则最不直观的一处)──
|
||||
* 小字号的行内元素会把行盒**撑高**,实测 h2 46 → **49px**、h3 37 → **40px**,
|
||||
* 每级都多 3px。3px 不起眼,但它出现在**每一个**标题上:长文 10 个二级标题
|
||||
* 就多出 30px,h2 的灰底跟着变高,整篇标题的节奏全变;更麻烦的是它**只在
|
||||
* 该标题有编号时出现**,于是「有编号的文章」和「没编号的文章」行距不一致,
|
||||
* 极难归因。
|
||||
*
|
||||
* ⚠️ 我先试过 `line-height: inherit`(以及写死 46px)—— **完全无效**,
|
||||
* 仍 49px。原因值得记下来,因为直觉是反的:
|
||||
*
|
||||
* 同行高下,行内盒的上下边界由「半行距」决定,而半行距
|
||||
* 把 content-area **居中**塞进 line-height:
|
||||
*
|
||||
* 盒底 = baseline + desc×fs + (L − fs) / 2
|
||||
* h2 (fs=24, L=46): 0.25×24 + (46−24)/2 = 17.00
|
||||
* span(fs=14.88,L=46): 0.25×14.88+ (46−14.88)/2 = 19.28 ← 多探 2.28
|
||||
*
|
||||
* **字号越小,半行距越大**,盒底反而越往下走。所以「让两者共用行高」
|
||||
* 不是解药,恰恰就是病根 —— line-height 怎么调都救不回来。
|
||||
*
|
||||
* ⚠️ `vertical-align` 也试过:`baseline` 仍 49px;`middle` 48.52px。
|
||||
*
|
||||
* ✅ 真解:把编号变成**原子行内盒**(inline-block),它就只按自身内容
|
||||
* 高度参与行盒,不再受半行距拉扯。配 `line-height: 1` 让盒高正好
|
||||
* 等于字号(14.88px),基线落在 asc 处 —— 上下都远在 strut 之内,
|
||||
* 实测 h2 46→**46**、h3 37→**37**,一分不多。
|
||||
* (八组隔离试验的完整数据见 `_tooling/yang_theme_test/_diag362b.js`。)
|
||||
*
|
||||
* 副作用检查:inline-block 不会跨行断开 ⇒ 编号永远留在标题第一行开头,
|
||||
* 正是想要的;`white-space: nowrap` 随之只作用于这个原子盒,不会影响标题文字。
|
||||
*
|
||||
* ⚠️ 归档页 `h3.ar-year` 同样不会带编号 —— 编号是在 the_content
|
||||
* 过滤器里按「目录条目」插的,而归档短代码的年份标题不在
|
||||
* yang_toc_scan() 的结果里(它们是 [Archives] 渲染时才产生的),
|
||||
* 所以天然不受影响。评论区 h3 同理(不在 .content 内)。 */
|
||||
.k-main .details .article .content .yang-h-num {
|
||||
/* 🔴 原子行内盒:不参与行盒的半行距计算,标题高度才不会被撑高(见上) */
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
margin-right: 0.35em;
|
||||
color: #a8abb2;
|
||||
font-size: 0.62em;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
/* 数字等宽,1.1 和 3.10 宽度一致,多行标题才不会看着参差 */
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* 编号自身不换行(inline-block 本来也不拆,写上是为了并列时也不拆) */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 暗色站点:跟目录序号同一个灰,否则 #a8abb2 在深色底上过亮 */
|
||||
html.yang-dark .k-main .details .article .content .yang-h-num {
|
||||
color: #767d88;
|
||||
}
|
||||
|
||||
/* 列表缩进与行距 */
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
|
||||
+313
-56
@@ -647,72 +647,52 @@ function yang_toc_parse_atts($raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把条目数组渲染成 <ul class="yang-toc-list">(只出列表,不含外壳)
|
||||
* 从扫描结果里筛出「要收录的标题」
|
||||
*
|
||||
* 侧栏只要列表,内嵌要完整盒子。拆成两个函数是为了让两边共用同一份
|
||||
* 嵌套/序号算法 —— 抄一份出来必然会漂移(改了一边忘了另一边)。
|
||||
* 抽成独立函数是为了让**目录**和**正文标题编号**用同一份筛选结果 ——
|
||||
* 一旦两处各自写一遍 `isset($levels[...])`,将来改收录规则(比如
|
||||
* 「h1 永远收」)必然只改一处,两边就对不上了:目录里有 8 条、
|
||||
* 正文编号到 7 就走歪了。(本项目的 #432 就是「同名逻辑抄两份」的坑。)
|
||||
*
|
||||
* @param array $items 条目(含 level/text/id)
|
||||
* @param array $atts 解析后的属性
|
||||
* @return array array('list' => HTML, 'picked' => 收录的条目数)
|
||||
* @param array $levels array(1..6 => true)
|
||||
* @return array 顺序保持原样,索引重排(0,1,2…)
|
||||
*/
|
||||
function yang_toc_build_list($items, $atts = array())
|
||||
function yang_toc_pick($items, $levels)
|
||||
{
|
||||
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);
|
||||
return $picked;
|
||||
}
|
||||
|
||||
// ── 序号(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 就只是「深度变大开一层、变小收几层」,
|
||||
关闭次数由深度差直接决定,不需要再猜。 */
|
||||
/**
|
||||
* 把每个条目的「层级」归一成**深度**(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) {
|
||||
@@ -735,6 +715,260 @@ function yang_toc_build_list($items, $atts = array())
|
||||
}
|
||||
$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;
|
||||
@@ -981,12 +1215,35 @@ add_filter('the_content', 'yang_toc_filter_content', 9);
|
||||
*/
|
||||
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($plan['settings']);
|
||||
$base = yang_toc_atts_from_settings($settings);
|
||||
$replaced = preg_replace_callback(
|
||||
'/\[toc(\s[^\]]*)?\]/i',
|
||||
function ($m) use ($items, $base) {
|
||||
@@ -998,13 +1255,13 @@ function yang_toc_apply($content, $out, $items, $has_marker, $plan)
|
||||
return is_string($replaced) ? $replaced : $content;
|
||||
}
|
||||
|
||||
/* ── ② 只出浮动:正文只补 id,目录由 wp_footer 输出 ── */
|
||||
/* ── ② 只出浮动:正文只补 id(和编号),目录由 wp_footer 输出 ── */
|
||||
if ($plan['inline_at'] === null) {
|
||||
return $out;
|
||||
}
|
||||
|
||||
/* ── ③ 正文里插一份 ── */
|
||||
$html = yang_toc_render($items, yang_toc_atts_from_settings($plan['settings']));
|
||||
$html = yang_toc_render($items, yang_toc_atts_from_settings($settings));
|
||||
if ($html === '') {
|
||||
// 一个符合层级的标题都没有 —— 不输出空壳
|
||||
return $out;
|
||||
|
||||
Reference in New Issue
Block a user