Last updated on

Java 实现 Markdown 转 HTML:从 CommonMark 到 flexmark-java 的完整方案


Java 实现 Markdown 转 HTML:从 CommonMark 到 flexmark-java 的完整方案

在很多 Java Web 项目中,我们需要将 Markdown 格式的内容渲染为 HTML 展示给用户——比如技术文档系统、博客平台、CMS 内容管理系统等。本文梳理 Java 生态中主流的 Markdown 解析库,并以实际代码演示如何集成扩展语法、代码高亮和安全过滤。


一、Java 生态中的 Markdown 解析库对比

Java 社区有多个 Markdown 解析方案,各有特点:

库名 协议 CommonMark 兼容 扩展语法 维护状态 适用场景
flexmark-java BSD-2 ✅ 完全兼容 表格、任务列表、目录、脚注等 50+ 扩展 活跃维护 生产环境首选
commonmark-java Apache-2.0 ✅ 完全兼容 标题锚点、表格、删除线等少量扩展 维护模式 简单场景
Pegdown Apache-2.0 ❌ 部分兼容 支持多种扩展 已停止维护 遗留项目
Markdown4j Apache-2.0 ❌ 不兼容 有限 已停止维护 极简场景

选型建议:新项目推荐使用 flexmark-java,它是 commonmark-java 作者开发的增强版本,功能更全面,扩展更丰富。如果你的需求非常简单(只需要基础标题、段落、链接),commonmark-java 也足够用。


二、方案一:使用 CommonMark 实现

commonmark-java 是 Atlassian 维护的 CommonMark 规范 Java 实现,适合基础场景。

2.1 Maven 依赖

<!-- CommonMark 核心库 -->
<dependency>
    <groupId>com.atlassian.commonmark</groupId>
    <artifactId>commonmark</artifactId>
    <version>0.17.0</version>
</dependency>

<!-- 扩展:标题锚点(为 h1-h6 自动生成 id 属性) -->
<dependency>
    <groupId>com.atlassian.commonmark</groupId>
    <artifactId>commonmark-ext-heading-anchor</artifactId>
    <version>0.17.0</version>
</dependency>

<!-- 扩展:GFM 表格支持 -->
<dependency>
    <groupId>com.atlassian.commonmark</groupId>
    <artifactId>commonmark-ext-gfm-tables</artifactId>
    <version>0.17.0</version>
</dependency>

2.2 工具类封装

下面是一个完整的工具类,包含基础转换和扩展转换两种模式:

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.commonmark.Extension;
import org.commonmark.ext.gfm.tables.TableBlock;
import org.commonmark.ext.gfm.tables.TablesExtension;
import org.commonmark.ext.heading.anchor.HeadingAnchorExtension;
import org.commonmark.node.Link;
import org.commonmark.node.Node;
import org.commonmark.parser.Parser;
import org.commonmark.renderer.html.AttributeProvider;
import org.commonmark.renderer.html.AttributeProviderContext;
import org.commonmark.renderer.html.AttributeProviderFactory;
import org.commonmark.renderer.html.HtmlRenderer;

public class MarkdownToHtmlUtils {

    /**
     * 基础转换:仅支持 CommonMark 标准语法
     */
    public static String markdownToHtml(String markdown) {
        Parser parser = Parser.builder().build();
        Node document = parser.parse(markdown);
        HtmlRenderer renderer = HtmlRenderer.builder().build();
        return renderer.render(document);
    }

    /**
     * 扩展转换:支持标题锚点 + GFM 表格 + 链接新窗口打开
     */
    public static String markdownToHtmlExtensions(String markdown) {
        // 标题锚点扩展:为 h1-h6 自动生成 id 属性,方便目录跳转
        Set<Extension> headingAnchorExtensions = Collections.singleton(
            HeadingAnchorExtension.create()
        );

        // 表格扩展:支持 GFM 风格的管道表格语法
        List<Extension> tableExtension = Collections.singletonList(
            TablesExtension.create()
        );

        // 构建解析器,注册扩展
        Parser parser = Parser.builder()
                .extensions(tableExtension)
                .build();
        Node document = parser.parse(markdown);

        // 构建渲染器,注册扩展和自定义属性提供者
        HtmlRenderer renderer = HtmlRenderer.builder()
                .extensions(headingAnchorExtensions)
                .extensions(tableExtension)
                .attributeProviderFactory(new AttributeProviderFactory() {
                    public AttributeProvider create(AttributeProviderContext context) {
                        return new CustomAttributeProvider();
                    }
                })
                .build();
        return renderer.render(document);
    }

    /**
     * 自定义 HTML 属性提供者
     * - 链接标签自动添加 target="_blank"(新窗口打开)
     * - 表格标签自动添加 CSS class(适配 UI 框架)
     */
    static class CustomAttributeProvider implements AttributeProvider {
        @Override
        public void setAttributes(Node node, String tagName, Map<String, String> attributes) {
            if (node instanceof Link) {
                attributes.put("target", "_blank");
                // 安全考虑:外部链接建议加上 rel="noopener noreferrer"
                attributes.put("rel", "noopener noreferrer");
            }
            if (node instanceof TableBlock) {
                attributes.put("class", "ui celled table");
            }
        }
    }
}

