W weiserv
← 返回博客

用 Markdown Treeprocessor 根治 Django 博客双 H1,并顺手修好代码块可读性

背景:两个看不见的渲染坑

我的个人技术站(Django 4.2 + 纯服务端渲染 SSR)文章内容用 Markdown 写,服务器端 markdown.markdown() 渲染成 HTML 再塞进模板。跑了一段时间后发现两个"看不见但很要命"的问题:

  1. 双 H1(duplicate H1)。页面框架(base 模板)已经把文章标题渲染成一个 <h1>;而正文 Markdown 里我习惯用 # 一级标题 起头,渲染后又是 <h1>。于是每篇文章详情页出现两个 H1。这对 SEO(搜索引擎把 H1 当作页面主题信号)和可访问性(屏幕阅读器依赖标题层级导航)都是扣分项。
  2. 白天模式代码块看不清。博客详情页用 Tailwind Typography(prose)排版,代码块在深色模式正常,但在白天模式下整块背景是深色、代码文字却是浅色,几乎看不清。

这两个问题都不报错、不崩溃,只是"看起来勉强能用",但偏偏是技术站该做对的基础。下面分别讲根因和修法。

一、根治双 H1:为什么正则 / JS 都不靠谱

最直觉的解法有三种,但都站不住:

坑 1:正则把正文 # 替换成 ##

很多人第一反应是"渲染前用正则把正文里的 # 换成 ##"。但 Markdown 正文的 # 不只出现在标题——代码块(fenced code)里到处是 ##!/usr/bin/env python# 这是注释。正则无差别替换会把这些也改掉,破坏代码内容。除非你先解析出代码块再替换,那又回到"自己写个 mini 解析器"的死路。

坑 2:前端 JS 降级 DOM 标题

另一个思路:渲染照旧出双 H1,再用 JS 把正文里的 h1 改成 h2。问题有二:
- 对 SEO 无效。搜索引擎爬虫拿到的 HTML 仍是双 H1(JS 在客户端才执行),而你要优化的恰恰就是"爬虫看到的源码"。
- 有闪烁:首屏先显示错误层级,JS 跑完才改,体验差。

坑 3:指望 Markdown 配置项

markdown 库有 metaattr_list 等扩展,但没有"整篇标题降一级"的现成开关,靠配置解决不了。

二、正解:在元素树阶段用 Treeprocessor 降级

Markdown 渲染管线大致是:块解析 → 内联解析 → 树处理(treeprocessors) → 序列化成 HTML。treeprocessors 阶段,文档已经是一棵 XML 元素树(<h1><p><pre> 都是节点),这正是做"标题降级"的最佳位置。

关键点:代码块在树里是 <pre><code> 节点,里面的 # 注释code 的文本子节点,根本不是 heading 元素。所以我们遍历树、只改写真正的 <h1>~<h6> 标签,代码块里的文本永远不会被碰到——天然不误伤。

实现就一个 Treeprocessor 子类:

import markdown
from markdown.extensions import Extension
from markdown.treeprocessors import Treeprocessor


class HeadingShift(Treeprocessor):
    """标题降一级(h1->h2 ... h5->h6),让文章页只保留一个 <h1>(文章标题)。
    在元素树层面操作,因此代码块 <pre><code> 文本节点里的 '#' 不受影响。
    """

    def run(self, root):
        for elem in root.iter():
            if elem.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
                level = int(elem.tag[1])
                new_level = min(level + 1, 6)  # 防止 h6 溢出成不存在的 h7
                elem.tag = f"h{new_level}"
        return root


class HeadingShiftExtension(Extension):
    def extendMarkdown(self, md):
        # priority 15:在 toc 等树处理器之后运行,确保拿到最终标题
        md.treeprocessors.register(HeadingShift(md), "heading_shift", 15)


# 注册进扩展列表(fenced_code 负责解析代码块,tables/nl2br/toc 按需)
MD_EXTENSIONS = ["fenced_code", "tables", "nl2br", "toc", HeadingShiftExtension()]


