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, "&apos;");
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}