使用MarkDown和Flask-PageDown支持富文本
对于发布短消息和状态更新来说,纯文本足够用了,但如果用户想发布长文章,就会觉得在格式上受到了限制,所以我们要将输入文章的多行文本输入框升级,让其支持MarkDown语法,还要添加富文本文章的预览功能
实现这个功能要用到一些新包
- PageDown:使用JavaScript实现的客户端MarkDown到HTML的转换程序
- Flask-PageDown:为Flask包装的PageDown,把PageDown集成到Flask-WTF表单中
- MarkDown:使用Python实现的服务器端MarkDown到HTML的转换程序
- Bleach:使用Python实现的HTML清理器
使用Flask-PageDown
Flask-PageDown拓展定义了一个PageDownField类,这个类和WTForms中的TextAreaField接口一致,使用PageDownField字段之前,先要初始化拓展,如下:
# app/__init__.py
from flask_pagedown import PageDown
#...
pagedown = PageDown()
#...
def create_app(config_name):
#...
pagedown.init_app(app)
#...
若想把首页中的多行文本控件转换成MarkDown富文本编辑器,PostForm表单中的body字段要进行修改,如下所示:
# app/main/forms.py
from flask_pagedown.fields import PageDownField
class PostForm(Form):
body = PageDownField("What's on your mind?", validators=[Required()])
submit = SubmitField('Submit')
MarkDown预览使用PageDown库生成,因此要在模板中修改,Flask-PageDown简化了这个过程,提供了一个红木板,从CDN中加载所需文件:
#app/templates/index.html
#...
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
做了上述修改后,在多行文本字段中输入MarkDown格式的文本会被立即渲染成HTML并显示在输入框下方:
在服务器上处理富文本
提交表单后,POST请求只会发送纯文本MarkDown文本,页面中显示的HTML预览会被丢掉,和表单一起发送生成的HTML预览有安全隐患,因为攻击者轻易就能修改HTML代码,让其和Markdown源不匹配,然后再提交表单,安全起见,只提交Markdown源文本,在服务器上使用Markdown(使用Python编写的Markdown到HTML转换程序)将其转换成HTML,得到HTML后,再使用Bleach进行清理,确保其中只包含几个允许使用的HTML标签
把Markdown格式的博客文章转换成HTML的过程可以在_posts.html
模板中完成,但这么做效率不高,因为每次渲染页面时都要转换一次,为了避免重复工作,我们可在创建博客文章时做一次性转换,转换后的博客文章HTML代码缓存在Post
模型的一个新字段中,在模板中可以直接调用,文章的Markdown源文本还要保存在数据库中,以防需要编辑,下例是对Post模型的改动:
from markdown import markdown
import bleach
body_html = db.Column(db.Text)
class Post(db.Model):
#...
def on_changed_body(target, value, oldvalue, initiator):
allowed_tags = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code',
'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul',
'h1', 'h2', 'h3', 'p']
target.body_html = bleach.linkify(bleach.clean(
markdown(value, output_format='html'),
tags = allowed_tags, strip=True))
db.event.listen(Post.body,'set',Post.on_changed_body)
on_changed_body
函数注册在body字段上,是SQLAlchemy里'set'
事件的监听程序,这意味着只要这个类实例的body字段设了新值,函数就会自动被调用,on_changed_body
函数把body字段中的文本渲染成HTML格式,结果保存在body_html
中,自动且高效地完成Markdown文本到HTML的转换
真正的转换过程分三步完成,首先,markdown()
函数初步把Markdown文本转换成HTML,然后把得到的结果和允许使用的HTML标签列表传给clean函数,clean()
函数删除所有不在白名单中的标签,转换的最后一步由linkify()
函数完成,这个函数由Bleach提供,把纯文本中的URL转换成适当的<a>
链接,最后一步是很有必要的,因为Markdown规范没有为自动生成链接提供官方支持,PageDown以拓展的形式实现了这个功能,因此在服务器上要调用linkify()
函数
最后,如果post.body.html
字段存在,还要把post.body
换成post.body_html
,如下例:
# app/templates/_posts.html
# ...
<div class='post-body'>
{% if post.body_html %}
{{ post.body_html | safe }}
{% else %}
{{ post.body}}
{% endif %}
</div>
渲染HTML格式内容时使用|safe
后缀,其目的是告诉Jinja2不要转义HTML元素,处于安全考虑,默认情况下Jinja2会转义所有模板变量,Markdown转换成的HTML在服务器上生成,因此可以放心渲染
博客文章的固定链接
用户有时希望能够在社交网络中和朋友分享某篇博客文章的链接,为此,每篇文章都要有一个专页,使用唯一的URL引用,支持固定链接功能的路由和视图函数如下例:
# app/main/views.py
#...
@main.route('/post/<int:id>')
def post(id):
post = Post.query.get_or_404(id)
return render_template('post.html', post=[post])
博客文章的URL使用插入数据库时分配的唯一id字段构建
某些类型的程序使用可读性高的字符串而不是数字ID构建固定链接,除了数字ID之外,程序还为博客文章起了个独特的字符串别名
注意,post.html
模板接收一个列表作为参数,这个列表就是要渲染的文章,这里必须要传入列表,因为只有这样,index.html
和user.html
引用的_posts.html
模板才能在这个页面中使用
固定链接添加到通用模板_posts.html
中,显示在文章下方:
# app/templates/_posts.html
<ul class="posts">
#...
<div class="post-content">
<div class="post-footer">
<a href="{{ url_for('.post', id=post.id) }}">
<span class="label label-default">Permalink</span></a>
</div>
</div>
</ul>
渲染固定页面的post.html
模板如下,其中引入了上述模板:
{% extends "base.html" %}
{% block title %}Flasky - Post{% endblock %}
{% block page_content %}
{% include '_posts.html' %}
{% endblock %}
博客文章编辑器
与博客文章相关的最后一个功能是文字编辑器,它允许用户编辑自己的文章,博客文章编辑器显示在单独的页面中,在这个页面的上部会显示文章的当前版本,以供参考,下面跟着一个Markdown编辑器,用于修改Markdown源,这个编辑器基于Flask-PageDown实现,所以页面下部还会显示一个编辑后的文章预览,edit_post.html
模板如下所示:
# app/templates/edit_post.html
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}Flasky - Edit Post{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>Edit Post</h1>
</div>
<div>
{{ wtf.quick.form(form) }}</div>
{% endblock %}
{% block scripts %}
{{ super() }}
{{ pagedown.include_pagedown() }}
{% endblock %}
博客文章编辑器使用的路由如下:
# app/main/views.py
@main.route('/edit/<int:id>', methods=['GET', 'POST'])
@login_required
def edit(id):
post = Post.query.get_or_404(id)
if current_user != post.author and \
not current_app.can(Permission.ADMINISTER):
abort(403)
form = PostForm()
if form.validate_on_submit():
post.body = form.body.data
db.session.add(post)
flash('The post has been Updated')
return redirect(url_for('.post', id=post.id))
form.body.data = post.body
return render_template('edit_post.html', form=form)
这个视图函数的作用是只允许博客文章的作者编辑文章,但管理员例外,管理员能编辑所有用户的文章,如果用户试图编辑其他用户的文章,就会返回403错误,这里使用的PostForm表单类和首页中使用的是同一个
问了功能完整,我们还可以在每篇文章的下面、固定链接的旁边添加一个指向编辑页面的链接,代码如下:
#app/templates/_posts.html
#...
#...
<div class="post-footer">
<a href="{{ url_for('.post', id=post.id) }}">
<span class="label label-default">Permalink</span></a>
{% if current_user == post.author %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class="label label-primary">Edit</span></a>
{% elif current_user.is_administrator() %}
<a href="{{ url_for('.edit', id=post.id) }}">
<span class = " label label-danger">Edit[Admin]</span>
</a>
{% endif %}
</div>
#...
通过这次修改,我们在当前用户发布的博客文章下面添加了一个“Edit”链接,如果当前用户是管理员,所有文章下面都会有编辑链接,为管理员显示的链接样式有点不同,以从视觉上表明这是管理功能,效果如下: