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.swing.text.AttributeSet;
22  import javax.swing.text.MutableAttributeSet;
23  
24  import java.io.Writer;
25  import java.util.ArrayList;
26  import java.util.Arrays;
27  import java.util.Collection;
28  import java.util.Collections;
29  import java.util.LinkedList;
30  import java.util.List;
31  import java.util.Queue;
32  import java.util.stream.Collectors;
33  
34  import org.apache.maven.doxia.sink.Sink;
35  import org.apache.maven.doxia.sink.SinkEventAttributes;
36  import org.apache.maven.doxia.sink.impl.SinkEventAttributeSet;
37  import org.apache.maven.doxia.sink.impl.Xhtml5BaseSink;
38  import org.apache.maven.doxia.util.DoxiaStringUtils;
39  import org.apache.maven.doxia.util.HtmlTools;
40  import org.slf4j.Logger;
41  import org.slf4j.LoggerFactory;
42  
43  /**
44   * Markdown generator implementation.
45   * <br>
46   * <b>Note</b>: The encoding used is UTF-8.
47   * Extends the Xhtml5 sink as in some context HTML needs to be emitted.
48   */
49  public class MarkdownSink extends Xhtml5BaseSink implements MarkdownMarkup {
50      private static final Logger LOGGER = LoggerFactory.getLogger(MarkdownSink.class);
51  
52      // ----------------------------------------------------------------------
53      // Instance fields
54      // ----------------------------------------------------------------------
55  
56      /** author. */
57      private Collection<String> authors;
58  
59      /** title. */
60      private String title;
61  
62      /** date. */
63      private String date;
64  
65      /** linkName. */
66      private String linkName;
67  
68      /** tableHeaderCellFlag, set to {@code true} for table rows containing at least one table header cell */
69      private boolean tableHeaderCellFlag;
70  
71      /** number of cells in a table. */
72      private int cellCount;
73  
74      /** justification of table cells per column. */
75      private List<Integer> cellJustif;
76  
77      /** is header row */
78      private boolean isFirstTableRow;
79  
80      /** The inner decorated writer to buffer the text of contexts requiring buffering. Writing to this and {@code bufferingWriter} has the same effect. */
81      private final BufferingStackWriter bufferingStackWriter;
82  
83      /** The outer decorated writer taking care of remembering the last two written lines. Writing to this and {@code writer} has the same effect. */
84      private final LastTwoLinesAwareWriter lineAwareWriter;
85  
86      private static final String USE_XHTML_SINK = "XhtmlSink";
87  
88      /** Keep track of end markup for inline events. Special value  {@link #USE_XHTML_SINK} is used to indicate usage of the Xhtml5BaseSink.inline_()*/
89      protected Queue<Queue<String>> inlineStack;
90  
91      /** The context of the surrounding elements as stack (LIFO) */
92      protected Queue<ElementContext> elementContextStack;
93  
94      private String figureSrc;
95  
96      /** flag if the current verbatim block added a HTML context or not */
97      private boolean isVerbatimHtmlContext;
98  
99      @FunctionalInterface
100     interface TextEscapeFunction {
101         String escape(ElementContext context, LastTwoLinesAwareWriter writer, String text);
102     }
103     /** Most important contextual metadata (of elements). This contains information about necessary escaping rules, potential prefixes and newlines */
104     enum ElementContext {
105         ROOT_WITH_BUFFERING(
106                 Type.GENERIC_CONTAINER,
107                 true,
108                 ElementContext::escapeMarkdown,
109                 true), // only needs buffering until head()_ is called to make sure to emit metadata first
110         ROOT_WITHOUT_BUFFERING(
111                 Type.GENERIC_CONTAINER,
112                 true,
113                 null,
114                 false), // used after head()_/body() to prevent unnecessary buffering
115         HEAD(Type.GENERIC_CONTAINER, false, null, true),
116         BODY(Type.GENERIC_CONTAINER, true, ElementContext::escapeMarkdown),
117         // only the elements, which affect rendering of children and are different from BODY or HEAD are listed here
118         FIGURE(Type.INLINE, false, ElementContext::escapeMarkdown, true),
119         HEADING(Type.LEAF_BLOCK, false, ElementContext::escapeMarkdown),
120         CODE_BLOCK(Type.LEAF_BLOCK, false, null),
121         CODE_SPAN(Type.INLINE, false, null, true),
122         TABLE(Type.CONTAINER_BLOCK, false, null, false, "", true),
123         TABLE_CAPTION(Type.INLINE, false, ElementContext::escapeMarkdown),
124         TABLE_ROW(Type.INLINE, false, null, true), // special handling of newlines
125         TABLE_CELL(
126                 Type.INLINE,
127                 false,
128                 ElementContext::escapeForTableCell,
129                 false), // special type, as allows containing inlines, but not starting on a separate line
130         // same parameters as BODY but paragraphs inside list items are handled differently
131         LIST_ITEM(Type.CONTAINER_BLOCK, false, ElementContext::escapeMarkdown, false, INDENT),
132         BLOCKQUOTE(Type.CONTAINER_BLOCK, false, ElementContext::escapeMarkdown, false, BLOCKQUOTE_START_MARKUP),
133         HTML_BLOCK(Type.GENERIC_CONTAINER, true, ElementContext::escapeHtml, false, "", false);
134 
135         /**
136          * @see <a href="https://spec.commonmark.org/0.30/#blocks-and-inlines">CommonMark, 3 Blocks and inlines</a>
137          */
138         enum Type {
139             /**
140              * Container with no special meaning for (nested) child element contexts
141              */
142             GENERIC_CONTAINER,
143             /**
144              * Is supposed to start on a new line, and must have a prefix (for nested blocks)
145              */
146             CONTAINER_BLOCK,
147             /**
148              * Is supposed to start on a new line, must not contain any other block element context (neither leaf nor container)
149              */
150             LEAF_BLOCK,
151             /**
152              * Are not allowed to contain any other element context (i.e. leaf contexts), except for some other inlines (depends on the actual type)
153              */
154             INLINE
155         }
156         /**
157          * {@code true} if block element, otherwise {@code false} for inline elements
158          */
159         final Type type;
160 
161         /**
162          * The function to call to escape the given text. The function is supposed to return the escaped text or return just the given text if no escaping is necessary in this context
163          */
164         final TextEscapeFunction escapeFunction;
165 
166         /**
167          * if {@code true} requires buffering any text appearing inside this context
168          */
169         final boolean requiresBuffering;
170 
171         /**
172          * prefix to be used for each line of (nested) block elements inside the current container context (only not empty for {@link #type} being {@link Type#CONTAINER_BLOCK})
173          */
174         final String prefix;
175 
176         /**
177          * Only relevant for block element, if set to {@code true} the element requires to be surrounded by blank lines.
178          */
179         final boolean requiresSurroundingByBlankLines;
180 
181         /**
182          * If markup linebreaks (i.e. insignificant linebreaks in the source) are allowed in this context.
183          * This is relevant for markdown as in some contexts (e.g. list items) linebreaks are always significant (while for HTML they wouldn't be)
184          */
185         final boolean allowsMarkupLinebreaks;
186 
187         ElementContext(Type type, boolean allowsMarkupLinebreaks, TextEscapeFunction escapeFunction) {
188             this(type, allowsMarkupLinebreaks, escapeFunction, false);
189         }
190 
191         ElementContext(
192                 Type type,
193                 boolean allowsMarkupLinebreaks,
194                 TextEscapeFunction escapeFunction,
195                 boolean requiresBuffering) {
196             this(type, allowsMarkupLinebreaks, escapeFunction, requiresBuffering, "");
197         }
198 
199         ElementContext(
200                 Type type,
201                 boolean allowsMarkupLinebreaks,
202                 TextEscapeFunction escapeFunction,
203                 boolean requiresBuffering,
204                 String prefix) {
205             this(type, allowsMarkupLinebreaks, escapeFunction, requiresBuffering, prefix, false);
206         }
207 
208         ElementContext(
209                 Type type,
210                 boolean allowsMarkupLinebreaks,
211                 TextEscapeFunction escapeFunction,
212                 boolean requiresBuffering,
213                 String prefix,
214                 boolean requiresSurroundingByBlankLines) {
215             this.type = type;
216             this.allowsMarkupLinebreaks = allowsMarkupLinebreaks;
217             this.escapeFunction = escapeFunction;
218             this.requiresBuffering = requiresBuffering;
219             if (type != Type.CONTAINER_BLOCK && prefix.length() != 0) {
220                 throw new IllegalArgumentException("Only container blocks may define a prefix (for nesting)");
221             }
222             this.prefix = prefix;
223             this.requiresSurroundingByBlankLines = requiresSurroundingByBlankLines;
224         }
225 
226         /**
227          * Must be called for each inline text to be emitted directly within this context (not relevant for nested context)
228          * @param text
229          * @return the escaped text (may be same as {@code text} when no escaping is necessary)
230          */
231         String escape(LastTwoLinesAwareWriter writer, String text) {
232             // is escaping necessary at all?
233             if (escapeFunction == null) {
234                 return text;
235             } else {
236                 return escapeFunction.escape(this, writer, text);
237             }
238         }
239 
240         /**
241          *
242          * @return {@code true} for all block types, {@code false} otherwise
243          */
244         boolean isBlock() {
245             return type == Type.CONTAINER_BLOCK || type == Type.LEAF_BLOCK;
246         }
247 
248         /**
249          *
250          * @return {@code true} if only HTML is allowed in this context
251          */
252         boolean isHtml() {
253             return this.equals(HTML_BLOCK);
254         }
255         /**
256          *
257          * @return {@code true} for all containers (allowing block elements as children), {@code false} otherwise
258          */
259         boolean isContainer() {
260             return type == Type.CONTAINER_BLOCK || type == Type.GENERIC_CONTAINER;
261         }
262 
263         public boolean isAllowsMarkupLinebreaks() {
264             return allowsMarkupLinebreaks;
265         }
266 
267         /**
268          * First use XML escaping (leveraging the predefined entities, for browsers)
269          * afterwards escape special characters in a text with a leading backslash (for markdown parsers)
270          *
271          * <pre>
272          * \, `, *, _, {, }, [, ], (, ), #, +, -, ., !
273          * </pre>
274          *
275          * @param text the string to escape, may be null
276          * @return the text escaped, "" if null String input
277          * @see <a href="https://daringfireball.net/projects/markdown/syntax#backslash">Backslash Escapes</a>
278          */
279         private String escapeMarkdown(LastTwoLinesAwareWriter writer, String text) {
280             if (text == null) {
281                 return "";
282             }
283             text = escapeHtml(writer, text); // assume UTF-8 output, i.e. only use the mandatory XML entities
284             int length = text.length();
285             StringBuilder buffer = new StringBuilder(length);
286 
287             for (int i = 0; i < length; ++i) {
288                 char c = text.charAt(i);
289                 switch (c) {
290                     case '\\':
291                     case '_':
292                     case '`':
293                     case '[':
294                     case ']':
295                     case '(':
296                     case ')':
297                     case '!':
298                         // always escape the previous characters as potentially everywhere relevant
299                         buffer.append(escapeMarkdown(c));
300                         break;
301                     case '*':
302                     case '+':
303                     case '-':
304                         // only relevant for unordered lists or horizontal rules
305                         if (isInBlankLine(buffer, writer)) {
306                             buffer.append(escapeMarkdown(c));
307                         } else {
308                             buffer.append(c);
309                         }
310                         break;
311                     case '=':
312                     case '#':
313                         if (this == HEADING || isInBlankLine(buffer, writer)) {
314                             buffer.append(escapeMarkdown(c));
315                         } else {
316                             buffer.append(c);
317                         }
318                         break;
319                     case '.':
320                         if (isAfterDigit(buffer, writer)) {
321                             buffer.append(escapeMarkdown(c));
322                         } else {
323                             buffer.append(c);
324                         }
325                         break;
326                     default:
327                         buffer.append(c);
328                 }
329             }
330             return buffer.toString();
331         }
332 
333         private static boolean isAfterDigit(StringBuilder buffer, LastTwoLinesAwareWriter writer) {
334             if (buffer.length() > 0) {
335                 return Character.isDigit(buffer.charAt(buffer.length() - 1));
336             } else {
337                 return writer.isAfterDigit();
338             }
339         }
340 
341         private static boolean isInBlankLine(StringBuilder buffer, LastTwoLinesAwareWriter writer) {
342             if (DoxiaStringUtils.isBlank(buffer.toString())) {
343                 return writer.isInBlankLine();
344             }
345             return false;
346         }
347 
348         private static String escapeMarkdown(char c) {
349             return "\\" + c;
350         }
351 
352         private String escapeHtml(LastTwoLinesAwareWriter writer, String text) {
353             return HtmlTools.escapeHTML(text, true);
354         }
355 
356         /**
357          * Escapes the pipe character according to <a href="https://github.github.com/gfm/#tables-extension-">GFM Table Extension</a> in addition
358          * to the regular markdown escaping.
359          * @param text
360          * @return the escaped text
361          * @see {@link #escapeMarkdown(String)
362          */
363         private String escapeForTableCell(LastTwoLinesAwareWriter writer, String text) {
364             return escapeMarkdown(writer, text).replace("|", "\\|");
365         }
366     }
367     // ----------------------------------------------------------------------
368     // Public protected methods
369     // ----------------------------------------------------------------------
370 
371     protected static MarkdownSink newInstance(Writer writer) {
372         BufferingStackWriter bufferingStackWriter = new BufferingStackWriter(writer);
373         LastTwoLinesAwareWriter lineAwareWriter = new LastTwoLinesAwareWriter(bufferingStackWriter);
374         return new MarkdownSink(lineAwareWriter, bufferingStackWriter);
375     }
376 
377     /**
378      * Constructor, initialize the Writer and the variables.
379      *
380      * @param writer not null writer to write the result. <b>Should</b> be an UTF-8 Writer.
381      */
382     private MarkdownSink(LastTwoLinesAwareWriter lineAwareWriter, BufferingStackWriter bufferingStackWriter) {
383         super(lineAwareWriter);
384         this.lineAwareWriter = lineAwareWriter;
385         this.bufferingStackWriter = bufferingStackWriter;
386         initInternal();
387         setInsertNewline(
388                 false); // we want to control newlines on our own to prevent (mostly to not break encapsulating markdown
389         // tables)
390     }
391 
392     private void initInternal() {
393         this.authors = new LinkedList<>();
394         this.title = null;
395         this.date = null;
396         this.linkName = null;
397         this.tableHeaderCellFlag = false;
398         this.cellCount = 0;
399         this.cellJustif = null;
400         this.elementContextStack = Collections.asLifoQueue(new LinkedList<>());
401         this.inlineStack = Collections.asLifoQueue(new LinkedList<>());
402         startContext(ElementContext.ROOT_WITH_BUFFERING);
403     }
404 
405     private void endContext(ElementContext expectedContext) {
406         ElementContext removedContext = elementContextStack.remove();
407         if (removedContext != expectedContext) {
408             throw new IllegalStateException("Unexpected context " + removedContext + ", expected " + expectedContext);
409         }
410         if (removedContext.isBlock()) {
411             endBlock(removedContext.requiresSurroundingByBlankLines
412                     || (isInListItem() && (removedContext == ElementContext.BLOCKQUOTE)
413                             || (removedContext == ElementContext.CODE_BLOCK)));
414         }
415         if (removedContext.requiresBuffering) {
416             // remove buffer from stack (assume it has been evaluated already)
417             bufferingStackWriter.removeBuffer();
418         }
419     }
420 
421     private void startContext(ElementContext newContext) {
422         if (newContext.requiresBuffering) {
423             bufferingStackWriter.addBuffer();
424         }
425         if (newContext.isBlock()) {
426             // every block element within a list item must be surrounded by blank lines
427             startBlock(newContext.requiresSurroundingByBlankLines
428                     || (isInListItem() && (newContext == ElementContext.BLOCKQUOTE)
429                             || (newContext == ElementContext.CODE_BLOCK)));
430         }
431         elementContextStack.add(newContext);
432     }
433 
434     private String toogleToRootContextWithoutBuffering(boolean dumpBuffer) {
435         final String buffer;
436         if (elementContextStack.element() == ElementContext.ROOT_WITH_BUFFERING) {
437             buffer = bufferingStackWriter.getCurrentBuffer().toString();
438             endContext(ElementContext.ROOT_WITH_BUFFERING);
439             if (dumpBuffer) {
440                 write(buffer);
441             }
442             startContext(ElementContext.ROOT_WITHOUT_BUFFERING);
443         } else if (elementContextStack.element() != ElementContext.ROOT_WITHOUT_BUFFERING) {
444             throw new IllegalStateException("Unexpected context " + elementContextStack.element()
445                     + ", expected ROOT_WITH_BUFFERING or ROOT_WITHOUT_BUFFERING");
446         } else {
447             buffer = "";
448         }
449         return buffer;
450     }
451     /**
452      * Ensures that the {@link #writer} is currently at the beginning of a new line.
453      * Optionally writes a line separator to ensure that.
454      */
455     private void ensureBeginningOfLine() {
456         // make sure that we are at the start of a line without adding unnecessary blank lines
457         if (!lineAwareWriter.isWriterAtStartOfNewLine()) {
458             write(EOL);
459         }
460     }
461 
462     /**
463      * Ensures that the {@link #writer} is preceded by a blank line.
464      * Optionally writes a blank line or just line delimiter to ensure that.
465      */
466     private void ensureBlankLine() {
467         // prevent duplicate blank lines
468         if (!lineAwareWriter.isWriterAfterBlankLine()) {
469             if (lineAwareWriter.isWriterAtStartOfNewLine()) {
470                 write(EOL);
471             } else {
472                 write(BLANK_LINE);
473             }
474         }
475     }
476 
477     private void startBlock(boolean requireBlankLine) {
478         if (requireBlankLine) {
479             ensureBlankLine();
480         } else {
481             ensureBeginningOfLine();
482         }
483         write(getLinePrefix());
484     }
485 
486     private void endBlock(boolean requireBlankLine) {
487         if (requireBlankLine) {
488             ensureBlankLine();
489         } else {
490             ensureBeginningOfLine();
491         }
492     }
493 
494     /**
495      * @return the prefix to be used for each line in the current context (i.e. the prefix of the current container context and all its ancestors), may be empty
496      */
497     private String getLinePrefix() {
498         StringBuilder prefix = new StringBuilder();
499         elementContextStack.stream().filter(c -> c.prefix.length() > 0).forEachOrdered(c -> prefix.insert(0, c.prefix));
500         return prefix.toString();
501     }
502 
503     private boolean isInListItem() {
504         return elementContextStack.stream()
505                 .filter(c -> c == ElementContext.LIST_ITEM)
506                 .findFirst()
507                 .isPresent();
508     }
509 
510     @Override
511     protected void init() {
512         super.init();
513         initInternal();
514     }
515 
516     @Override
517     public void head(SinkEventAttributes attributes) {
518         startContext(ElementContext.HEAD);
519     }
520 
521     @Override
522     public void head_() {
523         endContext(ElementContext.HEAD);
524         String priorHeadBuffer = toogleToRootContextWithoutBuffering(false);
525         // only write head block if really necessary
526         if (title == null && authors.isEmpty() && date == null) {
527             return;
528         }
529         write(METADATA_MARKUP + EOL);
530         if (title != null) {
531             write("title: " + title + EOL);
532         }
533         if (!authors.isEmpty()) {
534             write("author: " + EOL);
535             for (String author : authors) {
536                 write("  - " + author + EOL);
537             }
538         }
539         if (date != null) {
540             write("date: " + date + EOL);
541         }
542         write(METADATA_MARKUP + BLANK_LINE);
543         write(priorHeadBuffer);
544     }
545 
546     @Override
547     public void body(SinkEventAttributes attributes) {
548         toogleToRootContextWithoutBuffering(true);
549         startContext(ElementContext.BODY);
550     }
551 
552     @Override
553     public void body_() {
554         endContext(ElementContext.BODY);
555     }
556 
557     @Override
558     public void title_() {
559         String buffer = bufferingStackWriter.getAndClearCurrentBuffer();
560         if (!buffer.isEmpty()) {
561             this.title = buffer;
562         }
563     }
564 
565     @Override
566     public void author_() {
567         String buffer = bufferingStackWriter.getAndClearCurrentBuffer();
568         if (!buffer.isEmpty()) {
569             authors.add(buffer);
570         }
571     }
572 
573     @Override
574     public void date_() {
575         String buffer = bufferingStackWriter.getAndClearCurrentBuffer();
576         if (!buffer.isEmpty()) {
577             date = buffer;
578         }
579     }
580 
581     @Override
582     public void section(int level, SinkEventAttributes attributes) {
583         // not supported as often used around sectionTitles which would otherwise no longer be emitted as markdown
584     }
585 
586     @Override
587     public void section_(int level) {
588         // not supported as often used around sectionTitles which would otherwise no longer be emitted as markdown
589     }
590 
591     @Override
592     public void header(SinkEventAttributes attributes) {
593         // not supported as often used around sectionTitles which would otherwise no longer be emitted as markdown
594     }
595 
596     @Override
597     public void header_() {
598         // not supported as often used around sectionTitles which would otherwise no longer be emitted as markdown
599     }
600 
601     @Override
602     public void sectionTitle(int level, SinkEventAttributes attributes) {
603         startContext(ElementContext.HEADING);
604         if (level > 0) {
605             write(DoxiaStringUtils.repeat(SECTION_TITLE_START_MARKUP, level) + SPACE);
606         }
607     }
608 
609     @Override
610     public void sectionTitle_(int level) {
611         endContext(ElementContext.HEADING);
612         if (level > 0) {
613             ensureBlankLine(); // always end headings with blank line to increase compatibility with arbitrary MD
614             // editors
615         }
616     }
617 
618     @Override
619     public void list(SinkEventAttributes attributes) {
620         if (elementContextStack.element().isHtml()) {
621             super.list(attributes);
622         }
623     }
624 
625     @Override
626     public void list_() {
627         ensureBeginningOfLine();
628     }
629 
630     @Override
631     public void listItem(SinkEventAttributes attributes) {
632         startContext(ElementContext.LIST_ITEM);
633         write(LIST_UNORDERED_ITEM_START_MARKUP);
634     }
635 
636     @Override
637     public void listItem_() {
638         endContext(ElementContext.LIST_ITEM);
639     }
640 
641     @Override
642     public void numberedList(int numbering, SinkEventAttributes attributes) {
643         // markdown only supports decimal numbering
644         if (numbering != NUMBERING_DECIMAL) {
645             LOGGER.warn(
646                     "{}Markdown only supports numbered item with decimal style ({}) but requested was style {}, falling back to decimal style",
647                     getLocationLogPrefix(),
648                     NUMBERING_DECIMAL,
649                     numbering);
650         }
651     }
652 
653     @Override
654     public void numberedList_() {
655         ensureBeginningOfLine();
656     }
657 
658     @Override
659     public void numberedListItem(SinkEventAttributes attributes) {
660         startContext(ElementContext.LIST_ITEM);
661         write(LIST_ORDERED_ITEM_START_MARKUP);
662     }
663 
664     @Override
665     public void numberedListItem_() {
666         listItem_(); // identical for both numbered and not numbered list item
667     }
668 
669     @Override
670     public void definitionList(SinkEventAttributes attributes) {
671         LOGGER.warn(
672                 "{}Definition list not natively supported in Markdown, rendering HTML instead", getLocationLogPrefix());
673         startContext(ElementContext.HTML_BLOCK);
674         write("<dl>" + EOL);
675     }
676 
677     @Override
678     public void definitionList_() {
679         write("</dl>");
680         endContext(ElementContext.HTML_BLOCK);
681     }
682 
683     @Override
684     public void definedTerm(SinkEventAttributes attributes) {
685         write("<dt>");
686     }
687 
688     @Override
689     public void definedTerm_() {
690         write("</dt>" + EOL);
691     }
692 
693     @Override
694     public void definition(SinkEventAttributes attributes) {
695         write("<dd>");
696     }
697 
698     @Override
699     public void definition_() {
700         write("</dd>" + EOL);
701     }
702 
703     @Override
704     public void pageBreak() {
705         LOGGER.warn("{}Ignoring unsupported page break in Markdown", getLocationLogPrefix());
706     }
707 
708     @Override
709     public void paragraph(SinkEventAttributes attributes) {
710         // ignore paragraphs outside container contexts
711         if (elementContextStack.element().isContainer()) {
712             ensureBlankLine();
713             write(getLinePrefix());
714         } else {
715             LOGGER.warn(
716                     "{}Paragraphs outside of container contexts are not supported in Markdown, ignoring paragraph event in context {}",
717                     getLocationLogPrefix(),
718                     elementContextStack.element());
719         }
720     }
721 
722     @Override
723     public void paragraph_() {
724         // ignore paragraphs outside container contexts
725         if (elementContextStack.element().isContainer()) {
726             ensureBlankLine();
727         }
728     }
729 
730     @Override
731     public void verbatim(SinkEventAttributes attributes) {
732         if (!elementContextStack.element().isContainer()) {
733             // markdown doesn't allow block elements but one can instead rely on html blocks for this
734             startContext(ElementContext.HTML_BLOCK);
735             isVerbatimHtmlContext = true;
736         } else {
737             isVerbatimHtmlContext = false;
738         }
739 
740         if (elementContextStack.element().isHtml()) {
741             super.verbatim(attributes);
742         } else {
743             // if no source attribute, then don't emit an info string
744             startContext(ElementContext.CODE_BLOCK);
745             write(VERBATIM_START_MARKUP);
746             if (attributes != null && attributes.containsAttributes(SinkEventAttributeSet.SOURCE)) {
747                 write("unknown"); // unknown language
748             }
749             write(EOL);
750             write(getLinePrefix());
751         }
752     }
753 
754     @Override
755     public void verbatim_() {
756         if (elementContextStack.element().isHtml()) {
757             super.verbatim_();
758             if (isVerbatimHtmlContext) {
759                 endContext(ElementContext.HTML_BLOCK);
760                 isVerbatimHtmlContext = false;
761             }
762         } else {
763             ensureBeginningOfLine();
764             write(getLinePrefix());
765             write(VERBATIM_END_MARKUP + BLANK_LINE);
766             endContext(ElementContext.CODE_BLOCK);
767         }
768     }
769 
770     @Override
771     public void blockquote(SinkEventAttributes attributes) {
772         if (elementContextStack.element().isHtml()) {
773             super.blockquote(attributes);
774         } else {
775             startContext(ElementContext.BLOCKQUOTE);
776             write(BLOCKQUOTE_START_MARKUP);
777         }
778     }
779 
780     @Override
781     public void blockquote_() {
782         if (elementContextStack.element().isHtml()) {
783             super.blockquote_();
784         } else {
785             endContext(ElementContext.BLOCKQUOTE);
786         }
787     }
788 
789     @Override
790     public void horizontalRule(SinkEventAttributes attributes) {
791         ensureBeginningOfLine();
792         write(HORIZONTAL_RULE_MARKUP + BLANK_LINE);
793         write(getLinePrefix());
794     }
795 
796     @Override
797     public void table(SinkEventAttributes attributes) {
798         if (elementContextStack.element().isHtml()) {
799             super.table(attributes);
800         } else {
801             startContext(ElementContext.TABLE);
802         }
803     }
804 
805     @Override
806     public void table_() {
807         if (elementContextStack.element().isHtml()) {
808             super.table_();
809         } else {
810             endContext(ElementContext.TABLE);
811         }
812     }
813 
814     @Override
815     public void tableRows(int[] justification, boolean grid) {
816         if (elementContextStack.element().isHtml()) {
817             super.tableRows(justification, grid);
818         } else {
819             if (justification != null) {
820                 cellJustif = Arrays.stream(justification).boxed().collect(Collectors.toCollection(ArrayList::new));
821             } else {
822                 cellJustif = new ArrayList<>();
823             }
824             // grid flag is not supported
825             isFirstTableRow = true;
826         }
827     }
828 
829     @Override
830     public void tableRows_() {
831         if (elementContextStack.element().isHtml()) {
832             super.tableRows_();
833         } else {
834             cellJustif = null;
835         }
836     }
837 
838     @Override
839     public void tableRow(SinkEventAttributes attributes) {
840         if (elementContextStack.element().isHtml()) {
841             super.tableRow(attributes);
842         } else {
843             startContext(ElementContext.TABLE_ROW);
844             cellCount = 0;
845         }
846     }
847 
848     @Override
849     public void tableRow_() {
850         if (elementContextStack.element().isHtml()) {
851             super.tableRow_();
852         } else {
853             String buffer = bufferingStackWriter.getAndClearCurrentBuffer();
854             endContext(ElementContext.TABLE_ROW);
855             if (isFirstTableRow && !tableHeaderCellFlag) {
856                 // emit empty table header as this is mandatory for GFM table extension
857                 // (https://stackoverflow.com/a/17543474/5155923)
858                 writeEmptyTableHeader();
859                 writeTableDelimiterRow();
860                 tableHeaderCellFlag = false;
861                 isFirstTableRow = false;
862                 // afterwards emit the first row
863             }
864             write(TABLE_ROW_PREFIX);
865             write(buffer);
866             write(EOL);
867             if (isFirstTableRow) {
868                 // emit delimiter row
869                 writeTableDelimiterRow();
870                 isFirstTableRow = false;
871             }
872             // only reset cell count if this is the last row
873             cellCount = 0;
874         }
875     }
876 
877     private void writeEmptyTableHeader() {
878         write(TABLE_ROW_PREFIX);
879         for (int i = 0; i < cellCount; i++) {
880             write(DoxiaStringUtils.repeat(String.valueOf(SPACE), 3) + TABLE_CELL_SEPARATOR_MARKUP);
881         }
882         write(EOL);
883         write(getLinePrefix());
884     }
885 
886     /** Emit the delimiter row which determines the alignment */
887     private void writeTableDelimiterRow() {
888         write(TABLE_ROW_PREFIX);
889         int justification = Sink.JUSTIFY_DEFAULT;
890         for (int i = 0; i < cellCount; i++) {
891             // keep previous column's alignment in case too few are specified
892             if (cellJustif != null && cellJustif.size() > i) {
893                 justification = cellJustif.get(i);
894             }
895             switch (justification) {
896                 case Sink.JUSTIFY_RIGHT:
897                     write(TABLE_COL_RIGHT_ALIGNED_MARKUP);
898                     break;
899                 case Sink.JUSTIFY_CENTER:
900                     write(TABLE_COL_CENTER_ALIGNED_MARKUP);
901                     break;
902                 case Sink.JUSTIFY_LEFT:
903                     write(TABLE_COL_LEFT_ALIGNED_MARKUP);
904                     break;
905                 default:
906                     write(TABLE_COL_DEFAULT_ALIGNED_MARKUP);
907                     break;
908             }
909             write(TABLE_CELL_SEPARATOR_MARKUP);
910         }
911         write(EOL);
912     }
913 
914     @Override
915     public void tableCell(SinkEventAttributes attributes) {
916         if (elementContextStack.element().isHtml()) {
917             super.tableCell(attributes);
918         } else {
919             startContext(ElementContext.TABLE_CELL);
920             if (attributes != null) {
921                 // evaluate alignment attributes
922                 final int cellJustification;
923                 if (attributes.containsAttributes(SinkEventAttributeSet.LEFT)) {
924                     cellJustification = Sink.JUSTIFY_LEFT;
925                 } else if (attributes.containsAttributes(SinkEventAttributeSet.RIGHT)) {
926                     cellJustification = Sink.JUSTIFY_RIGHT;
927                 } else if (attributes.containsAttributes(SinkEventAttributeSet.CENTER)) {
928                     cellJustification = Sink.JUSTIFY_CENTER;
929                 } else {
930                     cellJustification = -1;
931                 }
932                 if (cellJustification > -1) {
933                     if (cellJustif.size() > cellCount) {
934                         cellJustif.set(cellCount, cellJustification);
935                     } else if (cellJustif.size() == cellCount) {
936                         cellJustif.add(cellJustification);
937                     } else {
938                         // create non-existing justifications for preceding columns
939                         for (int precedingCol = cellJustif.size(); precedingCol < cellCount; precedingCol++) {
940                             cellJustif.add(Sink.JUSTIFY_DEFAULT);
941                         }
942                         cellJustif.add(cellJustification);
943                     }
944                 }
945             }
946         }
947     }
948 
949     @Override
950     public void tableHeaderCell(SinkEventAttributes attributes) {
951         if (elementContextStack.element().isHtml()) {
952             super.tableHeaderCell(attributes);
953         } else {
954             tableCell(attributes);
955             tableHeaderCellFlag = true;
956         }
957     }
958 
959     @Override
960     public void tableCell_() {
961         if (elementContextStack.element().isHtml()) {
962             super.tableCell_();
963         } else {
964             endTableCell();
965         }
966     }
967 
968     @Override
969     public void tableHeaderCell_() {
970         if (elementContextStack.element().isHtml()) {
971             super.tableHeaderCell_();
972         } else {
973             endTableCell();
974         }
975     }
976 
977     /**
978      * Ends a table cell.
979      */
980     private void endTableCell() {
981         endContext(ElementContext.TABLE_CELL);
982         write(TABLE_CELL_SEPARATOR_MARKUP);
983         cellCount++;
984     }
985 
986     @Override
987     public void tableCaption(SinkEventAttributes attributes) {
988         if (elementContextStack.element().isHtml()) {
989             super.tableCaption(attributes);
990         } else {
991             elementContextStack.add(ElementContext.TABLE_CAPTION);
992         }
993     }
994 
995     @Override
996     public void tableCaption_() {
997         if (elementContextStack.element().isHtml()) {
998             super.tableCaption_();
999         } else {
1000             endContext(ElementContext.TABLE_CAPTION);
1001         }
1002     }
1003 
1004     @Override
1005     public void figure(SinkEventAttributes attributes) {
1006         if (elementContextStack.element().isHtml()) {
1007             super.figure(attributes);
1008         } else {
1009             figureSrc = null;
1010             startContext(ElementContext.FIGURE);
1011         }
1012     }
1013 
1014     @Override
1015     public void figureCaption(SinkEventAttributes attributes) {
1016         if (elementContextStack.element().isHtml()) {
1017             super.figureCaption(attributes);
1018         }
1019     }
1020 
1021     @Override
1022     public void figureCaption_() {
1023         if (elementContextStack.element().isHtml()) {
1024             super.figureCaption_();
1025         }
1026     }
1027 
1028     @Override
1029     public void figureGraphics(String name, SinkEventAttributes attributes) {
1030         if (elementContextStack.element().isHtml()) {
1031             super.figureGraphics(name, attributes);
1032         } else {
1033             figureSrc = name;
1034             // is it a standalone image (outside a figure)?
1035             if (elementContextStack.peek() != ElementContext.FIGURE) {
1036                 Object alt = attributes.getAttribute(SinkEventAttributes.ALT);
1037                 if (alt == null) {
1038                     alt = "";
1039                 }
1040                 writeImage(elementContextStack.element().escape(lineAwareWriter, alt.toString()), name);
1041             }
1042         }
1043     }
1044 
1045     @Override
1046     public void figure_() {
1047         if (elementContextStack.element().isHtml()) {
1048             super.figure_();
1049         } else {
1050             String label = bufferingStackWriter.getCurrentBuffer().toString();
1051             endContext(ElementContext.FIGURE);
1052             writeImage(label, figureSrc);
1053         }
1054     }
1055 
1056     private void writeImage(String alt, String src) {
1057         write("![");
1058         write(alt);
1059         write("](" + src + ")");
1060     }
1061 
1062     public void anchor(String name, SinkEventAttributes attributes) {
1063         super.anchor(name, attributes);
1064         if (!elementContextStack.element().isHtml()) {
1065             // close anchor tag immediately otherwise markdown would not be allowed afterwards
1066             write("</a>");
1067         }
1068     }
1069 
1070     @Override
1071     public void anchor_() {
1072         if (elementContextStack.element().isHtml()) {
1073             super.anchor_();
1074         } else {
1075             // anchor is always empty html element, i.e. already closed with anchor()
1076         }
1077     }
1078 
1079     public void link(String name, SinkEventAttributes attributes) {
1080         if (elementContextStack.element().isHtml()) {
1081             super.link(name, attributes);
1082         } else {
1083             if (elementContextStack.element() == ElementContext.CODE_BLOCK) {
1084                 LOGGER.warn("{}Ignoring unsupported link inside code block", getLocationLogPrefix());
1085             } else if (elementContextStack.element() == ElementContext.CODE_SPAN) {
1086                 // emit link outside the code span, i.e. insert at the beginning of the buffer
1087                 bufferingStackWriter.getCurrentBuffer().insert(0, LINK_START_1_MARKUP);
1088                 linkName = name;
1089             } else {
1090                 write(LINK_START_1_MARKUP);
1091                 linkName = name;
1092             }
1093         }
1094     }
1095 
1096     @Override
1097     public void link_() {
1098         if (elementContextStack.element().isHtml()) {
1099             super.link_();
1100         } else {
1101             if (elementContextStack.element() == ElementContext.CODE_BLOCK) {
1102                 return;
1103             } else if (elementContextStack.element() == ElementContext.CODE_SPAN) {
1104                 // defer emitting link end markup until inline_() is called
1105                 StringBuilder linkEndMarkup = new StringBuilder();
1106                 linkEndMarkup.append(LINK_START_2_MARKUP);
1107                 linkEndMarkup.append(linkName);
1108                 linkEndMarkup.append(LINK_END_MARKUP);
1109                 Queue<String> endMarkups = new LinkedList<>(inlineStack.poll());
1110                 endMarkups.add(linkEndMarkup.toString());
1111                 inlineStack.add(endMarkups);
1112             } else {
1113                 write(LINK_START_2_MARKUP + linkName + LINK_END_MARKUP);
1114             }
1115             linkName = null;
1116         }
1117     }
1118 
1119     @Override
1120     public void inline(SinkEventAttributes attributes) {
1121         Queue<String> endMarkups = Collections.asLifoQueue(new LinkedList<>());
1122         if (elementContextStack.element().isHtml()) {
1123             super.inline(attributes);
1124             endMarkups.add(USE_XHTML_SINK);
1125         } else {
1126             boolean requiresHtml = elementContextStack.element() == ElementContext.HTML_BLOCK;
1127             if (attributes != null
1128                     && elementContextStack.element() != ElementContext.CODE_BLOCK
1129                     && elementContextStack.element() != ElementContext.CODE_SPAN) {
1130                 // code excludes other styles in markdown
1131                 if (attributes.containsAttributes(SinkEventAttributeSet.Semantics.CODE)
1132                         || attributes.containsAttributes(SinkEventAttributeSet.Semantics.MONOSPACED)
1133                         || attributes.containsAttributes(SinkEventAttributeSet.MONOSPACED)) {
1134                     if (requiresHtml) {
1135                         write("<code>");
1136                         endMarkups.add("</code>");
1137                     } else {
1138                         startContext(ElementContext.CODE_SPAN);
1139                         write(MONOSPACED_START_MARKUP);
1140                         endMarkups.add(MONOSPACED_END_MARKUP);
1141                     }
1142                 } else {
1143                     SinkEventAttributeSet remainingAttributes = new SinkEventAttributeSet(attributes);
1144                     // in XHTML "<em>" is used, but some tests still rely on the outdated "<italic>"
1145                     if (filterAttributes(
1146                             remainingAttributes,
1147                             SinkEventAttributeSet.Semantics.EMPHASIS,
1148                             SinkEventAttributeSet.Semantics.ITALIC,
1149                             SinkEventAttributeSet.ITALIC)) {
1150                         if (requiresHtml) {
1151                             write("<em>");
1152                             endMarkups.add("</em>");
1153                         } else {
1154                             write(ITALIC_START_MARKUP);
1155                             endMarkups.add(ITALIC_END_MARKUP);
1156                         }
1157                     }
1158                     // in XHTML "<strong>" is used, but some tests still rely on the outdated "<bold>"
1159                     if (filterAttributes(
1160                             remainingAttributes,
1161                             SinkEventAttributeSet.Semantics.STRONG,
1162                             SinkEventAttributeSet.Semantics.BOLD,
1163                             SinkEventAttributeSet.BOLD)) {
1164                         if (requiresHtml) {
1165                             write("<strong>");
1166                             endMarkups.add("</strong>");
1167                         } else {
1168                             write(BOLD_START_MARKUP);
1169                             endMarkups.add(BOLD_END_MARKUP);
1170                         }
1171                     }
1172                     // <del> is supported via GFM strikethrough extension
1173                     if (filterAttributes(remainingAttributes, SinkEventAttributeSet.Semantics.DELETE)) {
1174                         if (requiresHtml) {
1175                             write("<del>");
1176                             endMarkups.add("</del>");
1177                         } else {
1178                             write(STRIKETHROUGH_START_MARKUP);
1179                             endMarkups.add(STRIKETHROUGH_END_MARKUP);
1180                         }
1181                     }
1182                     if (!remainingAttributes.isEmpty()) {
1183                         // use HTML for other inline semantics which are not natively supported in Markdown (e.g.
1184                         // subscript, superscript, small, etc.)
1185                         super.inline(remainingAttributes);
1186                         endMarkups.add(USE_XHTML_SINK);
1187                     }
1188                 }
1189             }
1190         }
1191         inlineStack.add(endMarkups);
1192     }
1193 
1194     private static boolean filterAttributes(MutableAttributeSet attributes, AttributeSet... attributesToFilter) {
1195         boolean hasAny = false;
1196         for (AttributeSet attributeSet : attributesToFilter) {
1197             if (attributes.containsAttributes(attributeSet)) {
1198                 hasAny = true;
1199                 attributes.removeAttributes(attributeSet);
1200             }
1201         }
1202         return hasAny;
1203     }
1204 
1205     @Override
1206     public void inline_() {
1207         for (String endMarkup : inlineStack.remove()) {
1208             if (USE_XHTML_SINK.equals(endMarkup)) {
1209                 super.inline_();
1210             } else {
1211                 if (endMarkup.equals(MONOSPACED_END_MARKUP)) {
1212                     String buffer = bufferingStackWriter.getCurrentBuffer().toString();
1213                     endContext(ElementContext.CODE_SPAN);
1214                     write(buffer);
1215                 }
1216                 write(endMarkup);
1217             }
1218         }
1219     }
1220 
1221     @Override
1222     public void italic() {
1223         inline(SinkEventAttributeSet.Semantics.ITALIC);
1224     }
1225 
1226     @Override
1227     public void italic_() {
1228         inline_();
1229     }
1230 
1231     @Override
1232     public void bold() {
1233         inline(SinkEventAttributeSet.Semantics.BOLD);
1234     }
1235 
1236     @Override
1237     public void bold_() {
1238         inline_();
1239     }
1240 
1241     @Override
1242     public void monospaced() {
1243         inline(SinkEventAttributeSet.Semantics.CODE);
1244     }
1245 
1246     @Override
1247     public void monospaced_() {
1248         inline_();
1249     }
1250 
1251     @Override
1252     public void lineBreak(SinkEventAttributes attributes) {
1253         if (elementContextStack.element() == ElementContext.TABLE_CELL) {
1254             super.lineBreak(attributes);
1255         } else {
1256             if (elementContextStack.element() == ElementContext.CODE_BLOCK) {
1257                 write(EOL);
1258             } else {
1259                 write("" + SPACE + SPACE + EOL);
1260             }
1261             write(getLinePrefix());
1262         }
1263     }
1264 
1265     @Override
1266     public void nonBreakingSpace() {
1267         write(NON_BREAKING_SPACE_MARKUP);
1268     }
1269 
1270     @Override
1271     public void text(String text, SinkEventAttributes attributes) {
1272         if (elementContextStack.element().isHtml()) {
1273             super.text(text, attributes);
1274         } else {
1275             if (attributes != null) {
1276                 inline(attributes);
1277             }
1278             ElementContext currentContext = elementContextStack.element();
1279             if (currentContext == ElementContext.TABLE_CAPTION) {
1280                 // table caption cannot even be emitted via XHTML in markdown as there is no suitable location
1281                 LOGGER.warn("{}Ignoring unsupported table caption in Markdown", getLocationLogPrefix());
1282             } else {
1283                 String unifiedText = currentContext.escape(lineAwareWriter, unifyEOLs(text));
1284                 // ignore newlines only, because those are emitted often coming from linebreaks in HTML with no
1285                 // semantical
1286                 // meaning
1287                 if (!unifiedText.equals(EOL)) {
1288                     String prefix = getLinePrefix();
1289                     if (prefix.length() > 0) {
1290                         unifiedText = unifiedText.replaceAll(EOL, EOL + prefix);
1291                     }
1292                 }
1293                 write(unifiedText);
1294             }
1295             if (attributes != null) {
1296                 inline_();
1297             }
1298         }
1299     }
1300 
1301     @Override
1302     public void rawText(String text) {
1303         write(text);
1304     }
1305 
1306     /**
1307      * {@inheritDoc}
1308      *
1309      * Unknown events just log a warning message but are ignored otherwise.
1310      * @see org.apache.maven.doxia.sink.Sink#unknown(String,Object[],SinkEventAttributes)
1311      */
1312     @Override
1313     public void unknown(String name, Object[] requiredParams, SinkEventAttributes attributes) {
1314         LOGGER.warn("{}Unknown Sink event '" + name + "', ignoring!", getLocationLogPrefix());
1315     }
1316 
1317     @Override
1318     public void markupLineBreak(int indentLevel) {
1319         // not allowed in all contexts
1320         if (elementContextStack.element().isAllowsMarkupLinebreaks()) {
1321             if (!lineAwareWriter.isWriterAfterBlankLine()) {
1322                 super.markupLineBreak(indentLevel);
1323             }
1324         }
1325     }
1326 
1327     @Override
1328     public void close() {
1329         toogleToRootContextWithoutBuffering(true);
1330         super.close();
1331     }
1332 }