Article publishing and Markdown support: rich text editor and paging

📂 Stage: Stage 4 - Practical Exercise (Daoman Blog) 🔗 Related chapters: 项目架构重构(Blueprints) · 数据库关系设计

Preface: Why choose Markdown + lightweight rendering?

When developing a technology blog (or "non-fully visually edited content platform"), we will face several common technology selection issues:

  • **Should you use a rich text editor (such as TinyMCE, Quill)? ** This type of editor is friendly to non-technical users, but the generated HTML is highly redundant, making it difficult to ensure consistency in front-end and back-end rendering;

  • **Should I write the rendering completely myself? ** There is no need to reinvent the wheel, the open source Python-Markdown already covers 99% of common needs;

  • **Do you need to add safety protection? ** **Must add! ** Direct rendering of user-submitted content is a hot spot for XSS attacks.

Taken together, the solution of this article is Users submit content using pure Markdown → The backend uses Python-Markdown to convert it to HTML → Use the Bleach library to clean up unsafe tags → Finally, it is displayed safely in the template.


1. Quickly build a Markdown rendering environment

1.1 Core dependency installation

We need two core libraries:

  • markdown: Responsible for converting Markdown text into HTML
  • bleach: Responsible for cleaning up XSS-related dangerous tags and attributes
pip install markdown bleach

If you need the basic color matching of code highlighting, you can install it by the way.pygments(Extendedcodehilitewill be used):

pip install pygments

1.2 Encapsulate safe rendering tool functions

existapp/utils.pyencapsulates a generalrender_markdownfunction, so that the whole project can be reused, and configuration adjustment only needs to change this one:

# 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
    )
Safety Tips

Bleach's cleaning rules are best matched to actual rendering requirements - only allow tags and attributes you clearly need to avoid leaving any unanticipated attack surfaces.

1.3 Integrate rendering on the article details page

Next, call this function in the route and template of the article details:

1.3.1 Route modification

# 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 Template modification

Be careful to use Jinja2|safefilter, otherwise the rendered HTML will be escaped into a string and displayed:

<!-- 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. Article form development: supports classification, tags, and status switching

We use Flask-WTF to implement the form, which supports basic verification, category drop-down, tag input and other functions:

# 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()
        ]

Tip:coerce=intEnsure that classification IDs are correctly converted to integers on the server side to avoid type mismatches.


3. Article list pagination: improve browsing experience

When the number of articles exceeds 10, loading all the content at once will be slow and the experience is not good. This is when pagination comes in handy. Flask-SQLAlchemy comes withpaginate()method, very convenient.

3.1 Routing to implement paging query

# 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 Encapsulate common paging macros

The paging component is used on many pages (such as category lists, tag lists), so we encapsulate it into a Jinja2 macro and place it intemplates/macros/Under the directory:

<!-- 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 Introducing macros into list templates

<!-- 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. Article tag processing: realize the addition, deletion, modification and query association of tags

Tags and articles have a many-to-many relationship (one article can have multiple tags, and one tag can also correspond to multiple articles). We have created an intermediate table during the database design stage. Now we need to encapsulate a function to process the label string entered by the user in the form:

# 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

Note: This function needs to be called within a database transaction, or in conjunction withdb.session.commit()Use to avoid the situation where tags are added but not saved.


5. Summary

In this article we have completed the following core functions:

  1. Safe Markdown Rendering: UsemarkdownLibrary conversion, usebleachThe library cleans up dangerous content;
  2. Complete article form: supports title, abstract, text, classification, tags, and release status;
  3. Universal article paging: using Flask-SQLAlchemypaginate()Method query, use Jinja2 macro to encapsulate paging components;
  4. Tag association processing: The encapsulation function parses the tag string to realize the addition, deletion and modification of many-to-many relationships.

💡 Safety Tip Review: Never render any content entered by the user (Markdown, HTML, part of plain text) without cleaning it! XSS attacks are very harmful, and attackers can use them to steal users' cookies, publish false content, etc.


🔗 Extended reading