SpringBoot实现Markdown语法转HTML标签
大家也可以自己拷贝源码地址
GitHub源码仓库地址
1项目启动
pom.xml文件
<properties><maven.compiler.source>8</maven.compiler.source><maven.compiler.target>8</maven.compiler.target><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties><parent><artifactId>spring-boot-starter-parent</artifactId><groupId>org.springframework.boot</groupId><version>2.7.1</version></parent><dependencies><!-- 将 Markdown 转换为 HTML --><dependency><groupId>com.vladsch.flexmark</groupId><artifactId>flexmark-all</artifactId><version>0.62.2</version></dependency><!--以下为基础依赖--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies>
核心文件
public class MarkdownConverter {// 定义一个静态方法,将 Markdown 文本转换为 HTMLpublic static String markdownToHtml(String markdown) {// 创建一个 MutableDataSet 对象来配置 Markdown 解析器的选项MutableDataSet options = new MutableDataSet();// 添加各种 Markdown 解析器的扩展options.set(Parser.EXTENSIONS, Arrays.asList(AutolinkExtension.create(), // 自动链接扩展,将URL文本转换为链接(如 http://example.com)自动变为 <a href="...">EmojiExtension.create(), // 表情符号扩展,用于解析表情符号(如 :smile: 转换为 😄)GitLabExtension.create(), // GitLab特有的Markdown扩展(如支持 ~~删除线~~)FootnoteExtension.create(), // 脚注扩展,用于添加和解析脚注(如 [^1])TaskListExtension.create(), // 任务列表扩展,用于创建任务列表(如 - [x] 已完成)
// CustomAdmonitionExtension.create(), // 提示框扩展,用于创建提示框(如 !!! note 创建提示框)TablesExtension.create())); // 表格扩展,用于解析和渲染表格(如 | 表头1 | 表头2 |)// 使用配置的选项构建一个 Markdown 解析器Parser parser = Parser.builder(options).build();//Markdown-->抽象语法树// 使用相同的选项构建一个 HTML 渲染器HtmlRenderer renderer = HtmlRenderer.builder(options).build();//抽象语法树-->HTML// 解析传入的 Markdown 文本并将其渲染为 HTMLreturn renderer.render(parser.parse(markdown));}
}
controller代码
@RestController
public class MarkdownController {@PostMapping("/test")public String markdownToHtml(String content) {return MarkdownConverter.markdownToHtml(content);}
}
启动入口
@SpringBootApplication
public class RunApplication {public static void main(String[] args) {SpringApplication.run(RunApplication.class, args);}
}
2 运行测试
2.1 基础markdown语法测试
输入内容
# 标题1## 标题2**粗体文本***斜体文本*`行内代码`> 引用文本
结果如下
2.2 自动链接扩展测试文本
输入内容
访问我们的网站 http://example.com 获取更多信息
结果如下
2.3 表情符号扩展测试文本
输入内容
这是一个微笑表情 :smile: 和大笑表情 :laughing:
结果如下
2.4 GitLab扩展测试文本
输入内容:
这是删除线文本 ~~删除线~~
结果如下:
这里看到删除线并不能正确解析,该问题待解决
2.5 脚注扩展测试文本
输入内容:
这是一个带有脚注的文本[^1].
[^1]: 这是脚注的内容。
结果如下:
2.6 任务列表扩展测试文本
输入内容:
- [x] 已完成的任务
- [ ] 未完成的任务
结果如下:
2.7 表格扩展测试文本
输入内容:
| 表头1 | 表头2 |
|-------|-------|
| 内容1 | 内容2 |
结果如下:
3 自定义解析器
我们想解析
!!! note这是一个提示框的内容
3.1 自定义解析器
增加以下两个类
public class CustomAdmonitionBlockParser extends AbstractBlockParser {final private static String ADMONITION_START_FORMAT = "^(\\?{3}\\+|\\?{3}|!{3}|:{3})\\s*(%s)(?:\\s+(%s))?\\s*$";final AdmonitionBlock block;//private BlockContent content = new BlockContent();final private AdmonitionOptions options;final private int contentIndent;private boolean hadBlankLine;private boolean isOver;CustomAdmonitionBlockParser(AdmonitionOptions options, int contentIndent) {this.options = options;this.contentIndent = contentIndent;this.block = new AdmonitionBlock();}private int getContentIndent() {return contentIndent;}@Overridepublic Block getBlock() {return block;}@Overridepublic boolean isContainer() {return true;}@Overridepublic boolean canContain(ParserState state, BlockParser blockParser, final Block block) {return true;}@Overridepublic BlockContinue tryContinue(ParserState state) {// 获取当前行内容BasedSequence line = state.getLine();final int nonSpaceIndex = state.getNextNonSpaceIndex();// 判断是否是终止符 "!!!"if (isOver) {return BlockContinue.none();}if (line.startsWith("!!!") || line.startsWith("???") || line.startsWith(":::")) {isOver = true;// 停止解析}// 如果当前行是空行,则继续解析,同时标记块中出现过空行if (state.isBlank()) {hadBlankLine = true;return BlockContinue.atIndex(nonSpaceIndex);}// 如果允许懒惰继续(lazy continuation),且未遇到空行if (!hadBlankLine && options.allowLazyContinuation) {return BlockContinue.atIndex(nonSpaceIndex);}// 如果缩进足够,则继续解析当前行if (state.getIndent() >= options.contentIndent) {int contentIndent = state.getColumn() + options.contentIndent;return BlockContinue.atColumn(contentIndent);}// 默认情况,继续解析当前行return BlockContinue.atIndex(nonSpaceIndex);}@Overridepublic void closeBlock(ParserState state) {block.setCharsFromContent();}public static class Factory implements CustomBlockParserFactory {@Nullable@Overridepublic Set<Class<?>> getAfterDependents() {return null;}@Nullable@Overridepublic Set<Class<?>> getBeforeDependents() {return null;}@Overridepublic @Nullable SpecialLeadInHandler getLeadInHandler(@NotNull DataHolder options) {return AdmonitionLeadInHandler.HANDLER;}@Overridepublic boolean affectsGlobalScope() {return false;}@NotNull@Overridepublic BlockParserFactory apply(@NotNull DataHolder options) {return new BlockFactory(options);}}static class AdmonitionLeadInHandler implements SpecialLeadInHandler {final static SpecialLeadInHandler HANDLER = new AdmonitionLeadInHandler();@Overridepublic boolean escape(@NotNull BasedSequence sequence, @Nullable DataHolder options, @NotNull Consumer<CharSequence> consumer) {if ((sequence.length() == 3 || sequence.length() == 4 && sequence.charAt(3) == '+') && (sequence.startsWith("???") || sequence.startsWith("!!!") || sequence.startsWith(":::"))) {consumer.accept("\\");consumer.accept(sequence);return true;}return false;}@Overridepublic boolean unEscape(@NotNull BasedSequence sequence, @Nullable DataHolder options, @NotNull Consumer<CharSequence> consumer) {if ((sequence.length() == 4 || sequence.length() == 5 && sequence.charAt(4) == '+') && (sequence.startsWith("\\???") || sequence.startsWith("\\!!!") || sequence.startsWith("\\:::"))) {consumer.accept(sequence.subSequence(1));return true;}return false;}}static boolean isMarker(final ParserState state,final int index,final boolean inParagraph,final boolean inParagraphListItem,final AdmonitionOptions options) {final boolean allowLeadingSpace = options.allowLeadingSpace;final boolean interruptsParagraph = options.interruptsParagraph;final boolean interruptsItemParagraph = options.interruptsItemParagraph;final boolean withLeadSpacesInterruptsItemParagraph = options.withSpacesInterruptsItemParagraph;CharSequence line = state.getLine();if (!inParagraph || interruptsParagraph) {if ((allowLeadingSpace || state.getIndent() == 0) && (!inParagraphListItem || interruptsItemParagraph)) {if (inParagraphListItem && !withLeadSpacesInterruptsItemParagraph) {return state.getIndent() == 0;} else {return state.getIndent() < state.getParsing().CODE_BLOCK_INDENT;}}}return false;}private static class BlockFactory extends AbstractBlockParserFactory {final private AdmonitionOptions options;BlockFactory(DataHolder options) {super(options);this.options = new AdmonitionOptions(options);}@Overridepublic BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {if (state.getIndent() >= 4) {return BlockStart.none();}int nextNonSpace = state.getNextNonSpaceIndex();BlockParser matched = matchedBlockParser.getBlockParser();boolean inParagraph = matched.isParagraphParser();boolean inParagraphListItem = inParagraph && matched.getBlock().getParent() instanceof ListItem && matched.getBlock() == matched.getBlock().getParent().getFirstChild();if (isMarker(state, nextNonSpace, inParagraph, inParagraphListItem, options)) {BasedSequence line = state.getLine();BasedSequence trySequence = line.subSequence(nextNonSpace, line.length());Parsing parsing = state.getParsing();Pattern startPattern = Pattern.compile(String.format(ADMONITION_START_FORMAT, parsing.ATTRIBUTENAME, parsing.LINK_TITLE_STRING));Matcher matcher = startPattern.matcher(trySequence);if (matcher.find()) {// admonition blockBasedSequence openingMarker = line.subSequence(nextNonSpace + matcher.start(1), nextNonSpace + matcher.end(1));BasedSequence info = line.subSequence(nextNonSpace + matcher.start(2), nextNonSpace + matcher.end(2));BasedSequence titleChars = matcher.group(3) == null ? BasedSequence.NULL : line.subSequence(nextNonSpace + matcher.start(3), nextNonSpace + matcher.end(3));int contentOffset = options.contentIndent;CustomAdmonitionBlockParser admonitionBlockParser = new CustomAdmonitionBlockParser (options, contentOffset);admonitionBlockParser.block.setOpeningMarker(openingMarker);admonitionBlockParser.block.setInfo(info);admonitionBlockParser.block.setTitleChars(titleChars);return BlockStart.of(admonitionBlockParser).atIndex(line.length());} else {return BlockStart.none();}} else {return BlockStart.none();}}}
}
public class CustomAdmonitionExtension implements Parser.ParserExtension, HtmlRenderer.HtmlRendererExtension, Formatter.FormatterExtension// , Parser.ReferenceHoldingExtension
{final public static DataKey<Integer> CONTENT_INDENT = new DataKey<>("ADMONITION.CONTENT_INDENT", 4);final public static DataKey<Boolean> ALLOW_LEADING_SPACE = new DataKey<>("ADMONITION.ALLOW_LEADING_SPACE", true);final public static DataKey<Boolean> INTERRUPTS_PARAGRAPH = new DataKey<>("ADMONITION.INTERRUPTS_PARAGRAPH", true);final public static DataKey<Boolean> INTERRUPTS_ITEM_PARAGRAPH = new DataKey<>("ADMONITION.INTERRUPTS_ITEM_PARAGRAPH", true);final public static DataKey<Boolean> WITH_SPACES_INTERRUPTS_ITEM_PARAGRAPH = new DataKey<>("ADMONITION.WITH_SPACES_INTERRUPTS_ITEM_PARAGRAPH", true);final public static DataKey<Boolean> ALLOW_LAZY_CONTINUATION = new DataKey<>("ADMONITION.ALLOW_LAZY_CONTINUATION", true);final public static DataKey<String> UNRESOLVED_QUALIFIER = new DataKey<>("ADMONITION.UNRESOLVED_QUALIFIER", "note");final public static DataKey<Map<String, String>> QUALIFIER_TYPE_MAP = new DataKey<>("ADMONITION.QUALIFIER_TYPE_MAP", CustomAdmonitionExtension::getQualifierTypeMap);final public static DataKey<Map<String, String>> QUALIFIER_TITLE_MAP = new DataKey<>("ADMONITION.QUALIFIER_TITLE_MAP", CustomAdmonitionExtension::getQualifierTitleMap);final public static DataKey<Map<String, String>> TYPE_SVG_MAP = new DataKey<>("ADMONITION.TYPE_SVG_MAP", CustomAdmonitionExtension::getQualifierSvgValueMap);public static Map<String, String> getQualifierTypeMap() {HashMap<String, String> infoSvgMap = new HashMap<>();// qualifier type mapinfoSvgMap.put("abstract", "abstract");infoSvgMap.put("summary", "abstract");infoSvgMap.put("tldr", "abstract");infoSvgMap.put("bug", "bug");infoSvgMap.put("danger", "danger");infoSvgMap.put("error", "danger");infoSvgMap.put("example", "example");infoSvgMap.put("snippet", "example");infoSvgMap.put("fail", "fail");infoSvgMap.put("failure", "fail");infoSvgMap.put("missing", "fail");infoSvgMap.put("faq", "faq");infoSvgMap.put("question", "faq");infoSvgMap.put("help", "faq");infoSvgMap.put("info", "info");infoSvgMap.put("todo", "info");infoSvgMap.put("note", "note");infoSvgMap.put("seealso", "note");infoSvgMap.put("quote", "quote");infoSvgMap.put("cite", "quote");infoSvgMap.put("success", "success");infoSvgMap.put("check", "success");infoSvgMap.put("done", "success");infoSvgMap.put("tip", "tip");infoSvgMap.put("hint", "tip");infoSvgMap.put("important", "tip");infoSvgMap.put("warning", "warning");infoSvgMap.put("caution", "warning");infoSvgMap.put("attention", "warning");return infoSvgMap;}public static Map<String, String> getQualifierTitleMap() {HashMap<String, String> infoTitleMap = new HashMap<>();infoTitleMap.put("abstract", "Abstract");infoTitleMap.put("summary", "Summary");infoTitleMap.put("tldr", "TLDR");infoTitleMap.put("bug", "Bug");infoTitleMap.put("danger", "Danger");infoTitleMap.put("error", "Error");infoTitleMap.put("example", "Example");infoTitleMap.put("snippet", "Snippet");infoTitleMap.put("fail", "Fail");infoTitleMap.put("failure", "Failure");infoTitleMap.put("missing", "Missing");infoTitleMap.put("faq", "Faq");infoTitleMap.put("question", "Question");infoTitleMap.put("help", "Help");infoTitleMap.put("info", "Info");infoTitleMap.put("todo", "To Do");infoTitleMap.put("note", "Note");infoTitleMap.put("seealso", "See Also");infoTitleMap.put("quote", "Quote");infoTitleMap.put("cite", "Cite");infoTitleMap.put("success", "Success");infoTitleMap.put("check", "Check");infoTitleMap.put("done", "Done");infoTitleMap.put("tip", "Tip");infoTitleMap.put("hint", "Hint");infoTitleMap.put("important", "Important");infoTitleMap.put("warning", "Warning");infoTitleMap.put("caution", "Caution");infoTitleMap.put("attention", "Attention");return infoTitleMap;}public static Map<String, String> getQualifierSvgValueMap() {HashMap<String, String> typeSvgMap = new HashMap<>();typeSvgMap.put("abstract", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-abstract.svg")));typeSvgMap.put("bug", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-bug.svg")));typeSvgMap.put("danger", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-danger.svg")));typeSvgMap.put("example", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-example.svg")));typeSvgMap.put("fail", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-fail.svg")));typeSvgMap.put("faq", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-faq.svg")));typeSvgMap.put("info", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-info.svg")));typeSvgMap.put("note", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-note.svg")));typeSvgMap.put("quote", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-quote.svg")));typeSvgMap.put("success", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-success.svg")));typeSvgMap.put("tip", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-tip.svg")));typeSvgMap.put("warning", getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/images/adm-warning.svg")));return typeSvgMap;}public static String getInputStreamContent(InputStream inputStream) {try {InputStreamReader streamReader = new InputStreamReader(inputStream);StringWriter stringWriter = new StringWriter();copy(streamReader, stringWriter);stringWriter.close();return stringWriter.toString();} catch (Exception e) {e.printStackTrace();return "";}}public static String getDefaultCSS() {return getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/admonition.css"));}public static String getDefaultScript() {return getInputStreamContent(CustomAdmonitionExtension.class.getResourceAsStream("/admonition.js"));}public static void copy(Reader reader, Writer writer) throws IOException {char[] buffer = new char[4096];int n;while (-1 != (n = reader.read(buffer))) {writer.write(buffer, 0, n);}writer.flush();reader.close();}private CustomAdmonitionExtension() {}public static CustomAdmonitionExtension create() {return new CustomAdmonitionExtension();}@Overridepublic void extend(Formatter.Builder formatterBuilder) {formatterBuilder.nodeFormatterFactory(new AdmonitionNodeFormatter.Factory());}@Overridepublic void rendererOptions(@NotNull MutableDataHolder options) {}@Overridepublic void parserOptions(MutableDataHolder options) {}@Overridepublic void extend(Parser.Builder parserBuilder) {parserBuilder.customBlockParserFactory(new CustomAdmonitionBlockParser.Factory());}@Overridepublic void extend(@NotNull HtmlRenderer.Builder htmlRendererBuilder, @NotNull String rendererType) {if (htmlRendererBuilder.isRendererType("HTML")) {htmlRendererBuilder.nodeRendererFactory(new AdmonitionNodeRenderer.Factory());} else if (htmlRendererBuilder.isRendererType("JIRA")) {}}
}
MarkdownConverter类中增加以下解析器
options.set(Parser.EXTENSIONS, Arrays.asList(// ... 其他扩展CustomAdmonitionExtension.create(), // 提示框扩展,用于创建提示框(如 !!! note 创建提示框)
));
3.2 测试自定义解析器
输入内容:
!!! note这是一个提示框的内容
结果如下: