Markdown 自动生成文章目录

我们的之前在博客中使用了 Markdown 来为文章提供排版支持。Markdown 在渲染内容的同时还可以自动提取整个内容的目录结构,现在我们来使用 Markdown 为文章自动生成目录。

在文中插入目录

先来回顾一下博客的 Post(文章)模型,其中 body 是我们存储 Markdown 文本的字段:

blog/models.py

from django.db import models

class Post(models.Model):
    # Other fields ...
    body = models.TextField()

再来回顾一下文章详情页的视图,我们在 PostDetailView 中将 postbody 字段中的 Markdown 文本渲染成了 HTML 文本,然后传递给模板显示。注意这里我们使用的是类视图,类视图的内容具体请参考 基于类的通用视图:ListView 和 DetailView

blog/views.py

class PostDetailView(DetailView):
    # 这些属性的含义和 ListView 是一样的
    model = Post
    template_name = 'blog/detail.html'
    context_object_name = 'post'

    def get(self, request, *args, **kwargs):
        # ...

    def get_object(self, queryset=None):
        # 覆写 get_object 方法的目的是因为需要对 post 的 body 值进行渲染
        post = super(PostDetailView, self).get_object(queryset=None)
        post.body = markdown.markdown(post.body,
                                      extensions=[
                                          'markdown.extensions.extra',
                                          'markdown.extensions.codehilite',
                                          'markdown.extensions.toc',
                                      ])
        return post

    def get_context_data(self, **kwargs):
        # ...

看到 get_object 方法中的代码,markdown.markdown() 方法把 post.body 中的 Markdown 文本渲染成了 HTML 文本。同时我们还给该方法提供了一个 extensions 的额外参数。其中 markdown.extensions.codehilite 是代码高亮拓展,而 markdown.extensions.toc 就是自动生成目录的拓展(这里可以看出我们有先见之明,如果你之前没有添加的话记得现在添加进去)。

在渲染 Markdown 文本时加入了 toc 拓展后,就可以在文中插入目录了。方法是在书写 Markdown 文本时,在你想生成目录的地方插入 [TOC] 标记即可。例如新写一篇 Markdown 博文,其 Markdown 文本内容如下:

[TOC]

## 我是标题一

这是标题一下的正文

## 我是标题二

这是标题二下的正文

### 我是标题二下的子标题
这是标题二下的子标题的正文

## 我是标题三
这是标题三下的正文

其最终渲染后的效果就是:

Markdown文中目录

原本 [TOC] 标记的地方被内容的目录替换了。

在页面的任何地方插入目录

上述方式的一个局限局限性就是只能通过 [TOC] 标记在文章内容中插入目录。如果我想在页面的其它地方,比如侧边栏插入一个目录该怎么做呢?方法其实也很简单,只需要稍微改动一下渲染 Markdown 文本内容的方式即可,具体代码就像这样:

blog/views.py

class PostDetailView(DetailView):
    # 这些属性的含义和 ListView 是一样的
    model = Post
    template_name = 'blog/detail.html'
    context_object_name = 'post'

    def get(self, request, *args, **kwargs):
        # ...

    def get_object(self, queryset=None):
        # 覆写 get_object 方法的目的是因为需要对 post 的 body 值进行渲染
        md = markdown.Markdown(extensions=[
            'markdown.extensions.extra',
            'markdown.extensions.codehilite',
            'markdown.extensions.toc',
        ])
        post.body = md.convert(post.body)
        post.toc = md.toc
        return post

    def get_context_data(self, **kwargs):
        # ...

和之前的代码不同,在 get_object 方法中我们没有直接用 markdown.markdown() 方法来渲染 post.body 中的内容,而是先实例化了一个 markdown.Markdownmd,和 markdown.markdown() 方法一样,也传入了 extensions 参数。接着我们便使用该实例的 convert 方法将 post.body 中的 Markdown 文本渲染成 HTML 文本。而一旦调用该方法后,实例 md 就会多出一个 toc 属性,这个属性的值就是内容的目录,我们把 md.toc 的值赋给 post.toc 属性(要注意这个 post 实例本身是没有 md 属性的,我们给它动态添加了 md 属性,这就是 Python 动态语言的好处,不然这里还真不知道该怎么把 toc 的值传给模板)。

接下来就在博客文章详情页的文章目录侧边栏渲染文章的目录吧!删掉占位用的目录内容,替换成如下代码:

{% block toc %}
    <div class="widget widget-content">
        <h3 class="widget-title">文章目录</h3>
        {{ post.toc|safe }}
    </div>
{% endblock toc %}

即使用模板变量标签 {{ post.toc }} 显示模板变量的值,注意 post.toc 实际是一段 HTML 代码,我们知道 Django 会对模板中的 HTML 代码进行转义,所以要使用 safe 标签防止 Django 对其转义。其最终渲染后的效果就是:

Markdown自动生成的侧边栏目录

美化标题的锚点 URL

文章内容的标题被设置了锚点,点击目录中的某个标题,页面就会跳到该文章内容中标题所在的位置,这时候浏览器的 URL 显示的值可能不太美观,比如像下面的样子:

http://127.0.0.1:8000/post/8/#_1

http://127.0.0.1:8000/post/8/#_3

#_1 就是锚点,Markdown 在设置锚点时利用的是标题的值,由于通常我们的标题都是中文,Markdown 没法处理,所以它就忽略的标题的值,而是简单地在后面加了个 _1 这样的锚点值。为了解决这一个问题,我们需要修改一下传给 extentions 的参数,其具体做法如下:

blog/views.py

from django.utils.text import slugify
from markdown.extensions.toc import TocExtension

