,在 the_content * **之外**,根本进不到这里被扫描。能被扫到的 h1 只可能是作者在正文里 * 自己写的一级章节标题。 * * 一旦正文用 h1 划分章节(很常见:从 Markdown 或别的编辑器粘过来, * 或原本的 ## 被降级),默认 "2,3" 会把这些一级章节整段丢掉, * 于是二级标题集体失去父级、目录变成平铺 —— 层级信息全错。 * 所以默认跟 h2/h3 一起收 h1,最多到 h3(h4 以下通常是细节,不进目录)。 * * ── 为什么在 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](小工具、页面构建器),短代码那条路也能用,且复用 * 同一个渲染函数,不会出现两套行为。 * * @author Yongquan Yang * @license MIT License */ if (!defined('ABSPATH')) { exit; } /** * 把标题文本变成锚点 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; } /** * 把条目数组渲染成目录 HTML * * @param array $items 条目(含 level/text/id) * @param array $atts 解析后的属性 * @return string 空串表示不该输出(没有可用标题) */ function yang_toc_render($items, $atts = array()) { if (empty($items)) { return ''; } // ── 参数归一 ── $title = isset($atts['title']) && trim($atts['title']) !== '' ? trim($atts['title']) : __('目录', 'kratos'); // levels:默认 1,2,3。文章标题本身在 the_content 之外,不会被扫到, // 所以正文里的 h1 是作者写的一级章节标题,必须收 —— 详见文件头注释。 $levels = array(); $raw_levels = isset($atts['levels']) ? (string) $atts['levels'] : '1,2,3'; foreach (preg_split('/[\s,,、]+/u', $raw_levels) 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); } $numbering = isset($atts['numbering']) && (string) $atts['numbering'] === '1'; $toggle = !isset($atts['toggle']) || (string) $atts['toggle'] === '1'; // ── 过滤出要收录的标题 ── $picked = array(); foreach ($items as $it) { if (isset($levels[$it['level']])) { $picked[] = $it; } } if (empty($picked)) { // 文章里一个符合条件的标题都没有 —— 用户明确要求「没有就不显示」 return ''; } // ── 序号(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(); $base_found = false; foreach ($counter as $k => $v) { $parts[] = $v; } $nums[$i] = implode('.', $parts); } } // ── 生成嵌套结构 ── /* 这里不用「绝对层级」直接建嵌套,而是先把每个条目归一成**深度**。 原因:标题层级会有跳级和回退(h2 → h4 → h3),按绝对 level 建栈, 回退时到底该关几次 极难算对 —— 第一版就是这么错的, 「回退 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; $max_depth = 0; foreach ($picked as $i => $it) { $lv = $it['level']; $d = $depths[$i]; if ($prev_depth < 0) { // 第一个条目 $o[] = ''; } $o[] = ''; } $list = implode('', $o); /* 折叠用
:原生元素,不需要 JS 就能工作。 JS 被禁用 / 加载失败时,目录仍然可以展开收起 —— 比用自定义 div + class 切换更抗故障。 */ if ($toggle) { $html = '
' . '' . '' . esc_html($title) . '' . '' . count($picked) . '' . '' . '' . '
'; } else { $html = '
' . '
' . '' . esc_html($title) . '' . '' . count($picked) . '' . '
' . '' . '
'; } return $html; } /** * the_content 主处理 * * 顺序很重要:先给标题补 id(扫描出条目),再替换 [TOC]。 * 反过来的话,[TOC] 生成时拿不到标题清单。 */ function yang_toc_filter_content($content) { if (is_admin() || !is_string($content) || $content === '') { return $content; } if (stripos($content, '[toc') === false) { // 没有 [TOC]:什么都不做。 // 不动标题 id 是刻意的 —— 不写 [TOC] 的文章不该被塞进一堆 id, // 那会改变页面上已有的选择器和锚点行为。 return $content; } $scan = yang_toc_scan($content); $items = $scan['items']; /* 替换 [TOC] / [toc] / [TOC ...]。大小写都认 —— 用户不会记得"这里的标签必须大写",凭直觉写 [toc] 就该能用。 */ $out = preg_replace_callback( '/\[toc(\s[^\]]*)?\]/i', function ($m) use ($items) { $atts = yang_toc_parse_atts(isset($m[1]) ? $m[1] : ''); return yang_toc_render($items, $atts); }, $scan['content'] ); return is_string($out) ? $out : $content; } add_filter('the_content', 'yang_toc_filter_content', 9); /** * 短代码兜底 * * 正常路径下 [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); return yang_toc_render($scan['items'], is_array($atts) ? $atts : array()); } add_shortcode('toc', 'yang_toc_shortcode'); add_shortcode('TOC', 'yang_toc_shortcode'); /** * 需要时才加载样式 * * 每篇文章都加一份 TOC 样式是浪费;只有正文里真的写了 [TOC] 才加载。 * 依赖 'kratos' 保证排在主题主样式之后,不会被覆盖。 */ function yang_toc_assets() { if (is_admin() || !is_singular()) { return; } $post = get_post(); if (!$post || !isset($post->post_content)) { return; } if (!has_shortcode($post->post_content, 'toc') && !has_shortcode($post->post_content, 'TOC')) { 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);