用 Markdown Treeprocessor 根治 Django 博客双 H1,并顺手修好代码块可读性
背景:两个看不见的渲染坑
我的个人技术站(Django 4.2 + 纯服务端渲染 SSR)文章内容用 Markdown 写,服务器端 markdown.markdown() 渲染成 HTML 再塞进模板。跑了一段时间后发现两个"看不见但很要命"的问题:
- 双 H1(duplicate H1)。页面框架(base 模板)已经把文章标题渲染成一个
<h1>;而正文 Markdown 里我习惯用# 一级标题起头,渲染后又是<h1>。于是每篇文章详情页出现两个 H1。这对 SEO(搜索引擎把 H1 当作页面主题信号)和可访问性(屏幕阅读器依赖标题层级导航)都是扣分项。 - 白天模式代码块看不清。博客详情页用 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 库有 meta、attr_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的深度优先遍历,覆盖全树,包括嵌套在blockquote、li里的标题。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 已根治;白天模式代码块背景转为浅灰、文字深灰,清晰可读。
小结:两个原则
- 结构性问题在"数据层"解决,别在"表现层"打补丁。双 H1 是 HTML 结构问题,正则(文本层)和 JS(表现层)都救不了;降到 Markdown 渲染的"元素树层"用 Treeprocessor 处理,才彻底且不误伤代码块。
- CSS 框架的默认假设要显式覆盖。
prose对pre/code的默认配色是基于"暗色代码块"的,你的浅色主题必须显式拆开pre与code两套规则,否则就会出"黑底浅字"的诡异组合。
这两处优化都已上线 weiserv.com,欢迎用浏览器开发者工具检视源码验证。