class PostDetailView(DetailView):
    # 这些属性的含义和 ListView 是一样的
    model = Post
    template_name = 'blog/detail.html'
    context_object_name = 'post'

    def get(self, request, *args, **kwargs):
        # ...

    def get_object(self, queryset=None):
        # 覆写 get_object 方法的目的是因为需要对 post 的 body 值进行渲染
        md = markdown.Markdown(extensions=[
            'markdown.extensions.extra',
            'markdown.extensions.codehilite',
            # 记得在顶部引入 TocExtension 和 slugify
            TocExtension(slugify=slugify),
        ])
        post.body = md.convert(post.body)
        post.toc = md.toc
        return post

    def get_context_data(self, **kwargs):
        # ...

和之前不同的是,extensions 中的 toc 拓展不再是字符串 markdown.extensions.toc ,而是 TocExtension 的实例。TocExtension 在实例化时其 slugify 参数可以接受一个函数作为参数,这个函数将被用于处理标题的锚点值。Markdown 内置的处理方法不能处理中文标题,所以我们使用了 django.utils.text 中的 slugify 方法,该方法可以很好地处理中文。

这时候标题的锚点 URL 变得好看多了。

http://127.0.0.1:8000/post/8/#我是标题一

http://127.0.0.1:8000/post/8/#我是标题二下的子标题

总结

本章节的代码位于:Step24: extract content automatically using markdown

如果遇到问题,请通过下面的方式寻求帮助。

-- EOF --


31 条评论 / 15 人参与
推粪的小瓢虫丶

博主请问我用你的代码运行发现,post.body也会渲染toc,这样2个toc不是很美观,请问应该怎么解决呢?django是2版本的。


追梦人物 [博主] 推粪的小瓢虫丶

一种解决方案就是使用 js 动态给 toc 添加样式


Even

你好,我想咨询一下,我后端也用了markdown插件TinyMCE去保存文章,但是这样保存在数据库的数据就自动带了html标签,无法使用toc变量去传输目录。针对这种情况博主有好的解决方案吗?


Even Even

"后端"改成"后台admin"


Lenaa56

我的侧边栏为什么没有文章目录这一栏?


LYCai

post.toc = md.toc没有值的,文章是有目录的,代码也试过复制你的代码,好奇怪


xiaopanddxiong LYCai

前面加一行:post = super(PostDetailView, self).get_object(queryset=None)


LYCai xiaopanddxiong

试过了,但是还是没有值


lanlingsheng LYCai

检查下 blog/urls.py  是不是

  url(r'^post/(?P<pk>[0-9]+)/$', views.PostDetailView.as_view(), name='detail'),


LYCai lanlingsheng

谢谢啦 , 原来是这个问题,太粗心了


acaiblog lanlingsheng

我在url.py添加这个参数后访问文章详情页面返回404错误?这是为什么呢?


JonhoyChan

博主你好,为什么我的url是这样的,用的python3#%E6%A0%87%E9%A2%98%E4%B8%80

还有post.toc = md.toc这段话里的md.toc在pycharm下会变黄,但是运行却正常,感觉很奇怪


Stallionshell

博主你好,为什么在添加目录的get_object方法中,不需要先

post = super(PostDetailView, self).get_object(queryset=None)

实例化post呢?我测试了一下似乎不用不添加这个语句运行时也没有报错。


追梦人物 [博主] Stallionshell

小鹏super30110
请教下,如何在点击详情也上端的评论链接时也能跳转到先面的评论表单,或者已有评论列表起始位置,谢谢!

追梦人物 [博主] 小鹏super30110
在你需要跳转的地方设置 #锚点,在跳转的链接中加入这个锚点即可。具体请搜索 html 文档锚点设置方法。

李江波
post.toc = md.toc
楼主,这个传过去没数据怎么回事

追梦人物 [博主] 李江波
审核一下代码是不是写错了?然后看看是不是文章本身没有目录?

LYCai 李江波

同问 你解决了吗

Image


Xu Lichen
我在运行的时候发现下行出现
TocExtension(slugify=slugify),
提示:没有slugify这个参数,然后我就去看markdown源码,发现TocExtension内只configs这一个参数,且源码内有
for key, value in configs:
self.setConfig(key, value)
想是对configs格式有要求,要key和value的值且要可迭代,
因此我就把代码改成TocExtension(configs=[('slugify', slugify)]),
之后运行正常,没有细究原因,仅供参考。

追梦人物 [博主] Xu Lichen
怀疑是 markdown 的版本和示例中的不同?最新版 markdown 是支持这个参数的。

发情的兔
"我们把 md.toc 的值赋给 post.md 属性"这里应该是post.toc吧?

追梦人物 [博主] 发情的兔
貌似是的,感谢指正。

mihelloO
厉害!TocExtension(slugify=slugify) 这个博主哪里有相关资料。我看了源码才知道这个用法。

追梦人物 [博主] mihelloO
Markdown 的官方文档就有。

mihelloO 追梦人物 [博主]
学习了,现在django进阶是跟着博主混了。

追梦人物 [博主] mihelloO
多看文档咯,最近在忙什么项目?

mihelloO 追梦人物 [博主]
下个阶段搞个运维平台,管理服务器。刚搞了个小工具。实时浏览日志,就是用django channels做 web 版本的tail -f。以后多叨扰博主了,多请教你。

追梦人物 [博主] mihelloO
一起学习了,你说的这些我都没用过,该我向你学习。

mihelloO 追梦人物 [博主]
一起学习,跟你学。我发现博客用手机无法微博登录,【访问出错】error:redirect_uri_mismatch

追梦人物 [博主] mihelloO
是的,微博登录在有些浏览器下有问题,也不知道怎么回事。