关键点说明

  • CustomAttributeProvider 是 CommonMark 提供的扩展点,可以在渲染 HTML 时修改标签属性。上面的例子中,我们让所有链接在新窗口打开,并为表格添加了 Semantic UI 的样式类。
  • rel="noopener noreferrer" 是安全最佳实践,防止新窗口页面通过 window.opener 访问原页面。

2.3 Spring MVC 控制器集成

在实际项目中,通常需要从 classpath 或数据库读取 Markdown 文件,转换后传递给模板引擎渲染:

import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Controller
@RequestMapping(value = "/portal/docs/")
public class MarkdownToHtmlController {

    @RequestMapping(value = "openapi-framework-{mdName}")
    public String openapiFramework(
            HttpServletRequest request,
            HttpServletResponse response,
            Model model,
            @PathVariable("mdName") String mdName) throws IOException {
        // 从 classpath 读取 Markdown 文件
        String markdown = getJarResource(mdName);
        // 转换为 HTML
        String html = MarkdownToHtmlUtils.markdownToHtmlExtensions(markdown);
        model.addAttribute("markdownToHtml", html);
        return "/docs/docs_index";
    }

    private String getJarResource(String mdName) throws IOException {
        Resource resource = new ClassPathResource("openapi/openapi-framework-" + mdName + ".md");
        byte[] binaryData = FileCopyUtils.copyToByteArray(resource.getInputStream());
        return new String(binaryData, StandardCharsets.UTF_8);
    }
}

2.4 模板页面

在 Thymeleaf 或其他模板引擎中,使用 th:utext 或等效方式输出未转义的 HTML:

<!-- Thymeleaf 示例 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>文档</title>
    <link rel="stylesheet" href="/css/markdown-style.css">
</head>
<body>
    <article class="markdown-body" th:utext="${markdownToHtml}"></article>
</body>
</html>

注意:使用 utext(unescaped text)时要确保 Markdown 来源可信,否则可能存在 XSS 风险。下一节会讲解安全过滤方案。


三、方案二:使用 flexmark-java(推荐)

flexmark-java 是 commonmark-java 的增强版,支持 50 多种扩展,是生产环境的更优选择。

3.1 Maven 依赖

<dependency>
    <groupId>com.vladsch.flexmark</groupId>
    <artifactId>flexmark-all</artifactId>
    <version>0.64.8</version>
</dependency>

3.2 基础使用

import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.util.data.MutableDataSet;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.toc.TocExtension;
import com.vladsch.flexmark.ext.tasklist.TaskListExtension;

import java.util.Arrays;

public class FlexmarkDemo {
    public static String convert(String markdown) {
        // 配置扩展
        MutableDataSet options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(
            TablesExtension.create(),           // GFM 表格
            StrikethroughExtension.create(),    // 删除线 ~~text~~
            TocExtension.create(),              // 目录 [TOC]
            TaskListExtension.create()          // 任务列表 - [ ] / - [x]
        ));

        // 构建解析器和渲染器
        Parser parser = Parser.builder(options).build();
        HtmlRenderer renderer = HtmlRenderer.builder(options).build();

        Node document = parser.parse(markdown);
        return renderer.render(document);
    }
}

3.3 代码语法高亮

flexmark-java 本身不包含代码高亮功能,但可以与外部高亮库配合。推荐方案是在渲染后的 HTML 中集成 highlight.jsPrism.js

<!-- 在页面中引入 highlight.js -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>hljs.highlightAll();</script>

flexmark-java 会将代码块渲染为 <pre><code class="language-java">...</code></pre> 格式,highlight.js 会自动识别并高亮。


四、XSS 安全过滤

如果 Markdown 内容来自用户输入(如评论、帖子),必须进行 XSS 过滤,防止恶意用户注入脚本:

// 使用 Jsoup 进行 HTML 清洗
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;

public class HtmlSanitizer {
    public static String sanitize(String html) {
        // 定义允许的安全标签和属性
        Safelist safelist = Safelist.relaxed()
            .addTags("span", "div", "pre", "code")
            .addAttributes("code", "class")     // 保留代码语言标识
            .addAttributes("a", "target", "rel") // 保留链接属性
            .addProtocols("img", "src", "http", "https", "data");

        return Jsoup.clean(html, safelist);
    }
}

处理流程:Markdown → 解析为 HTML → Jsoup 清洗 → 输出到页面


五、样式方案

Markdown 转 HTML 后,需要配合 CSS 才能获得良好的显示效果。推荐使用以下开源样式:

样式方案 特点 地址
GitHub Markdown CSS 仿 GitHub 风格,最流行 sindresorhus/github-markdown-css
Markdown CSS 简洁现代风格 stewartlord/markdown.css
手写 CSS 完全自定义,匹配项目风格 自行编写

六、总结

场景 推荐方案
简单文档展示 commonmark-java + 表格/锚点扩展
功能丰富的内容平台 flexmark-java + 多种扩展
用户生成内容(UGC) 上述方案 + Jsoup XSS 过滤
需要代码高亮 后端转换 + 前端 highlight.js

在实际项目中,Markdown 转 HTML 看似简单,但涉及扩展语法支持、安全过滤、样式适配等多个环节。选择成熟的库(flexmark-java)并配合安全清洗(Jsoup),可以在保证功能完整的同时避免常见的安全陷阱。