使用Django6.1开发博客(4) - Markdown与代码高亮
上一篇文章的详情页能打开,但正文只是几行普通段落。技术博客离不开 fenced code、表格和标题层级,纯文本很快就会不够用。我接着把 Markdown 渲染接进详情页,并让代码块得到 Pygments 高亮。

整条链路很短。作者在后台保存 Markdown,数据库只保存原始文本;详情页请求到达时,模板过滤器把它渲染成 HTML;CodeHilite 给代码块加上类名;页面加载预生成的 Pygments CSS,浏览器完成着色。
先决定在哪里渲染
Markdown 有两种常见做法。一种在保存前渲染,把 HTML 存进另一列;另一种每次展示时渲染。这个阶段我选择后者。
保存前渲染能让读取路径更快,但会带来两份真相。原始 Markdown 和 HTML 必须一起迁移,Markdown 库升级、扩展调整、样式策略变化时,历史文章都要重新处理。展示时渲染虽然每次多算一点,但数据库里的 body 始终只有一个来源。
博客的文章量还小,读路径的这点开销可以接受。以后访问量上来了,先加缓存,或者在保存后异步刷新渲染结果;这时还不用急着改数据模型。
我还把「渲染位置」和「信任边界」分开想了一遍。展示时渲染只是一个技术选择,真正决定能不能安全使用 mark_safe 的是输入来源。现在 Post.body 的写入入口只有 Django Admin,作者必须先通过认证和权限检查。也就是说,Markdown 语法本身不会自动可信;能进入后台的人才构成信任边界。
这个前提一旦变化,方案也要跟着变化。开放注册作者时,至少要限制 HTML 标签;允许评论里写代码时,展示器必须默认转义;从外部站点导入文章时,还要清理脚本和事件属性。到那时,渲染函数可以继续复用,安全策略要按来源分开。
安装两个包。
| |
本阶段锁定到的版本如下。
| |
Markdown 负责把文本转成 HTML,Pygments 负责识别代码语言并输出高亮类名。两者都是这个领域的成熟库,这里不需要自己写解析器。
写一个模板过滤器
Django 模板默认会转义变量,这是安全底线。{{ post.body }} 里的 <h2> 会显示成文本,不会变成标题。要渲染 Markdown,就必须显式告诉模板输出结果可以当作 HTML。
在 blog/templatetags/blog_extras.py 里建一个过滤器。
| |
Django 要求应用下有 templatetags 包,模板里加载模块名。
| |
详情页把原来的 linebreaks 换掉。
| |
mark_safe 是这个过滤器的边界。它表示这里输出的 HTML 已经来自受控内容。当前 body 只由登录作者在 Django Admin 里编辑,输入源是可信的。等以后开放评论或允许匿名投稿,就不能直接沿用这个判断,必须按输入来源单独处理。
三个扩展已经覆盖当前需求。fenced_code 处理三反引号代码块,tables 处理表格,codehilite 把代码块交给 Pygments。没有提前加目录、脚注、数学公式这些扩展,教程还没写到那些需求。
扩展名必须放在列表里,CodeHilite 还需要单独的 extension_configs。第一次写这段代码时,我很自然地把 codehilite 拼错成 codehighlight,页面不报错,只是代码块退化成普通 pre。Markdown 不会猜你想用哪个扩展,拼错就等于没有启用。
配置字典也容易写错位置。pygments_style 属于 codehilite 的配置,不能放在顶层。下面这段是最小可用的结构。
| |
如果不确定输出,可以在 Python shell 里直接调用一次。
| |
看到 <h1> 和 .codehilite 同时出现,说明两个扩展都生效。这个检查只需要几秒钟,却能避免在浏览器里反复保存文章。
生成高亮样式
CodeHilite 输出类名,颜色交给 CSS。下面是一个简化后的结果。
| |
nb 会被 Pygments 样式表解释成内建函数的颜色。换一个 Pygments 风格,HTML 结构不变,只有 CSS 变化。
settings.py 里记录样式名。
| |
再用 Pygments 生成静态 CSS。
| |
我把 CSS 预生成成文件,不打算在页面渲染时动态输出。原因是它几乎不变,放静态文件可以交给浏览器缓存,也可以在部署阶段继续压缩。base.html 加载它。
| |
正文区域的几个元素也补一点基础样式。代码块要能横向滚动,表格要有边界,标题要和上一段拉开距离。
| |
代码块里的长行很常见。系统命令、堆栈、URL 都可能超过屏幕宽度。如果给 pre 加 white-space: normal,代码会换行,但缩进和复制结果都会变形。所以这里保留 overflow auto,让长命令横向滚动。阅读稍微麻烦一点,准确性更值钱。
表格同样需要约束。Markdown 表格一多,小屏幕会被撑开。当前先把 width 设为 100%,让列宽按内容分配,后面做移动端优化时再决定哪些列隐藏或折叠。
Pygments 官方内置了很多风格。亮色页面用 default 最稳妥,它的对比度经过长时间使用检验,不会为了好看牺牲可读性。以后做主题切换时,可以准备亮暗两份 CSS,再根据页面属性切换。
生成 CSS 这一步可以放进脚本里,避免某台机器手工复制旧样式。下面是一个最小命令式脚本。
| |
如果以后样式名进入设置,脚本可以读取 django.conf.settings。但当前只有一处使用,先把命令写清楚。每次升级 Pygments 或者更换风格,重新执行一遍,再让测试检查 .codehilite 仍然出现。
依赖升级也有一个小坑。Markdown 库的主版本和次版本都可能调整扩展行为,Pygments 可能增加新的词法分析器。锁定版本可以保证教程稳定,长期项目则要定期读官方变更记录。升级时先在本地重建虚拟环境,再跑这 13 条测试。
| |
这两行只升级指定包,不会顺手把 Django 也推到另一个版本。依赖更新最好一次处理一个变量。出问题时,diff 能直接指向原因。
测试渲染结果
上一篇文章的三条视图测试还在。现在往详情页测试里加一条 Markdown 断言。
| |
第一条断言检查 Markdown 标题真的变成了 HTML。第二条检查 CodeHilite 容器出现。第三条检查代码内容没有丢。如果过滤器悄悄退出,或者扩展名拼错,测试会直接失败。
这个测试不验证每一个颜色类。Pygments 自己已经测试过语法高亮,我们要守住的是集成点,也就是请求进入视图后,正文确实经过了 Markdown 管道。
这里也不用前端 Markdown 库。把渲染放在服务端有几个直接好处。第一,文章 HTML 可以保持稳定,用户浏览器不支持某些功能也不影响正文。第二,搜索和缓存都能工作在渲染后的结果上。第三,页面不需要再为了正文加载一套解析器。
前端只负责展示,也意味着代码高亮不依赖 JavaScript。Pygments 的 CSS 加载完成后,颜色已经在了。页面滚动、字体加载或脚本失败,都不会让代码块突然变成灰白。
服务端渲染还有一个副产品。正文 HTML 在响应里已经成形,以后做站点地图、RSS、邮件摘要或者全文索引时,可以复用同一条生成路径。虽然本阶段还没有这些功能,但不用为它们改模型。
当然,展示时渲染也不是无限便宜的。一篇几万字的文章每次请求都重新解析,会浪费 CPU。合理的第一步是先量化。等详情页出现真实访问量后,可以给单篇文章加缓存,键里带上文章主键和更新时间。缓存失效的信号已经有了,就是 updated_at。
这个设计也带来一个副产品。Markdown 源文本永远是数据库里的权威内容。缓存可以被清掉,渲染结果可以被重算,正文本身不需要迁移。
第一次跑这个测试时,页面输出全是转义后的 <h2>。原因很典型。Django 模板默认不信任任何变量,markdown.markdown() 返回的 HTML 又被自动转义了一层。加 mark_safe 以后,测试变绿。安全机制没有坏,只是这里需要显式声明信任边界。
这类问题很适合用测试暴露。肉眼看页面时,注意力容易被标题和颜色吸引,真正生成 HTML 的过程反而被跳过。断言 <h2>标题</h2> 后,任何一层意外转义都会被抓住。
如果以后要升级 Markdown 库,这组测试也能当回归检查。新版本可能更严格地处理空行、缩进或内联 HTML,旧文章里某些写法可能产生不同结构。先让测试跑过,再抽查几篇代表性文章,比直接部署后再等读者反馈稳妥。
现在启动服务,打开一篇带代码的文章。
| |
详情页里的标题、列表、表格和代码块会按各自元素展示。

提交前仍然跑完整清单。
| |
本阶段共 13 条测试。
| |
数据库不需要迁移,因为 body 从一开始就是 TextField。Markdown 属于展示能力,不改文章数据的形状;变化集中在外部依赖、模板过滤器和静态样式里。
源码
GitHub 地址:https://github.com/yidao620c/simpleblog