如何用DOMDocument强制WordPress标题标签按顺序排列?
修复WordPress内容中的标题层级顺序问题
我们在定制开发WordPress主题时,收到了关于标题层级顺序的反馈——Google Lighthouse等自动化工具都指出,为了让屏幕阅读器等无障碍工具正确识别页面结构,标题层级不能跳过(比如h1之后直接用h4)。
问题根源是客户的内容输入习惯:他们优先选视觉好看的标题样式,而非按规范使用标题层级,导致页面出现h1→h4→h2这类混乱的结构。我们试过让客户规范输入,但对他们来说门槛太高,就像要求他们给图片加alt文本一样难。
解决方案思路
我们打算写一个the_content过滤器,解析内容里的所有标题标签,把它们替换成顺序合理的层级,同时保留原有的样式类。虽然程序没法完全准确判断用户预期的标题结构,但这是目前可行的折中方案(有更好思路欢迎交流)。
示例
用户输入的原始代码:
<h2 class="title--h2">This is a second level heading</h2> <p>Etiam vitae erat ullamcorper ipsum ultrices convallis ac quis nulla. Nam euismod imperdiet enim eu venenatis. Nulla non bibendum dui. Maecenas id tincidunt orci. Sed pellentesque ipsum et tempor convallis. Etiam elementum augue aliquet enim venenatis tincidunt. Praesent nunc dolor, vulputate nec aliquet consectetur, aliquet nec elit. Vivamus non eros nec nibh vestibulum lacinia. Morbi diam turpis, accumsan ac fringilla eget, fringilla vitae lorem. Ut consequat tortor orci, sed lobortis metus facilisis nec. Nulla sed enim in tortor blandit aliquet. Curabitur a finibus mi.</p> <h4 class="title--h4">This is a fourth level heading</h4> <p>Nullam blandit, mauris vel vestibulum aliquet, quam lectus laoreet mi, id euismod ligula augue sit amet velit. Suspendisse suscipit lacus quis mauris varius, sed cursus mi auctor. Nullam non augue in ante malesuada blandit. Nam eu purus commodo, porttitor odio commodo, tristique nunc. Suspendisse vitae vehicula turpis. Aenean turpis nibh, auctor ac mollis congue, iaculis id tortor. Morbi in est erat. Proin aliquam varius neque a sollicitudin. Vestibulum varius in urna sit amet hendrerit.</p> <h4 class="title--h4">This is a fourth level heading</h4> <p>Donec vitae est sapien. Nulla facilisi. Quisque sed auctor ante, sed viverra elit. Quisque justo arcu, vulputate tempor odio ac, mollis blandit justo. Morbi viverra tincidunt leo vel mattis. Aliquam erat volutpat. Nunc tortor tellus, porta sit amet tellus sed, interdum condimentum ex. </p>
处理后的输出:
<h2 class="title--h2">This is a second level heading</h2> <p>Etiam vitae erat ullamcorper ipsum ultrices convallis ac quis nulla. Nam euismod imperdiet enim eu venenatis. Nulla non bibendum dui. Maecenas id tincidunt orci. Sed pellentesque ipsum et tempor convallis. Etiam elementum augue aliquet enim venenatis tincidunt. Praesent nunc dolor, vulputate nec aliquet consectetur, aliquet nec elit. Vivamus non eros nec nibh vestibulum lacinia. Morbi diam turpis, accumsan ac fringilla eget, fringilla vitae lorem. Ut consequat tortor orci, sed lobortis metus facilisis nec. Nulla sed enim in tortor blandit aliquet. Curabitur a finibus mi.</p> <h3 class="title--h4">This is a fourth level heading</h3> <p>Nullam blandit, mauris vel vestibulum aliquet, quam lectus laoreet mi, id euismod ligula augue sit amet velit. Suspendisse suscipit lacus quis mauris varius, sed cursus mi auctor. Nullam non augue in ante malesuada blandit. Nam eu purus commodo, porttitor odio commodo, tristique nunc. Suspendisse vitae vehicula turpis. Aenean turpis nibh, auctor ac mollis congue, iaculis id tortor. Morbi in est erat. Proin aliquam varius neque a sollicitudin. Vestibulum varius in urna sit amet hendrerit.</p> <h4 class="title--h4">This is a fourth level heading</h4> <p>Donec vitae est sapien. Nulla facilisi. Quisque sed auctor ante, sed viverra elit. Quisque justo arcu, vulputate tempor odio ac, mollis blandit justo. Morbi viverra tincidunt leo vel mattis. Aliquam erat volutpat. Nunc tortor tellus, porta sit amet tellus sed, interdum condimentum ex. </p>
当前代码遇到的问题
我写的过滤器已经能跟踪上一个标题层级并计算目标层级,但还有两个核心问题:
- 使用
$node->replaceChild()修改DOM时,遍历过程中DOM结构变化会导致部分元素被跳过; - 无法自动保留标题的所有属性,手动复制属性容易遗漏CMS环境下的自定义属性。
以下是我当前的代码:
/** * Ensure heading levels are always in sequence * * @param string $content * @return string */ function namespace_fix_title_sequence(string $content): string { if (! (is_admin() && ! wp_doing_ajax()) && $content) { $DOM = new DOMDocument(); /** * Use internal errors to get around HTML5 warnings */ libxml_use_internal_errors(true); /** * Load in the content, with proper encoding and an `<html>` wrapper required for parsing */ $DOM->loadHTML("<?xml encoding='utf-8' ?><html>{$content}</html>", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); /** * Clear errors to get around HTML5 warnings */ libxml_clear_errors(); /** * Use XPath to query headings */ $XPath = new DOMXPath($DOM); $headings = $XPath->query("//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]"); /** * Track previous heading level */ $previous_level = 1; foreach ($headings as $heading) { /** * Get the current level */ $current_level = intval(preg_replace("/^h/", "", $heading->nodeName)); /** * Determine the target level */ $target_level = ($current_level - $previous_level <= 1 ? $current_level : $previous_level + 1); /** * DEBUG */ echo "<p>Previous: {$previous_level}</p>"; echo "<p>Current: {$current_level}</p>"; echo "<p>Target: {$target_level}</p>"; echo "<hr />"; /** * Replace current level with target level */ // ? /** * Update the previous level */ $previous_level = $target_level; } /** * Save changes, remove unneeded tags */ $content = implode(array_map([$DOM->documentElement->ownerDocument, "saveHTML"], iterator_to_array($DOM->documentElement->childNodes))); } return $content; } add_filter("the_content", "namespace_fix_title_sequence", 100, 1);
修正后的完整代码
解决上述问题的关键:
- 把DOM节点集合转为数组再遍历,避免遍历中修改DOM导致元素丢失;
- 遍历原标题的所有属性,复制到新标题节点上;
- 创建新的标题节点,替换旧节点。
/** * Ensure heading levels are always in sequence * * @param string $content * @return string */ function namespace_fix_title_sequence(string $content): string { if (! (is_admin() && ! wp_doing_ajax()) && $content) { $DOM = new DOMDocument(); // 忽略HTML5解析警告 libxml_use_internal_errors(true); $DOM->loadHTML("<?xml encoding='utf-8' ?><html>{$content}</html>", LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); libxml_clear_errors(); $XPath = new DOMXPath($DOM); $headings = $XPath->query("//*[self::h1 or self::h2 or self::h3 or self::h4 or self::h5 or self::h6]"); // 把节点集合转为数组,避免遍历中修改DOM导致元素跳过 $headingsArray = iterator_to_array($headings); $previous_level = 1; foreach ($headingsArray as $heading) { $current_level = intval(preg_replace("/^h/", "", $heading->nodeName)); // 计算目标层级:最多比上一级高1级 $target_level = ($current_level - $previous_level <= 1) ? $current_level : $previous_level + 1; // 确保层级不超过6 $target_level = min($target_level, 6); if ($target_level != $current_level) { // 创建新的标题节点 $newHeading = $DOM->createElement("h{$target_level}"); // 复制原标题的所有子节点(内容) foreach ($heading->childNodes as $child) { $newHeading->appendChild($child->cloneNode(true)); } // 复制原标题的所有属性 foreach ($heading->attributes as $attr) { $newHeading->setAttribute($attr->name, $attr->value); } // 替换旧节点 $heading->parentNode->replaceChild($newHeading, $heading); } $previous_level = $target_level; } // 提取处理后的内容 $content = ""; foreach ($DOM->documentElement->childNodes as $node) { $content .= $DOM->saveHTML($node); } } return $content; } add_filter("the_content", "namespace_fix_title_sequence", 100, 1);
关键说明
- 避免遍历跳过元素:将
$headings转为数组$headingsArray,这样遍历的是静态副本,不会因为DOM结构变化影响遍历; - 完整保留属性:通过遍历
$heading->attributes,把原标题的所有属性(包括自定义属性)复制到新标题节点; - 层级边界控制:添加
min($target_level, 6)确保标题层级不会超过h6; - 内容复制:通过
cloneNode(true)复制原标题的所有子节点,保证标题内容完全保留。
内容的提问来源于stack exchange,提问作者JacobTheDev
相关产品推荐
相关产品推荐

