文章发布与 Markdown 支持:富文本编辑器与分页

📂 所属阶段:第四阶段 — 实战演练(道满博客)
🔗 相关章节:项目架构重构(Blueprints) · 数据库关系设计

前言:为什么选 Markdown + 轻量级渲染?

在开发技术博客(或者说“非完全可视化编辑的内容平台”)时,我们会面临几个常见的技术选型问题:

  • 要不要用富文本编辑器(如 TinyMCE、Quill)?
    这类编辑器对非技术用户友好,但生成的 HTML 冗余度高,很难保证前后端渲染的一致性;

  • 要不要完全自己写渲染?
    没必要重复造轮子,开源的 Python-Markdown 已经覆盖了 99% 的常用需求;

  • 要不要加安全防护?
    必须加! 用户提交的内容直接渲染是 XSS 攻击的重灾区。

综合来看,本文的方案是用户用纯 Markdown 提交内容 → 后端用 Python-Markdown 转成 HTML → 用 Bleach 库清理不安全标签 → 最后在模板里安全展示


1. 快速搭建 Markdown 渲染环境

1.1 核心依赖安装

我们需要两个核心库:

  • markdown:负责把 Markdown 文本转换成 HTML
  • bleach:负责清理 XSS 相关的危险标签和属性
pip install markdown bleach

如果需要代码高亮的基础配色,可以顺便安装 pygments(扩展 codehilite 会用到):

pip install pygments

1.2 封装安全的渲染工具函数

app/utils.py 里封装一个通用的 render_markdown 函数,这样全项目都可以复用,而且配置调整只需要改这一处:

# app/utils.py
import markdown
import bleach

def render_markdown(content):
    """将 Markdown 渲染为符合规范的安全 HTML
    
    Args:
        content (str): 原始 Markdown 文本
    Returns:
        str: 清理后的 HTML 文本
    """
    # 1. 开启常用 Markdown 扩展
    html = markdown.markdown(
        content,
        extensions=[
            "extra",       # 支持表格、定义列表、删除线等基础语法增强
            "codehilite",  # 代码高亮(需要配合 Pygments 使用)
            "toc",         # 自动生成目录(可通过 [TOC] 标记插入正文)
        ],
        # 可选:配置代码高亮的 Pygments 参数
        extension_configs={
            "codehilite": {
                "css_class": "codehilite",  # 给代码块加 class 方便自定义样式
                "linenums": True,           # 开启行号显示
            }
        }
    )

    # 2. 用 Bleach 清理危险标签和属性
    # 只允许博客内容常用的标签
    allowed_tags = [
        "h1", "h2", "h3", "h4", "h5", "h6",
        "p", "br", "strong", "em", "u", "s", "blockquote", "pre", "code",
        "ul", "ol", "li",
        "a", "img",
        "table", "thead", "tbody", "tr", "th", "td",
        "div", "span",  # 给自定义样式/结构留空间
    ]
    # 只允许标签必需的属性
    allowed_attributes = {
        "a": ["href", "title", "target"],  # 加 target="_blank" 方便用户打开外部链接
        "img": ["src", "alt", "title"],
        "code": ["class"],  # 留 codehilite 的 class
        "span": ["class"],  # 留自定义 span 的 class
    }
    # 额外加个协议白名单,防止 a/img 用 javascript: 等恶意协议
    allowed_protocols = ["http", "https", "mailto"]

    return bleach.clean(
        html,
        tags=allowed_tags,
        attributes=allowed_attributes,
        protocols=allowed_protocols
    )
安全提示

Bleach 的清理规则最好和实际渲染需求匹配——只放行你明确需要的标签和属性,避免留下任何未预期的攻击面。

1.3 在文章详情页集成渲染

接下来在文章详情的路由和模板里调用这个函数:

1.3.1 路由修改

# app/articles/routes.py
from flask import render_template
from app import db
from app.models import Post
from app.utils import render_markdown
from . import articles_bp

@articles_bp.route("/<int:post_id>")
def detail(post_id):
    # 用 get_or_404 防止访问不存在的文章时报 500
    post = Post.query.get_or_404(post_id)
    # 简单增加阅读量
    post.views += 1
    db.session.commit()
    # 渲染 Markdown 正文
    rendered_content = render_markdown(post.content)
    return render_template(
        "articles/detail.html",
        post=post,
        rendered_content=rendered_content
    )

