View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.doxia.module.markdown;
20  
21  import javax.inject.Inject;
22  import javax.inject.Named;
23  import javax.inject.Singleton;
24  
25  import java.io.IOException;
26  import java.io.Reader;
27  import java.util.Arrays;
28  import java.util.Collections;
29  import java.util.LinkedHashMap;
30  import java.util.List;
31  import java.util.Map;
32  import java.util.Map.Entry;
33  import java.util.regex.Matcher;
34  import java.util.regex.Pattern;
35  import java.util.stream.Collectors;
36  
37  import com.vladsch.flexmark.ast.Heading;
38  import com.vladsch.flexmark.ast.HtmlCommentBlock;
39  import com.vladsch.flexmark.ext.abbreviation.AbbreviationExtension;
40  import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
41  import com.vladsch.flexmark.ext.definition.DefinitionExtension;
42  import com.vladsch.flexmark.ext.escaped.character.EscapedCharacterExtension;
43  import com.vladsch.flexmark.ext.footnotes.FootnoteExtension;
44  import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
45  import com.vladsch.flexmark.ext.tables.TablesExtension;
46  import com.vladsch.flexmark.ext.typographic.TypographicExtension;
47  import com.vladsch.flexmark.ext.wikilink.WikiLinkExtension;
48  import com.vladsch.flexmark.ext.yaml.front.matter.YamlFrontMatterExtension;
49  import com.vladsch.flexmark.html.HtmlRenderer;
50  import com.vladsch.flexmark.util.ast.Node;
51  import com.vladsch.flexmark.util.ast.TextCollectingVisitor;
52  import com.vladsch.flexmark.util.data.MutableDataSet;
53  import org.apache.commons.io.IOUtils;
54  import org.apache.maven.doxia.markup.HtmlMarkup;
55  import org.apache.maven.doxia.markup.TextMarkup;
56  import org.apache.maven.doxia.module.xhtml5.Xhtml5Parser;
57  import org.apache.maven.doxia.parser.AbstractTextParser;
58  import org.apache.maven.doxia.parser.ParseException;
59  import org.apache.maven.doxia.sink.Sink;
60  import org.apache.maven.doxia.util.HtmlTools;
61  import org.codehaus.plexus.util.xml.pull.XmlPullParser;
62  import org.jsoup.Jsoup;
63  import org.jsoup.nodes.Document;
64  
65  /**
66   * <p>
67   * Implementation of {@link org.apache.maven.doxia.parser.Parser} for Markdown documents.
68   * </p>
69   * <p>
70   * Defers effective parsing to the <a href="https://github.com/vsch/flexmark-java">flexmark-java library</a>,
71   * which generates HTML content then delegates parsing of this content to a slightly modified Doxia Xhtml5 parser.
72   * (before 1.8, the <a href="http://pegdown.org">PegDown library</a> was used)
73   * </p>
74   *
75   * @author Vladimir Schneider
76   * @author Julien Nicoulaud
77   * @since 1.3
78   */
79  @Singleton
80  @Named("markdown")
81  public class MarkdownParser extends AbstractTextParser implements TextMarkup {
82  
83      /**
84       * Regex that identifies a multimarkdown-style metadata section at the start of the document
85       *
86       * In order to ensure that we have minimal risk of false positives when slurping metadata sections, the
87       * first key in the metadata section must be one of these standard keys or else the entire metadata section is
88       * ignored.
89       * @see <a href="https://fletcher.github.io/MultiMarkdown-5/metadata.html">Multimarkdown Metadata</a>
90       */
91      private static final Pattern METADATA_SECTION_PATTERN = Pattern.compile(
92              "\\A^"
93                      + "(?:title|author|date|address|affiliation|copyright|email|keywords|language|phone|subtitle)"
94                      + "[ \\t]*:[\\S\\s]+?^[ \\t]*$",
95              Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
96  
97      /**
98       * Regex that captures the key and value of a multimarkdown-style metadata entry.
99       * 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      * The parser of the HTML produced by Flexmark, that we will
109      * use to convert this HTML to Sink events
110      */
111     @Inject
112     private MarkdownHtmlParser parser;
113 
114     /**
115      * Flexmark's Markdown parser (one static instance fits all)
116      */
117     private static final com.vladsch.flexmark.parser.Parser FLEXMARK_PARSER;
118 
119     /**
120      * Flexmark's Markdown Metadata parser
121      */
122     private static final com.vladsch.flexmark.parser.Parser FLEXMARK_METADATA_PARSER;
123 
124     /**
125      * Flexmark's HTML renderer (its output will be re-parsed and converted to Sink events)
126      */
127     private static final HtmlRenderer FLEXMARK_HTML_RENDERER;
128 
129     // Initialize the Flexmark parser and renderer, once and for all
130     static {
131         MutableDataSet flexmarkOptions = new MutableDataSet();
132 
133         // Enable the extensions that we used to have in Pegdown
134         flexmarkOptions.set(
135                 com.vladsch.flexmark.parser.Parser.EXTENSIONS,
136                 Arrays.asList(
137                         EscapedCharacterExtension.create(),
138                         AbbreviationExtension.create(),
139                         AutolinkExtension.create(),
140                         DefinitionExtension.create(),
141                         TypographicExtension.create(),
142                         TablesExtension.create(),
143                         WikiLinkExtension.create(),
144                         FootnoteExtension.create(),
145                         StrikethroughExtension.create()));
146 
147         // Disable wrong apostrophe replacement
148         flexmarkOptions.set(TypographicExtension.SINGLE_QUOTE_UNMATCHED, "&apos;");
149 
150         // Additional options on the HTML rendering
151         flexmarkOptions.set(HtmlRenderer.HTML_BLOCK_OPEN_TAG_EOL, false);
152         flexmarkOptions.set(HtmlRenderer.HTML_BLOCK_CLOSE_TAG_EOL, false);
153         flexmarkOptions.set(HtmlRenderer.MAX_TRAILING_BLANK_LINES, -1);
154         flexmarkOptions.set(HtmlRenderer.FENCED_CODE_NO_LANGUAGE_CLASS, "nohighlight nocode");
155 
156         // Build the Markdown parser
157         FLEXMARK_PARSER =
158                 com.vladsch.flexmark.parser.Parser.builder(flexmarkOptions).build();
159 
160         MutableDataSet flexmarkMetadataOptions = new MutableDataSet();
161         flexmarkMetadataOptions.set(
162                 com.vladsch.flexmark.parser.Parser.EXTENSIONS, Arrays.asList(YamlFrontMatterExtension.create()));
163         FLEXMARK_METADATA_PARSER = com.vladsch.flexmark.parser.Parser.builder(flexmarkMetadataOptions)
164                 .build();
165 
166         // Build the HTML renderer
167         FLEXMARK_HTML_RENDERER = HtmlRenderer.builder(flexmarkOptions)
168                 .linkResolverFactory(new FlexmarkDoxiaLinkResolver.Factory())
169                 .build();
170     }
171 
172     @Override
173     public void parse(Reader source, Sink sink, String reference) throws ParseException {
174         try {
175             // Markdown to HTML (using flexmark-java library)
176             String xhtml = toXhtml(source);
177 
178             // TODO: add locator for the markdown source (not the intermediate HTML format)
179             // this requires writing a custom renderer not leveraging the XHTML parser
180 
181             // then HTML to Sink API
182             parser.setEmitComments(isEmitComments());
183             parser.parse(xhtml, getWrappedSink(sink), "Intermediate HTML from " + reference);
184         } catch (IOException e) {
185             throw new ParseException("Failed reading Markdown source document", e);
186         }
187     }
188 
189     private boolean processMetadataForHtml(StringBuilder html, StringBuilder source) {
190         final Map<String, List<String>> metadata;
191         final int endOffset; // end of metadata within source
192         // support two types of metadata:
193         if (source.toString().startsWith("---")) {
194             // 1. YAML front matter (https://github.com/vsch/flexmark-java/wiki/Extensions#yaml-front-matter)
195             Node documentRoot = FLEXMARK_METADATA_PARSER.parse(source.toString());
196             YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor();
197             visitor.visit(documentRoot);
198             metadata = visitor.getData();
199             endOffset = visitor.getEndOffset();
200         } else {
201             // 2. Multimarkdown metadata (https://fletcher.github.io/MultiMarkdown-5/metadata.html), not yet supported
202             // by Flexmark (https://github.com/vsch/flexmark-java/issues/550)
203             metadata = new LinkedHashMap<>();
204             Matcher metadataMatcher = METADATA_SECTION_PATTERN.matcher(source);
205             if (metadataMatcher.find()) {
206                 String entry = metadataMatcher.group(0) + EOL;
207                 Matcher entryMatcher = METADATA_ENTRY_PATTERN.matcher(entry);
208                 while (entryMatcher.find()) {
209                     String key = entryMatcher.group(1);
210                     String value = normalizeMultilineValue(entryMatcher.group(2));
211                     metadata.put(key, Collections.singletonList(value));
212                 }
213                 endOffset = metadataMatcher.end(0);
214             } else {
215                 endOffset = 0;
216             }
217         }
218         if (endOffset > 0) {
219             // Trim the metadata from the source
220             source.delete(0, endOffset);
221         }
222         return writeHtmlMetadata(html, metadata);
223     }
224 
225     static String normalizeMultilineValue(String value) {
226         return value.trim().replaceAll("[ \\t]*[\\r\\n]+[ \\t]*", " ");
227     }
228 
229     private boolean writeHtmlMetadata(StringBuilder html, Map<String, List<String>> data) {
230         boolean containsTitle = false;
231         for (Entry<String, List<String>> entry : data.entrySet()) {
232             if (writeHtmlMetadata(html, entry.getKey(), entry.getValue())) {
233                 containsTitle = true;
234             }
235         }
236         return containsTitle;
237     }
238 
239     private boolean writeHtmlMetadata(StringBuilder html, String key, List<String> values) {
240         if ("title".equalsIgnoreCase(key)) {
241             html.append("<title>");
242             html.append(HtmlTools.escapeHTML(values.stream().collect(Collectors.joining(", ")), false));
243             html.append("</title>");
244             return true;
245         } else {
246             if (key.equalsIgnoreCase("author") && values.size() > 1) {
247                 // for multiple authors emit multiple meta tags
248                 for (String value : values) {
249                     writeHtmlMetadata(html, key, Collections.singletonList(value));
250                 }
251             } else {
252                 // every other multi-value should just be concatenated and emitted in a single meta tag
253                 final String separator;
254                 if (key.equalsIgnoreCase("keywords")) {
255                     separator = ",";
256                 } else {
257                     separator = EOL;
258                 }
259                 html.append("<meta name='");
260                 html.append(HtmlTools.escapeHTML(key));
261                 html.append("' content='");
262                 html.append(HtmlTools.escapeHTML(values.stream().collect(Collectors.joining(separator))));
263                 html.append("' />");
264             }
265             return false;
266         }
267     }
268 
269     /**
270      * uses flexmark-java library to parse content and generate HTML output.
271      *
272      * @param source the Markdown source
273      * @return HTML content generated by flexmark-java
274      * @throws IOException passed through
275      */
276     String toXhtml(Reader source) throws IOException {
277         // Read the source
278         StringBuilder markdownText = new StringBuilder(IOUtils.toString(source));
279 
280         // Now, build the HTML document
281         StringBuilder html = new StringBuilder(1000);
282         html.append("<html>");
283         html.append("<head>");
284 
285         boolean haveTitle = processMetadataForHtml(html, markdownText);
286 
287         // Now is the time to parse the Markdown document
288         // (after we've trimmed out the metadatas, and before we check for its headings)
289         Node documentRoot = FLEXMARK_PARSER.parse(markdownText.toString());
290 
291         // Special trick: if there is no title specified as a metadata in the header, we will use the first
292         // heading as the document title
293         if (!haveTitle && documentRoot.hasChildren()) {
294             // Skip the comment nodes
295             Node firstNode = documentRoot.getFirstChild();
296             while (firstNode != null && firstNode instanceof HtmlCommentBlock) {
297                 firstNode = firstNode.getNext();
298             }
299 
300             // If this first non-comment node is a heading, we use it as the document title
301             if (firstNode != null && firstNode instanceof Heading) {
302                 html.append("<title>");
303                 TextCollectingVisitor collectingVisitor = new TextCollectingVisitor();
304                 String headingText = collectingVisitor.collectAndGetText(firstNode);
305                 html.append(HtmlTools.escapeHTML(headingText, false));
306                 html.append("</title>");
307             }
308         }
309         html.append("</head>");
310         html.append("<body>");
311 
312         // Convert our Markdown document to HTML and append it to our HTML
313         FLEXMARK_HTML_RENDERER.render(documentRoot, html);
314 
315         html.append("</body>");
316         html.append("</html>");
317 
318         return toXhtml(html.toString());
319     }
320 
321     private String toXhtml(String html) {
322         final Document document = Jsoup.parse(html);
323         document.outputSettings().syntax(Document.OutputSettings.Syntax.xml).prettyPrint(false);
324         return document.html();
325     }
326 
327     /**
328      * Internal parser for HTML generated by the Markdown library.
329      *
330      * 2 special things:
331      * <ul>
332      * <li> DIV elements are translated as Unknown Sink events
333      * </ul>
334      * PRE elements need to be "source" because the Xhtml5Sink will surround the
335      * corresponding verbatim() Sink event with a DIV element with class="source",
336      * which is how most Maven Skin (incl. Fluido) recognize a block of code, which
337      * needs to be highlighted accordingly.
338      */
339     @Named
340     public static class MarkdownHtmlParser extends Xhtml5Parser {
341         public MarkdownHtmlParser() {
342             super();
343         }
344 
345         @Override
346         protected void init() {
347             super.init();
348         }
349 
350         @Override
351         protected boolean baseEndTag(XmlPullParser parser, Sink sink) {
352             boolean visited = super.baseEndTag(parser, sink);
353             if (!visited) {
354                 if (parser.getName().equals(HtmlMarkup.DIV.toString())) {
355                     handleUnknown(parser, sink, TAG_TYPE_END);
356                     visited = true;
357                 }
358             }
359             return visited;
360         }
361 
362         @Override
363         protected boolean baseStartTag(XmlPullParser parser, Sink sink) {
364             boolean visited = super.baseStartTag(parser, sink);
365             if (!visited) {
366                 if (parser.getName().equals(HtmlMarkup.DIV.toString())) {
367                     handleUnknown(parser, sink, TAG_TYPE_START);
368                     visited = true;
369                 }
370             }
371             return visited;
372         }
373     }
374 }