001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.apache.maven.doxia.module.markdown; 020 021import javax.inject.Inject; 022import javax.inject.Named; 023import javax.inject.Singleton; 024 025import java.io.IOException; 026import java.io.Reader; 027import java.util.Arrays; 028import java.util.Collections; 029import java.util.LinkedHashMap; 030import java.util.List; 031import java.util.Map; 032import java.util.Map.Entry; 033import java.util.regex.Matcher; 034import java.util.regex.Pattern; 035import java.util.stream.Collectors; 036 037import com.vladsch.flexmark.ast.Heading; 038import com.vladsch.flexmark.ast.HtmlCommentBlock; 039import com.vladsch.flexmark.ext.abbreviation.AbbreviationExtension; 040import com.vladsch.flexmark.ext.autolink.AutolinkExtension; 041import com.vladsch.flexmark.ext.definition.DefinitionExtension; 042import com.vladsch.flexmark.ext.escaped.character.EscapedCharacterExtension; 043import com.vladsch.flexmark.ext.footnotes.FootnoteExtension; 044import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension; 045import com.vladsch.flexmark.ext.tables.TablesExtension; 046import com.vladsch.flexmark.ext.typographic.TypographicExtension; 047import com.vladsch.flexmark.ext.wikilink.WikiLinkExtension; 048import com.vladsch.flexmark.ext.yaml.front.matter.YamlFrontMatterExtension; 049import com.vladsch.flexmark.html.HtmlRenderer; 050import com.vladsch.flexmark.util.ast.Node; 051import com.vladsch.flexmark.util.ast.TextCollectingVisitor; 052import com.vladsch.flexmark.util.data.MutableDataSet; 053import org.apache.maven.doxia.markup.HtmlMarkup; 054import org.apache.maven.doxia.markup.TextMarkup; 055import org.apache.maven.doxia.module.xhtml5.Xhtml5Parser; 056import org.apache.maven.doxia.parser.AbstractTextParser; 057import org.apache.maven.doxia.parser.ParseException; 058import org.apache.maven.doxia.sink.Sink; 059import org.apache.maven.doxia.util.HtmlTools; 060import org.codehaus.plexus.util.xml.pull.XmlPullParser; 061import org.jsoup.Jsoup; 062import org.jsoup.nodes.Document; 063import org.jsoup.parser.Tag; 064 065/** 066 * <p> 067 * Implementation of {@link org.apache.maven.doxia.parser.Parser} for Markdown documents. 068 * </p> 069 * <p> 070 * Defers effective parsing to the <a href="https://github.com/vsch/flexmark-java">flexmark-java library</a>, 071 * which generates HTML content then delegates parsing of this content to a slightly modified Doxia Xhtml5 parser. 072 * (before 1.8, the <a href="http://pegdown.org">PegDown library</a> was used) 073 * </p> 074 * 075 * @author Vladimir Schneider 076 * @author Julien Nicoulaud 077 * @since 1.3 078 */ 079@Singleton 080@Named("markdown") 081public class MarkdownParser extends AbstractTextParser implements TextMarkup { 082 083 /** 084 * Regex that identifies a multimarkdown-style metadata section at the start of the document 085 * 086 * In order to ensure that we have minimal risk of false positives when slurping metadata sections, the 087 * first key in the metadata section must be one of these standard keys or else the entire metadata section is 088 * ignored. 089 * @see <a href="https://fletcher.github.io/MultiMarkdown-5/metadata.html">Multimarkdown Metadata</a> 090 */ 091 private static final Pattern METADATA_SECTION_PATTERN = Pattern.compile( 092 "\\A^" 093 + "(?:title|author|date|address|affiliation|copyright|email|keywords|language|phone|subtitle)" 094 + "[ \\t]*:[\\S\\s]+?^[ \\t]*$", 095 Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); 096 097 /** 098 * Regex that captures the key and value of a multimarkdown-style metadata entry. 099 * Group 1 captures the key, group 2 captures the value. Multivalues are not supported in the syntax! 100 * Multiline values need to be normalized 101 * @see <a href="https://fletcher.github.io/MultiMarkdown-5/metadata.html">Multimarkdown Metadata</a> 102 * 103 */ 104 private static final Pattern METADATA_ENTRY_PATTERN = Pattern.compile( 105 "^([^:\\r\\n]+?)[ \\t]*:([\\S\\s]+?)(?=(?:^(?:[^:\\r\\n]+?)[ \\t]*:)|^[ \\t]*$)", Pattern.MULTILINE); 106 107 /** 108 * Matches a self-closing HTML tag, e.g. {@code <object type="image/svg+xml" data="x.svg"/>}. Group 1 is the 109 * tag name, group 2 is the raw attribute string. Quoted attribute values are matched as a whole so a 110 * {@code /} inside one (e.g. a {@code data="a/b"} attribute) is not mistaken for the closing slash. 111 */ 112 private static final Pattern SELF_CLOSING_HTML_TAG = 113 Pattern.compile("<([a-zA-Z][a-zA-Z0-9:-]*)((?:\"[^\"]*\"|'[^']*'|[^\"'/>])*?)\\s*/>"); 114 115 /** 116 * The parser of the HTML produced by Flexmark, that we will 117 * use to convert this HTML to Sink events 118 */ 119 @Inject 120 private MarkdownHtmlParser parser; 121 122 /** 123 * Flexmark's Markdown parser (one static instance fits all) 124 */ 125 private static final com.vladsch.flexmark.parser.Parser FLEXMARK_PARSER; 126 127 /** 128 * Flexmark's Markdown Metadata parser 129 */ 130 private static final com.vladsch.flexmark.parser.Parser FLEXMARK_METADATA_PARSER; 131 132 /** 133 * Flexmark's HTML renderer (its output will be re-parsed and converted to Sink events) 134 */ 135 private static final HtmlRenderer FLEXMARK_HTML_RENDERER; 136 137 // Initialize the Flexmark parser and renderer, once and for all 138 static { 139 MutableDataSet flexmarkOptions = new MutableDataSet(); 140 141 // Enable the extensions that we used to have in Pegdown 142 flexmarkOptions.set( 143 com.vladsch.flexmark.parser.Parser.EXTENSIONS, 144 Arrays.asList( 145 EscapedCharacterExtension.create(), 146 AbbreviationExtension.create(), 147 AutolinkExtension.create(), 148 DefinitionExtension.create(), 149 TypographicExtension.create(), 150 TablesExtension.create(), 151 WikiLinkExtension.create(), 152 FootnoteExtension.create(), 153 StrikethroughExtension.create())); 154 155 // Disable wrong apostrophe replacement 156 flexmarkOptions.set(TypographicExtension.SINGLE_QUOTE_UNMATCHED, "'"); 157 158 // Additional options on the HTML rendering 159 flexmarkOptions.set(HtmlRenderer.HTML_BLOCK_OPEN_TAG_EOL, false); 160 flexmarkOptions.set(HtmlRenderer.HTML_BLOCK_CLOSE_TAG_EOL, false); 161 flexmarkOptions.set(HtmlRenderer.MAX_TRAILING_BLANK_LINES, -1); 162 flexmarkOptions.set(HtmlRenderer.FENCED_CODE_NO_LANGUAGE_CLASS, "nohighlight nocode"); 163 164 // Build the Markdown parser 165 FLEXMARK_PARSER = 166 com.vladsch.flexmark.parser.Parser.builder(flexmarkOptions).build(); 167 168 MutableDataSet flexmarkMetadataOptions = new MutableDataSet(); 169 flexmarkMetadataOptions.set( 170 com.vladsch.flexmark.parser.Parser.EXTENSIONS, Arrays.asList(YamlFrontMatterExtension.create())); 171 FLEXMARK_METADATA_PARSER = com.vladsch.flexmark.parser.Parser.builder(flexmarkMetadataOptions) 172 .build(); 173 174 // Build the HTML renderer 175 FLEXMARK_HTML_RENDERER = HtmlRenderer.builder(flexmarkOptions) 176 .linkResolverFactory(new FlexmarkDoxiaLinkResolver.Factory()) 177 .build(); 178 } 179 180 @Override 181 public void parse(Reader source, Sink sink, String reference) throws ParseException { 182 try { 183 // Markdown to HTML (using flexmark-java library) 184 String xhtml = toXhtml(source); 185 186 // TODO: add locator for the markdown source (not the intermediate HTML format) 187 // this requires writing a custom renderer not leveraging the XHTML parser 188 189 // then HTML to Sink API 190 parser.setEmitComments(isEmitComments()); 191 parser.parse(xhtml, getWrappedSink(sink), "Intermediate HTML from " + reference); 192 } catch (IOException e) { 193 throw new ParseException("Failed reading Markdown source document", e); 194 } 195 } 196 197 private boolean processMetadataForHtml(StringBuilder html, StringBuilder source) { 198 final Map<String, List<String>> metadata; 199 final int endOffset; // end of metadata within source 200 // support two types of metadata: 201 if (source.toString().startsWith("---")) { 202 // 1. YAML front matter (https://github.com/vsch/flexmark-java/wiki/Extensions#yaml-front-matter) 203 Node documentRoot = FLEXMARK_METADATA_PARSER.parse(source.toString()); 204 YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); 205 visitor.visit(documentRoot); 206 metadata = visitor.getData(); 207 endOffset = visitor.getEndOffset(); 208 } else { 209 // 2. Multimarkdown metadata (https://fletcher.github.io/MultiMarkdown-5/metadata.html), not yet supported 210 // by Flexmark (https://github.com/vsch/flexmark-java/issues/550) 211 metadata = new LinkedHashMap<>(); 212 Matcher metadataMatcher = METADATA_SECTION_PATTERN.matcher(source); 213 if (metadataMatcher.find()) { 214 String entry = metadataMatcher.group(0) + EOL; 215 Matcher entryMatcher = METADATA_ENTRY_PATTERN.matcher(entry); 216 while (entryMatcher.find()) { 217 String key = entryMatcher.group(1); 218 String value = normalizeMultilineValue(entryMatcher.group(2)); 219 metadata.put(key, Collections.singletonList(value)); 220 } 221 endOffset = metadataMatcher.end(0); 222 } else { 223 endOffset = 0; 224 } 225 } 226 if (endOffset > 0) { 227 // Trim the metadata from the source 228 source.delete(0, endOffset); 229 } 230 return writeHtmlMetadata(html, metadata); 231 } 232 233 static String normalizeMultilineValue(String value) { 234 return value.trim().replaceAll("[ \\t]*[\\r\\n]+[ \\t]*", " "); 235 } 236 237 private boolean writeHtmlMetadata(StringBuilder html, Map<String, List<String>> data) { 238 boolean containsTitle = false; 239 for (Entry<String, List<String>> entry : data.entrySet()) { 240 if (writeHtmlMetadata(html, entry.getKey(), entry.getValue())) { 241 containsTitle = true; 242 } 243 } 244 return containsTitle; 245 } 246 247 private boolean writeHtmlMetadata(StringBuilder html, String key, List<String> values) { 248 if ("title".equalsIgnoreCase(key)) { 249 html.append("<title>"); 250 html.append(HtmlTools.escapeHTML(values.stream().collect(Collectors.joining(", ")), false)); 251 html.append("</title>"); 252 return true; 253 } else { 254 if (key.equalsIgnoreCase("author") && values.size() > 1) { 255 // for multiple authors emit multiple meta tags 256 for (String value : values) { 257 writeHtmlMetadata(html, key, Collections.singletonList(value)); 258 } 259 } else { 260 // every other multi-value should just be concatenated and emitted in a single meta tag 261 final String separator; 262 if (key.equalsIgnoreCase("keywords")) { 263 separator = ","; 264 } else { 265 separator = EOL; 266 } 267 html.append("<meta name='"); 268 html.append(HtmlTools.escapeHTML(key)); 269 html.append("' content='"); 270 html.append(HtmlTools.escapeHTML(values.stream().collect(Collectors.joining(separator)))); 271 html.append("' />"); 272 } 273 return false; 274 } 275 } 276 277 /** 278 * uses flexmark-java library to parse content and generate HTML output. 279 * 280 * @param source the Markdown source 281 * @return HTML content generated by flexmark-java 282 * @throws IOException passed through 283 */ 284 String toXhtml(Reader source) throws IOException { 285 // Read the source 286 StringBuilder markdownText = new StringBuilder(); 287 char[] buffer = new char[8192]; 288 int n; 289 while ((n = source.read(buffer)) != -1) { 290 markdownText.append(buffer, 0, n); 291 } 292 293 // Now, build the HTML document 294 StringBuilder html = new StringBuilder(1000); 295 html.append("<html>"); 296 html.append("<head>"); 297 298 boolean haveTitle = processMetadataForHtml(html, markdownText); 299 300 // Now is the time to parse the Markdown document 301 // (after we've trimmed out the metadatas, and before we check for its headings) 302 Node documentRoot = FLEXMARK_PARSER.parse(markdownText.toString()); 303 304 // Special trick: if there is no title specified as a metadata in the header, we will use the first 305 // heading as the document title 306 if (!haveTitle && documentRoot.hasChildren()) { 307 // Skip the comment nodes 308 Node firstNode = documentRoot.getFirstChild(); 309 while (firstNode != null && firstNode instanceof HtmlCommentBlock) { 310 firstNode = firstNode.getNext(); 311 } 312 313 // If this first non-comment node is a heading, we use it as the document title 314 if (firstNode != null && firstNode instanceof Heading) { 315 html.append("<title>"); 316 TextCollectingVisitor collectingVisitor = new TextCollectingVisitor(); 317 String headingText = collectingVisitor.collectAndGetText(firstNode); 318 html.append(HtmlTools.escapeHTML(headingText, false)); 319 html.append("</title>"); 320 } 321 } 322 html.append("</head>"); 323 html.append("<body>"); 324 325 // Convert our Markdown document to HTML and append it to our HTML 326 FLEXMARK_HTML_RENDERER.render(documentRoot, html); 327 328 html.append("</body>"); 329 html.append("</html>"); 330 331 return toXhtml(html.toString()); 332 } 333 334 private String toXhtml(String html) { 335 final Document document = Jsoup.parse(closeSelfClosingNonVoidHtmlTags(html)); 336 document.outputSettings().syntax(Document.OutputSettings.Syntax.xml).prettyPrint(false); 337 return document.html(); 338 } 339 340 /** 341 * Raw HTML embedded in a Markdown source (e.g. {@code <object type="image/svg+xml" data="x.svg"/>}) is passed 342 * through untouched by flexmark. HTML5 has no such thing as a self-closing tag outside the fixed list of void 343 * elements (br, img, hr, ...): for every other element the trailing slash is simply ignored, so 344 * {@code <object .../>} is an open tag with no matching close. Left as-is, {@link Jsoup#parse(String)} then 345 * nests all following content inside that dangling {@code <object>}, and the eventual close tag is emitted only 346 * once the surrounding block ends - silently hiding everything in between (browsers treat it as {@code <object>} 347 * fallback content). Close such tags explicitly before jsoup ever sees them, so the element does not swallow its 348 * siblings. 349 * 350 * @param html the HTML generated by rendering the Markdown document 351 * @return the same HTML with self-closing non-void tags rewritten to an explicit open/close pair 352 */ 353 private static String closeSelfClosingNonVoidHtmlTags(String html) { 354 Matcher matcher = SELF_CLOSING_HTML_TAG.matcher(html); 355 StringBuffer result = new StringBuffer(); 356 while (matcher.find()) { 357 String tagName = matcher.group(1); 358 String attributes = matcher.group(2); 359 if (Tag.valueOf(tagName).isSelfClosing()) { 360 matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group())); 361 } else { 362 matcher.appendReplacement( 363 result, Matcher.quoteReplacement("<" + tagName + attributes + "></" + tagName + ">")); 364 } 365 } 366 matcher.appendTail(result); 367 return result.toString(); 368 } 369 370 /** 371 * Internal parser for HTML generated by the Markdown library. 372 * 373 * 2 special things: 374 * <ul> 375 * <li> DIV elements are translated as Unknown Sink events 376 * </ul> 377 * A code block does not carry the {@code source} decoration on its {@code verbatim} event, unlike the APT 378 * and XDoc modules. The language of a fenced code block has to be conveyed anyway, and the {@code inline} 379 * event emitted for the nested CODE element carries both that and the code semantics, so the decoration 380 * would add nothing. See {@code fencedCodeBlockSinkEvent} in the module tests for the exact events. 381 */ 382 @Named 383 public static class MarkdownHtmlParser extends Xhtml5Parser { 384 public MarkdownHtmlParser() { 385 super(); 386 } 387 388 @Override 389 protected void init() { 390 super.init(); 391 } 392 393 @Override 394 protected boolean baseEndTag(XmlPullParser parser, Sink sink) { 395 boolean visited = super.baseEndTag(parser, sink); 396 if (!visited) { 397 if (parser.getName().equals(HtmlMarkup.DIV.toString())) { 398 handleUnknown(parser, sink, TAG_TYPE_END); 399 visited = true; 400 } 401 } 402 return visited; 403 } 404 405 @Override 406 protected boolean baseStartTag(XmlPullParser parser, Sink sink) { 407 boolean visited = super.baseStartTag(parser, sink); 408 if (!visited) { 409 if (parser.getName().equals(HtmlMarkup.DIV.toString())) { 410 handleUnknown(parser, sink, TAG_TYPE_START); 411 visited = true; 412 } 413 } 414 return visited; 415 } 416 } 417}