1.3.2 模板修改

注意要用 Jinja2 的 |safe 过滤器,否则渲染后的 HTML 会被转义成字符串显示:

<!-- templates/articles/detail.html -->
<article class="article-container">
    <header class="article-header">
        <h1>{{ post.title }}</h1>
        <div class="article-meta">
            <span class="meta-item">👤 {{ post.author.username }}</span>
            <span class="meta-item">📅 {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
            <span class="meta-item">👁️ {{ post.views }}</span>
        </div>
    </header>
    <!-- 安全展示渲染后的 HTML -->
    <section class="article-content">
        {{ rendered_content|safe }}
    </section>
</article>

2. 文章表单开发:支持分类、标签、状态切换

我们用 Flask-WTF 来实现表单,支持基本的验证、分类下拉、标签输入等功能:

# app/forms/article.py
from flask_wtf import FlaskForm
from wtforms import (
    StringField, TextAreaField, SelectField, BooleanField, SubmitField
)
from wtforms.validators import DataRequired, Length, Optional

class ArticleForm(FlaskForm):
    title = StringField(
        "标题",
        validators=[
            DataRequired(message="标题是必填项哦😯"),
            Length(min=5, max=200, message="标题长度需要在 5-200 个字符之间")
        ],
        render_kw={"placeholder": "请输入一个清晰的标题"}
    )
    summary = StringField(
        "摘要",
        validators=[
            Optional(),
            Length(max=300, message="摘要太长啦,控制在 300 字符以内吧")
        ],
        render_kw={"placeholder": "可选,会显示在文章列表和搜索引擎预览中"}
    )
    content = TextAreaField(
        "正文(支持 Markdown)",
        validators=[
            DataRequired(message="正文不能是空的哦"),
            Length(min=10, message="正文至少需要 10 个字符")
        ],
        render_kw={"rows": 20, "placeholder": "在这里写你的文章内容~"}
    )
    category_id = SelectField(
        "分类",
        coerce=int,  # 把表单提交的字符串转成 int
        validators=[Optional()]
    )
    tags = StringField(
        "标签",
        render_kw={"placeholder": "多个标签用英文逗号分隔,比如 Python, Flask, 博客"}
    )
    is_published = BooleanField(
        "立即发布",
        default=True  # 默认勾选立即发布
    )
    submit = SubmitField("保存")

    # 动态加载分类下拉选项
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        from app.models import Category
        # 第一个选项是“无分类”
        self.category_id.choices = [(0, "无")] + [
            (c.id, c.name) for c in Category.query.order_by(Category.name).all()
        ]

提示coerce=int 确保分类 ID 在服务端被正确转换为整数,避免类型不匹配。


3. 文章列表分页:提升浏览体验

当文章数量超过 10 篇时,一次性加载所有内容会很慢,而且体验不好,这时候分页就派上用场了。Flask-SQLAlchemy 自带了 paginate() 方法,非常方便。

3.1 路由实现分页查询

# app/articles/routes.py
from flask import request

@articles_bp.route("/")
def index():
    # 从 URL 参数中获取当前页码,默认是第 1 页
    page = request.args.get("page", 1, type=int)
    # 每页显示的文章数量
    per_page = 10

    # 查询已发布的文章,按创建时间倒序排列,然后分页
    pagination = Post.query\
        .filter_by(is_published=True)\
        .order_by(Post.created_at.desc())\
        .paginate(
            page=page,
            per_page=per_page,
            error_out=False  # 页码不存在时返回空列表,而不是 404
        )

    # pagination.items 是当前页的文章列表
    return render_template(
        "articles/index.html",
        pagination=pagination,
        posts=pagination.items
    )

3.2 封装通用的分页宏

分页组件在很多页面都会用到(比如分类列表、标签列表),所以我们把它封装成 Jinja2 宏,放在 templates/macros/ 目录下:

<!-- templates/macros/pagination.html -->
{% macro render_pagination(pagination, endpoint, **kwargs) %}
{% if pagination.pages > 1 %}
<nav class="pagination-container" aria-label="文章列表分页导航">
    <ul class="pagination">
        <!-- 上一页按钮 -->
        {% if pagination.has_prev %}
        <li class="page-item">
            <a class="page-link" href="{{ url_for(endpoint, page=pagination.prev_num, **kwargs) }}">
                <i class="fas fa-chevron-left"></i> 上一页
            </a>
        </li>
        {% else %}
        <li class="page-item disabled">
            <span class="page-link"><i class="fas fa-chevron-left"></i> 上一页</span>
        </li>
        {% endif %}

        <!-- 页码列表 -->
        {% for p in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
            {% if p %}
                <li class="page-item {% if p == pagination.page %}active{% endif %}">
                    <a class="page-link" href="{{ url_for(endpoint, page=p, **kwargs) }}">{{ p }}</a>
                </li>
            {% else %}
                <li class="page-item disabled">
                    <span class="page-link">...</span>
                </li>
            {% endif %}
        {% endfor %}

        <!-- 下一页按钮 -->
        {% if pagination.has_next %}
        <li class="page-item">
            <a class="page-link" href="{{ url_for(endpoint, page=pagination.next_num, **kwargs) }}">
                下一页 <i class="fas fa-chevron-right"></i>
            </a>
        </li>
        {% else %}
        <li class="page-item disabled">
            <span class="page-link">下一页 <i class="fas fa-chevron-right"></i></span>
        </li>
        {% endif %}
    </ul>
</nav>
{% endif %}
{% endmacro %}

3.3 在列表模板中引入宏

<!-- templates/articles/index.html -->
<!-- 引入分页宏 -->
{% from "macros/pagination.html" import render_pagination %}

<!-- 文章列表部分 -->
<section class="article-list">
    {% for post in posts %}
    <div class="article-card">
        <h2 class="article-card-title">
            <a href="{{ url_for('articles.detail', post_id=post.id) }}">{{ post.title }}</a>
        </h2>
        <div class="article-card-meta">
            <span>📅 {{ post.created_at.strftime('%Y-%m-%d') }}</span>
            {% if post.category %}
            <span>🏷️ 分类:{{ post.category.name }}</span>
            {% endif %}
        </div>
        <p class="article-card-summary">{{ post.summary or post.content[:200]|striptags }}...</p>
    </div>
    {% else %}
    <p class="empty-tip">还没有发布任何文章哦~</p>
    {% endfor %}
</section>

<!-- 调用分页宏 -->
{{ render_pagination(pagination, 'articles.index') }}

4. 文章标签处理:实现标签的增删改查关联

标签和文章是多对多的关系(一篇文章可以有多个标签,一个标签也可以对应多篇文章),我们在数据库设计阶段已经创建了中间表。现在需要封装一个函数,用来处理用户在表单中输入的标签字符串:

# app/utils.py(可以放在之前的 render_markdown 下面)
from app import db
from app.models import Tag

def process_article_tags(post, tags_string):
    """解析表单输入的标签字符串,更新文章的标签关联
    
    Args:
        post (Post): 要更新的文章对象
        tags_string (str): 用户输入的标签字符串,用英文逗号分隔
    """
    if not tags_string or not tags_string.strip():
        # 如果标签字符串为空,清空文章的所有标签
        post.tags = []
        return

    # 1. 解析标签字符串,去重并去除空白
    tag_names = list({
        t.strip() for t in tags_string.split(",") if t.strip()
    })

    # 2. 获取或创建标签对象
    tags = []
    for name in tag_names:
        tag = Tag.query.filter_by(name=name).first()
        if not tag:
            tag = Tag(name=name)
            db.session.add(tag)
        tags.append(tag)

    # 3. 更新文章的标签关联
    post.tags = tags

注意:这个函数需要在数据库事务内调用,或者配合 db.session.commit() 使用,避免出现标签已添加但未保存的情况。


5. 小结

本文我们完成了以下核心功能:

  1. 安全的 Markdown 渲染:用 markdown 库转换,用 bleach 库清理危险内容;
  2. 完整的文章表单:支持标题、摘要、正文、分类、标签、发布状态;
  3. 通用的文章分页:用 Flask-SQLAlchemy 的 paginate() 方法查询,用 Jinja2 宏封装分页组件;
  4. 标签关联处理:封装函数解析标签字符串,实现多对多关系的增删改。

💡 安全提示回顾:永远不要对用户输入的任何内容(Markdown、HTML、纯文本的一部分)不做清理直接渲染!XSS 攻击的危害非常大,攻击者可以通过它窃取用户的 Cookie、发布虚假内容等。


🔗 扩展阅读