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

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


1. Markdown 支持

1.1 安装 markdown 库

pip install markdown bleach

1.2 渲染 Markdown

# app/utils.py
import markdown
import bleach

def render_markdown(content):
    """将 Markdown 渲染为安全的 HTML"""
    # 支持常用扩展
    html = markdown.markdown(
        content,
        extensions=[
            "extra",       # 表格、定义列表等
            "codehilite",  # 代码高亮
            "toc",         # 目录
        ]
    )
    # 清理危险标签
    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",
    ]
    return bleach.clean(html, tags=allowed_tags, attributes={"a": ["href", "title"], "img": ["src", "alt", "title"], "code": ["class"], "span": ["class"]})

1.3 在文章详情页渲染

# app/articles/routes.py
from app.utils import render_markdown

@articles_bp.route("/<int:post_id>")
def detail(post_id):
    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)
<!-- templates/articles/detail.html -->
<article class="article">
    <h1>{{ post.title }}</h1>
    <div class="article-meta">
        <span>作者:{{ post.author.username }}</span>
        <span>发布时间:{{ post.created_at.strftime('%Y-%m-%d') }}</span>
        <span>阅读:{{ post.views }}</span>
    </div>
    <!-- 渲染后的 HTML -->
    <div class="article-content">
        {{ rendered_content|safe }}
    </div>
</article>

2. 文章表单

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

class ArticleForm(FlaskForm):
    title = StringField("标题", validators=[
        DataRequired(message="标题不能为空"),
        Length(min=5, max=200, message="标题长度 5-200 个字符")
    ])
    summary = StringField("摘要", validators=[
        Optional(),
        Length(max=300)
    ])
    content = TextAreaField("正文(支持 Markdown)", validators=[
        DataRequired(message="正文不能为空"),
        Length(min=10)
    ])
    category_id = SelectField("分类", coerce=int)
    tags = StringField("标签", description="多个标签用逗号分隔")
    is_published = BooleanField("立即发布")
    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.all()
        ]

3. 文章列表分页

@articles_bp.route("/")
def index():
    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)

    return render_template("articles/index.html", pagination=pagination)

模板分页组件:

<!-- templates/macros/pagination.html -->
{% macro render_pagination(pagination, endpoint) %}
{% if pagination.pages > 1 %}
<nav aria-label="分页导航">
    <ul class="pagination justify-content-center">
        {% if pagination.has_prev %}
        <li class="page-item">
            <a class="page-link" href="{{ url_for(endpoint, page=pagination.prev_num) }}">上一页</a>
        </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) }}">{{ 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) }}">下一页</a>
        </li>
        {% endif %}
    </ul>
</nav>
{% endif %}
{% endmacro %}

4. 文章标签处理

# 创建/更新文章时处理标签
def process_tags(post, tags_string):
    """解析标签字符串,更新文章标签"""
    if not tags_string:
        return

    tag_names = [t.strip() for t in tags_string.split(",") if t.strip()]

    from app.models import Tag
    for name in tag_names:
        tag = Tag.query.filter_by(name=name).first()
        if not tag:
            tag = Tag(name=name)
            db.session.add(tag)
        if tag not in post.tags:
            post.tags.append(tag)

    # 移除不在新列表中的标签
    current_names = set(tag_names)
    post.tags = [t for t in post.tags if t.name in current_names]

5. 小结

Markdown 文章发布流程:
1. 安装:pip install markdown bleach
2. 渲染:render_markdown(content)
3. 清理:bleach.clean(html, tags=[...], attributes={...})
4. 显示:{{ rendered_content|safe }}

💡 安全提示:永远不要对用户输入的 HTML/Markdown 不做清理直接渲染!XSS 攻击可以通过 Markdown 评论植入恶意脚本。


🔗 扩展阅读