def render_md(text):
    return markdown.markdown(text or "", extensions=MD_EXTENSIONS)

几处值得说:

  • root.iter()xml.etree.ElementTree 的深度优先遍历,覆盖全树,包括嵌套在 blockquoteli 里的标题。
  • min(level + 1, 6) 保证 ###### h6 不会涨成不存在的 h7,而是停在 h6
  • 优先级 15 是我特意选的:Markdown 的 toc 扩展也是 treeprocessor,要等它处理完标题再降级,否则目录锚点会和实际层级错位。数字越大越晚执行,15 在默认 toc 之后。
  • 因为只改 .tag 字符串(节点类型),不重建节点,属性、嵌套结构全部保留,零副作用。

渲染后,正文里 # 一级标题 变成 <h2>##<h3>……页面框架的 <h1>(文章标题)保持唯一。双 H1 当场消失。

三、顺手修:白天模式代码块"黑底浅字"

双 H1 解决后,顺带把代码块可读性也修了。根因在 Tailwind Typography 的 prose

  • 我用的类是 prose prose-slate(白天)/dark:prose-invert(黑夜)。
  • prose-slate<pre> 默认一个深色背景(slate-800/900),给行内 code 浅色背景。
  • 之前我只给 prose-code 设了浅色背景(bg-slate-100),但没覆盖 <pre> 的背景——于是白天模式下 <pre> 仍是深色底、里面代码文字是浅色,整块看不清。

修法:把"块级代码 <pre>"和"行内代码 code"拆成两套样式,让 <pre> 内的 code 背景透明、跟随 <pre> 自身配色:

/* 行内 code:浅底深字,白天/黑夜都清晰 */
.prose-tech :not(pre) > code {
  @apply rounded bg-slate-100 px-1 py-0.5 font-mono text-sm text-slate-800 dark:bg-slate-800 dark:text-slate-200;
}
/* 块级 pre:白天浅底深字,黑夜深底浅字 */
.prose-tech pre {
  @apply overflow-x-auto rounded-xl bg-slate-100 p-4 text-sm text-slate-800 shadow-inner;
}
.dark .prose-tech pre {
  @apply bg-slate-900 text-slate-200;
}
/* pre 内的 code 背景透明,跟随 pre,避免叠色 */
.prose-tech pre code {
  @apply bg-transparent p-0 text-inherit;
}

注意 :not(pre) > code 这个选择器——它精确命中"不在 <pre> 里的 code"(即行内代码),避免和块级代码抢样式。改完 tailwind/input.css 后必须重新构建 CSS,并给模板里 CSS 链接加版本号(如 ?v=3)破坏浏览器缓存,否则用户看到的还是旧样式。

四、验证:以线上真实响应为准

改完部署,验证不是 grep 服务器文件,而是看线上真实 HTML

# 文章详情页 <h1> 应只剩 1 个(文章标题),不再有双 H1
curl -s https://weiserv.com/blog/<slug>/ | grep -o "<h1" | wc -l
# 正文最高级标题应为 <h2>(原本双 H1 时的正文 #)
curl -s https://weiserv.com/blog/<slug>/ | grep -o "<h2" | head -1

实测 weiserv.com 文章页 h1 命中数 = 1,正文最高级为 <h2>,双 H1 已根治;白天模式代码块背景转为浅灰、文字深灰,清晰可读。

小结:两个原则

  1. 结构性问题在"数据层"解决,别在"表现层"打补丁。双 H1 是 HTML 结构问题,正则(文本层)和 JS(表现层)都救不了;降到 Markdown 渲染的"元素树层"用 Treeprocessor 处理,才彻底且不误伤代码块。
  2. CSS 框架的默认假设要显式覆盖prosepre/code 的默认配色是基于"暗色代码块"的,你的浅色主题必须显式拆开 precode 两套规则,否则就会出"黑底浅字"的诡异组合。

这两处优化都已上线 weiserv.com,欢迎用浏览器开发者工具检视源码验证。

广告位占位 · post-inline